Skip to content

Commit 85cbddf

Browse files
committed
feat(m3-react): Added Select and Slider parity with Storybook updates
Added M3Select and M3Slider components with public exports for React. Added Storybook stories/docs for both components and country flag assets for M3Select. Updated story sorting and reworked M3Link stories to show custom controls based on M3Link, including target=_blank for example.com links.
1 parent d6b2257 commit 85cbddf

15 files changed

Lines changed: 1600 additions & 22 deletions

File tree

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import type {
2+
FC,
3+
HTMLAttributes,
4+
ReactElement,
5+
ReactNode,
6+
SVGAttributes,
7+
} from 'react'
8+
9+
import type { Placement } from '@floating-ui/dom'
10+
11+
import {
12+
M3Menu,
13+
M3MenuItem,
14+
} from '@/components/menu'
15+
import { M3ScrollRail } from '@/components/scroll-rail'
16+
import { M3TextField } from '@/components/text-field'
17+
18+
import {
19+
useCallback,
20+
useEffect,
21+
useMemo,
22+
useRef,
23+
useState,
24+
} from 'react'
25+
26+
import {
27+
useId,
28+
} from '@/hooks'
29+
30+
import { distinct } from '@/utils/content'
31+
import { toClassName } from '@/utils/styling'
32+
33+
export type M3SelectOption<Value = unknown> = {
34+
value: Value;
35+
label: string;
36+
}
37+
38+
type SelectValue<Value> = Value | null
39+
type SlotContext<Value> = {
40+
active: boolean;
41+
option: M3SelectOption<Value>;
42+
}
43+
44+
export interface M3SelectProps<Value = unknown> extends HTMLAttributes<HTMLElement> {
45+
id?: string;
46+
value?: SelectValue<Value>;
47+
label?: string;
48+
options?: Array<M3SelectOption<Value>>;
49+
equalPredicate?: (a: SelectValue<Value>, b: SelectValue<Value>) => boolean;
50+
invalid?: boolean;
51+
placeholder?: string;
52+
placement?: Placement;
53+
disabled?: boolean;
54+
readonly?: boolean;
55+
outlined?: boolean;
56+
onUpdate?: (value: Value) => void;
57+
}
58+
59+
const CaretIcon: FC<SVGAttributes<SVGSVGElement>> = (attrs) => (
60+
<svg
61+
xmlns="http://www.w3.org/2000/svg"
62+
width="24"
63+
height="24"
64+
viewBox="0 0 24 24"
65+
{...attrs}
66+
>
67+
<path d="M9.5 17L14.5 12L9.5 7V17Z" fill="currentColor" />
68+
</svg>
69+
)
70+
71+
const Leading: FC<{ children: ReactNode }> = props => <>{props.children}</>
72+
const OptionLeading: FC<{ children: ReactNode }> = props => <>{props.children}</>
73+
const OptionContent: FC<{ children: ReactNode }> = props => <>{props.children}</>
74+
75+
const asRenderProp = <Context,>(value: unknown): null | ((context: Context) => ReactNode) => {
76+
return typeof value === 'function' ? value as (context: Context) => ReactNode : null
77+
}
78+
79+
const renderSlot = <Context,>(slot: ReactElement | null, context: Context): ReactNode => {
80+
if (!slot) {
81+
return null
82+
}
83+
84+
const child = (slot.props as { children?: unknown }).children
85+
const renderProp = asRenderProp<Context>(child)
86+
87+
return renderProp ? renderProp(context) : child as ReactNode
88+
}
89+
90+
const M3Select = <Value,>({
91+
id,
92+
value = null,
93+
label = '',
94+
options = [],
95+
equalPredicate = (a, b) => a === b,
96+
invalid = false,
97+
placeholder = '',
98+
placement = 'bottom-start',
99+
disabled = false,
100+
readonly = false,
101+
outlined = false,
102+
className = '',
103+
children = [],
104+
onUpdate = (_: Value) => {},
105+
...attrs
106+
}: M3SelectProps<Value>) => {
107+
const _id = useId(id, 'm3-select')
108+
109+
const [expanded, setExpanded] = useState(false)
110+
const [shouldBeExpanded, setShouldBeExpanded] = useState(false)
111+
const [rootWidth, setRootWidth] = useState(0)
112+
113+
const root = useRef<HTMLDivElement | null>(null)
114+
115+
const [slots] = useMemo(() => distinct(children, {
116+
leading: Leading,
117+
optionLeading: OptionLeading,
118+
optionContent: OptionContent,
119+
}), [children])
120+
121+
const text = useMemo(() => {
122+
return options.find(option => equalPredicate(option.value, value))?.label ?? ''
123+
}, [
124+
options,
125+
value,
126+
equalPredicate,
127+
])
128+
129+
const pick = useCallback((option: M3SelectOption<Value>) => {
130+
onUpdate(option.value)
131+
setShouldBeExpanded(false)
132+
}, [
133+
onUpdate,
134+
])
135+
136+
useEffect(() => {
137+
const _root = root.current
138+
if (!_root) {
139+
return
140+
}
141+
142+
setRootWidth(_root.offsetWidth)
143+
144+
let frameId: number | null = null
145+
const observer = new ResizeObserver(([entry]) => {
146+
if (!entry) {
147+
return
148+
}
149+
150+
if (frameId !== null) {
151+
cancelAnimationFrame(frameId)
152+
}
153+
154+
frameId = requestAnimationFrame(() => setRootWidth(entry.contentRect.width))
155+
})
156+
157+
observer.observe(_root)
158+
159+
return () => {
160+
observer.disconnect()
161+
162+
if (frameId !== null) {
163+
cancelAnimationFrame(frameId)
164+
}
165+
}
166+
}, [])
167+
168+
return (
169+
<div
170+
ref={root}
171+
aria-controls={_id + '-menu'}
172+
aria-expanded={expanded ? 'true' : 'false'}
173+
aria-disabled={disabled ? 'true' : 'false'}
174+
aria-readonly={readonly ? 'true' : 'false'}
175+
aria-haspopup="listbox"
176+
role="combobox"
177+
className={toClassName([className, {
178+
'm3-select': true,
179+
'm3-select_expanded': shouldBeExpanded,
180+
}])}
181+
{...attrs}
182+
>
183+
<M3TextField
184+
id={_id}
185+
value={text}
186+
label={label}
187+
placeholder={placeholder}
188+
invalid={invalid}
189+
readonly={readonly}
190+
outlined={outlined}
191+
className="m3-select__field"
192+
>
193+
{slots.leading ? (
194+
<M3TextField.LeadingIcon>
195+
{renderSlot(slots.leading, { active: shouldBeExpanded })}
196+
</M3TextField.LeadingIcon>
197+
) : null}
198+
199+
<M3TextField.TrailingIcon>
200+
<CaretIcon
201+
aria-hidden="true"
202+
className="m3-select__caret"
203+
/>
204+
</M3TextField.TrailingIcon>
205+
</M3TextField>
206+
207+
<M3Menu
208+
id={_id + '-menu'}
209+
shown={shouldBeExpanded}
210+
target={root.current}
211+
placement={placement}
212+
aria-hidden={expanded ? 'false' : 'true'}
213+
disabled={disabled || readonly}
214+
style={{ width: rootWidth + 'px' }}
215+
role="listbox"
216+
onToggle={(shown) => {
217+
setExpanded(shown)
218+
setShouldBeExpanded(shown)
219+
}}
220+
>
221+
<div className="m3-select__scroll-box">
222+
<M3ScrollRail />
223+
224+
{options.map((option, index) => (
225+
<M3MenuItem
226+
key={index}
227+
selected={equalPredicate(option.value, value)}
228+
role="option"
229+
onClick={() => pick(option)}
230+
>
231+
{slots.optionLeading ? (
232+
<M3MenuItem.Leading>
233+
{renderSlot<SlotContext<Value>>(slots.optionLeading, {
234+
option,
235+
active: shouldBeExpanded,
236+
})}
237+
</M3MenuItem.Leading>
238+
) : null}
239+
240+
{slots.optionContent ? (
241+
renderSlot<SlotContext<Value>>(slots.optionContent, {
242+
option,
243+
active: shouldBeExpanded,
244+
})
245+
) : option.label}
246+
</M3MenuItem>
247+
))}
248+
</div>
249+
</M3Menu>
250+
</div>
251+
)
252+
}
253+
254+
export default Object.assign(M3Select, {
255+
Leading,
256+
OptionLeading,
257+
OptionContent,
258+
})
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export type {
2+
M3SelectProps,
3+
M3SelectOption,
4+
} from './M3Select'
5+
6+
export { default as M3Select } from './M3Select'

0 commit comments

Comments
 (0)