Skip to content

Commit 38c1dc3

Browse files
committed
feat: Select POC
1 parent 27e6aa5 commit 38c1dc3

File tree

5 files changed

+410
-2
lines changed

5 files changed

+410
-2
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Select } from '@solved-ac/ui-react'
2+
import { ComponentMeta, ComponentStory } from '@storybook/react'
3+
import React from 'react'
4+
5+
export default {
6+
title: 'Components/Select',
7+
component: Select,
8+
argTypes: {
9+
value: {
10+
control: 'text',
11+
description: 'The value of the select',
12+
},
13+
items: {
14+
control: 'none',
15+
},
16+
},
17+
} as ComponentMeta<typeof Select>
18+
19+
const Template: ComponentStory<typeof Select> = (args) => <Select {...args} />
20+
21+
export const Default = Template.bind({})
22+
Default.args = {
23+
value: 'Select',
24+
items: Array(10)
25+
.fill(undefined)
26+
.map((_, i) => `Item ${i}`),
27+
}
28+
29+
export const CustomRender = Template.bind({})
30+
CustomRender.args = {
31+
value: 'Select',
32+
items: ['kr', 'gb', 'us', 'jp'],
33+
render: (s: string) => (
34+
<>
35+
<img src={`https://flagicons.lipis.dev/flags/4x3/${s}.svg`} alt={s} /> {s}
36+
</>
37+
),
38+
}

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,5 +76,8 @@
7676
},
7777
"files": [
7878
"dist"
79-
]
79+
],
80+
"dependencies": {
81+
"@floating-ui/react-dom-interactions": "^0.8.0"
82+
}
8083
}

src/components/Select.tsx

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
import {
2+
autoUpdate,
3+
flip,
4+
FloatingFocusManager,
5+
FloatingOverlay,
6+
inner,
7+
offset,
8+
shift,
9+
SideObject,
10+
size,
11+
useClick,
12+
useDismiss,
13+
useFloating,
14+
useInnerOffset,
15+
useInteractions,
16+
useListNavigation,
17+
useRole,
18+
useTypeahead
19+
} from '@floating-ui/react-dom-interactions'
20+
import React, {
21+
ElementType,
22+
ReactNode,
23+
useEffect,
24+
useLayoutEffect,
25+
useRef,
26+
useState
27+
} from 'react'
28+
import { PC, PP } from '../types/PolymorphicElementProps'
29+
30+
// Adopted from https://codesandbox.io/s/shy-snowflake-kp6479?file=/src/Select.tsx:5939-5954
31+
32+
type SelectItemNode = string | { value: string }
33+
34+
export interface SelectProps<T extends SelectItemNode = string> {
35+
fullWidth?: boolean
36+
items?: T[]
37+
value?: string | null
38+
onChange?: (value: T) => void
39+
render?: (value: T) => ReactNode
40+
}
41+
42+
export const Select: PC<'button', SelectProps> = React.forwardRef(
43+
<T extends ElementType, E extends SelectItemNode>(
44+
props: PP<T, SelectProps<E>>
45+
// ref?: PR<T>
46+
) => {
47+
const {
48+
fullWidth = false,
49+
items = [],
50+
value,
51+
onChange,
52+
render = (e) => (typeof e === 'string' ? e : e.value),
53+
as,
54+
...rest
55+
} = props
56+
57+
const listRef = useRef<Array<HTMLElement | null>>([])
58+
const listContentRef = useRef<Array<string | null>>([])
59+
const overflowRef = useRef<null | SideObject>(null)
60+
const allowSelectRef = useRef(false)
61+
const allowMouseUpRef = useRef(true)
62+
const selectTimeoutRef = useRef<any>()
63+
const upArrowRef = useRef<HTMLDivElement | null>(null)
64+
const downArrowRef = useRef<HTMLDivElement | null>(null)
65+
66+
const [open, setOpen] = useState(false)
67+
const [selectedIndex, setSelectedIndex] = useState(0)
68+
const [activeIndex, setActiveIndex] = useState<number | null>(null)
69+
const [fallback, setFallback] = useState(false)
70+
const [innerOffset, setInnerOffset] = useState(0)
71+
const [controlledScrolling, setControlledScrolling] = useState(false)
72+
const [touch, setTouch] = useState(false)
73+
const [blockSelection, setBlockSelection] = useState(false)
74+
75+
useEffect(() => {
76+
if (onChange) onChange(items[selectedIndex])
77+
}, [selectedIndex])
78+
79+
useEffect(() => {
80+
const idx = items.findIndex((it) =>
81+
typeof it === 'string' ? it === value : it.value === value
82+
)
83+
if (idx !== -1) {
84+
setSelectedIndex(idx)
85+
}
86+
}, [value])
87+
88+
const { x, y, reference, floating, strategy, context, refs } = useFloating({
89+
placement: 'bottom-start',
90+
open,
91+
onOpenChange: setOpen,
92+
whileElementsMounted: autoUpdate,
93+
middleware: fallback
94+
? [
95+
offset(5),
96+
...[
97+
touch
98+
? shift({ crossAxis: true, padding: 10 })
99+
: flip({ padding: 10 }),
100+
],
101+
size({
102+
apply({ elements, availableHeight }) {
103+
Object.assign(elements.floating.style, {
104+
maxHeight: `${availableHeight}px`,
105+
})
106+
},
107+
padding: 10,
108+
}),
109+
]
110+
: [
111+
inner({
112+
listRef,
113+
overflowRef,
114+
index: selectedIndex,
115+
offset: innerOffset,
116+
onFallbackChange: setFallback,
117+
padding: 10,
118+
minItemsVisible: touch ? 10 : 4,
119+
referenceOverflowThreshold: 20,
120+
}),
121+
offset({ crossAxis: -4 }),
122+
],
123+
})
124+
125+
const { getReferenceProps, getFloatingProps, getItemProps } =
126+
useInteractions([
127+
useClick(context, { pointerDown: true }),
128+
useDismiss(context, { outsidePointerDown: false }),
129+
useRole(context, { role: 'listbox' }),
130+
useInnerOffset(context, {
131+
enabled: !fallback,
132+
onChange: setInnerOffset,
133+
overflowRef,
134+
}),
135+
useListNavigation(context, {
136+
listRef,
137+
activeIndex,
138+
selectedIndex,
139+
onNavigate: setActiveIndex,
140+
}),
141+
useTypeahead(context, {
142+
listRef: listContentRef,
143+
activeIndex,
144+
onMatch: open ? setActiveIndex : setSelectedIndex,
145+
}),
146+
])
147+
148+
useLayoutEffect(() => {
149+
if (open) {
150+
selectTimeoutRef.current = setTimeout(() => {
151+
allowSelectRef.current = true
152+
}, 300)
153+
154+
return () => {
155+
clearTimeout(selectTimeoutRef.current)
156+
}
157+
}
158+
allowSelectRef.current = false
159+
allowMouseUpRef.current = true
160+
setInnerOffset(0)
161+
setFallback(false)
162+
setBlockSelection(false)
163+
return undefined
164+
}, [open])
165+
166+
// Replacement for `useDismiss` as the arrows are outside of the floating
167+
// element DOM tree.
168+
useLayoutEffect(() => {
169+
const onPointerDown = (e: PointerEvent): void => {
170+
const target = e.target as Node
171+
if (
172+
!refs.floating.current?.contains(target) &&
173+
!upArrowRef.current?.contains(target) &&
174+
!downArrowRef.current?.contains(target)
175+
) {
176+
setOpen(false)
177+
}
178+
}
179+
180+
if (open) {
181+
document.addEventListener('pointerdown', onPointerDown)
182+
return () => {
183+
document.removeEventListener('pointerdown', onPointerDown)
184+
}
185+
}
186+
return undefined
187+
}, [open, refs])
188+
189+
// Scroll the `activeIndex` item into view only in "controlledScrolling"
190+
// (keyboard nav) mode.
191+
useLayoutEffect(() => {
192+
if (open && controlledScrolling) {
193+
requestAnimationFrame(() => {
194+
if (activeIndex != null) {
195+
listRef.current[activeIndex]?.scrollIntoView({ block: 'nearest' })
196+
}
197+
})
198+
}
199+
}, [open, refs, controlledScrolling, activeIndex])
200+
201+
// Scroll the `selectedIndex` into view upon opening the floating element.
202+
useLayoutEffect(() => {
203+
if (open && fallback) {
204+
requestAnimationFrame(() => {
205+
if (selectedIndex != null) {
206+
listRef.current[selectedIndex]?.scrollIntoView({ block: 'nearest' })
207+
}
208+
})
209+
}
210+
}, [open, fallback, selectedIndex])
211+
212+
// Unset the height limiting for fallback mode. This gets executed prior to
213+
// the positioning call.
214+
useLayoutEffect(() => {
215+
if (refs.floating.current && fallback) {
216+
refs.floating.current.style.maxHeight = ''
217+
}
218+
}, [refs, fallback])
219+
220+
const selected = selectedIndex < items.length ? items[selectedIndex] : null
221+
222+
return (
223+
<React.Fragment>
224+
<button
225+
type="button"
226+
ref={reference}
227+
{...getReferenceProps({
228+
onTouchStart() {
229+
setTouch(true)
230+
},
231+
onPointerMove({ pointerType }) {
232+
if (pointerType === 'mouse') {
233+
setTouch(false)
234+
}
235+
},
236+
})}
237+
{...rest}
238+
>
239+
{selected ? render(selected) : null}
240+
</button>
241+
{open && (
242+
<FloatingOverlay lockScroll={!touch} style={{ zIndex: 1 }}>
243+
<FloatingFocusManager context={context} preventTabbing>
244+
<div
245+
ref={floating}
246+
className="MacSelect"
247+
style={{
248+
position: strategy,
249+
top: y ?? 0,
250+
left: x ?? 0,
251+
}}
252+
{...getFloatingProps({
253+
onKeyDown() {
254+
setControlledScrolling(true)
255+
},
256+
onPointerMove() {
257+
setControlledScrolling(false)
258+
},
259+
onContextMenu(e) {
260+
e.preventDefault()
261+
},
262+
})}
263+
>
264+
{items.map((item, i) => {
265+
return (
266+
<button
267+
type="button"
268+
key={typeof item === 'string' ? item : item.value}
269+
// Prevent immediate selection on touch devices when
270+
// pressing the ScrollArrows
271+
disabled={blockSelection}
272+
aria-selected={selectedIndex === i}
273+
role="option"
274+
style={{
275+
// background:
276+
// activeIndex === i
277+
// ? 'rgba(0,200,255,0.2)'
278+
// : i === selectedIndex
279+
// ? 'rgba(0,0,50,0.05)'
280+
// : 'transparent',
281+
fontWeight: i === selectedIndex ? 'bold' : 'normal',
282+
}}
283+
ref={(node) => {
284+
listRef.current[i] = node
285+
listContentRef.current[i] =
286+
typeof item === 'string' ? item : item.value
287+
}}
288+
{...getItemProps({
289+
onTouchStart() {
290+
allowSelectRef.current = true
291+
allowMouseUpRef.current = false
292+
},
293+
onKeyDown() {
294+
allowSelectRef.current = true
295+
},
296+
onClick() {
297+
if (allowSelectRef.current) {
298+
setSelectedIndex(i)
299+
setOpen(false)
300+
}
301+
},
302+
onMouseUp() {
303+
if (!allowMouseUpRef.current) {
304+
return
305+
}
306+
307+
if (allowSelectRef.current) {
308+
setSelectedIndex(i)
309+
setOpen(false)
310+
}
311+
312+
// On touch devices, prevent the element from
313+
// immediately closing `onClick` by deferring it
314+
clearTimeout(selectTimeoutRef.current)
315+
selectTimeoutRef.current = setTimeout(() => {
316+
allowSelectRef.current = true
317+
})
318+
},
319+
})}
320+
>
321+
{render(item)}
322+
</button>
323+
)
324+
})}
325+
</div>
326+
</FloatingFocusManager>
327+
</FloatingOverlay>
328+
)}
329+
</React.Fragment>
330+
)
331+
}
332+
)

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export * from './Footer'
1414
export * from './NavBar'
1515
export * from './PaginationItem'
1616
export * from './Paragraph'
17+
export * from './Select'
1718
export * from './Space'
1819
export * from './TextField'
1920
export * from './Typo'

0 commit comments

Comments
 (0)