@duro-app/ui 5.13.0 → 5.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/{Table-Bms2ftt3.js → Table-DnaoH-h5.js} +747 -635
- package/dist/Table-DnaoH-h5.js.map +1 -0
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/ColorMode/ColorMode.d.ts +2 -1
- package/dist/components/ColorMode/ColorMode.d.ts.map +1 -1
- package/dist/components/ColorMode/storage.d.ts +4 -0
- package/dist/components/ColorMode/storage.d.ts.map +1 -0
- package/dist/components/ColorMode/styles.css.d.ts +4 -0
- package/dist/components/ColorMode/styles.css.d.ts.map +1 -1
- package/dist/components/ColorMode/useSystemScheme.d.ts +7 -0
- package/dist/components/ColorMode/useSystemScheme.d.ts.map +1 -0
- package/dist/components/Combobox/Combobox.d.ts +7 -2
- package/dist/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/components/Combobox/comboboxState.d.ts +29 -0
- package/dist/components/Combobox/comboboxState.d.ts.map +1 -0
- package/dist/components/Combobox/native.css.d.ts +27 -0
- package/dist/components/Combobox/native.css.d.ts.map +1 -0
- package/dist/components/Combobox/useComboboxRoot.d.ts +6 -10
- package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.d.ts +5 -2
- package/dist/components/Drawer/Drawer.d.ts.map +1 -1
- package/dist/components/Drawer/DrawerSnap.stories.d.ts +2 -0
- package/dist/components/Drawer/DrawerSnap.stories.d.ts.map +1 -1
- package/dist/components/Drawer/native.css.d.ts +4 -0
- package/dist/components/Drawer/native.css.d.ts.map +1 -1
- package/dist/components/Drawer/styles.css.d.ts +4 -0
- package/dist/components/Drawer/styles.css.d.ts.map +1 -1
- package/dist/components/Form/FormSubmitContext.d.ts +8 -0
- package/dist/components/Form/FormSubmitContext.d.ts.map +1 -0
- package/dist/components/Listbox/native.css.d.ts +84 -0
- package/dist/components/Listbox/native.css.d.ts.map +1 -0
- package/dist/components/Select/Select.d.ts +9 -2
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.stories.d.ts +11 -0
- package/dist/components/Select/Select.stories.d.ts.map +1 -1
- package/dist/components/Select/native.css.d.ts +11 -0
- package/dist/components/Select/native.css.d.ts.map +1 -0
- package/dist/components/Select/selectState.d.ts +21 -0
- package/dist/components/Select/selectState.d.ts.map +1 -0
- package/dist/components/Select/styles.css.d.ts +10 -0
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/components/Select/useSelectRoot.d.ts +6 -8
- package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
- package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts +13 -0
- package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts.map +1 -0
- package/dist/components/ThemeProvider/ThemeProvider.d.ts +9 -2
- package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
- package/dist/components/ThemeProvider/overrideRule.d.ts +7 -0
- package/dist/components/ThemeProvider/overrideRule.d.ts.map +1 -0
- package/dist/components/ThemeProvider/overrides.d.ts +16 -0
- package/dist/components/ThemeProvider/overrides.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3302 -3268
- package/dist/index.js.map +1 -1
- package/dist/shared/textOf.d.ts +7 -0
- package/dist/shared/textOf.d.ts.map +1 -0
- package/dist/table.js +1 -1
- package/package.json +3 -2
- package/src/components/Button/Button.tsx +12 -1
- package/src/components/ColorMode/ColorMode.meta.ts +1 -1
- package/src/components/ColorMode/ColorMode.tsx +16 -37
- package/src/components/ColorMode/storage.native.ts +8 -0
- package/src/components/ColorMode/storage.ts +19 -0
- package/src/components/ColorMode/styles.css.ts +6 -0
- package/src/components/ColorMode/useSystemScheme.native.ts +6 -0
- package/src/components/ColorMode/useSystemScheme.ts +19 -0
- package/src/components/Combobox/Combobox.meta.ts +1 -1
- package/src/components/Combobox/Combobox.native.tsx +295 -0
- package/src/components/Combobox/Combobox.tsx +14 -8
- package/src/components/Combobox/comboboxState.ts +107 -0
- package/src/components/Combobox/native.css.ts +33 -0
- package/src/components/Combobox/useComboboxRoot.ts +17 -91
- package/src/components/Drawer/Drawer.meta.ts +1 -1
- package/src/components/Drawer/Drawer.native.tsx +29 -7
- package/src/components/Drawer/Drawer.tsx +11 -2
- package/src/components/Drawer/DrawerSnap.stories.tsx +22 -2
- package/src/components/Drawer/native.css.ts +4 -0
- package/src/components/Drawer/styles.css.ts +4 -0
- package/src/components/Form/Form.meta.ts +1 -1
- package/src/components/Form/Form.native.tsx +120 -0
- package/src/components/Form/FormSubmitContext.ts +12 -0
- package/src/components/Listbox/Listbox.meta.ts +1 -1
- package/src/components/Listbox/Listbox.native.tsx +153 -0
- package/src/components/Listbox/native.css.ts +91 -0
- package/src/components/ScrollArea/ScrollArea.meta.ts +1 -1
- package/src/components/ScrollArea/ScrollArea.native.tsx +61 -0
- package/src/components/Select/Select.meta.ts +1 -1
- package/src/components/Select/Select.native.tsx +224 -0
- package/src/components/Select/Select.stories.tsx +49 -1
- package/src/components/Select/Select.tsx +20 -3
- package/src/components/Select/native.css.ts +13 -0
- package/src/components/Select/selectState.ts +36 -0
- package/src/components/Select/styles.css.ts +11 -0
- package/src/components/Select/useSelectRoot.ts +9 -30
- package/src/components/ThemeProvider/ThemeOverrides.stories.tsx +113 -0
- package/src/components/ThemeProvider/ThemeProvider.meta.ts +1 -1
- package/src/components/ThemeProvider/ThemeProvider.native.tsx +36 -7
- package/src/components/ThemeProvider/ThemeProvider.tsx +42 -6
- package/src/components/ThemeProvider/overrideRule.ts +39 -0
- package/src/components/ThemeProvider/overrides.ts +27 -0
- package/src/form.native.ts +7 -0
- package/src/index.native.ts +15 -0
- package/src/index.ts +1 -0
- package/src/shared/focusInput.native.ts +18 -0
- package/src/shared/textOf.ts +13 -0
- package/dist/Table-Bms2ftt3.js.map +0 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {createContext, type ReactNode, type Ref, useContext} from 'react'
|
|
2
|
+
import {View} from 'react-native'
|
|
3
|
+
import {Pressable} from 'react-native-gesture-handler'
|
|
4
|
+
import {html} from 'react-strict-dom'
|
|
5
|
+
import {sheetStyles} from './native.css'
|
|
6
|
+
import {textOf} from '../../shared/textOf'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Native Listbox (plan N0, Slice C): a plain list of options, laid out where
|
|
10
|
+
* it is rendered — inside a Select or Combobox sheet. Nothing is anchored and
|
|
11
|
+
* nothing is highlighted: native has no arrow keys (Decision 6), so each option
|
|
12
|
+
* is its own screen-reader stop and a press chooses it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type ListboxMaxHeight = 'listMaxH' | 'listMaxHSm'
|
|
16
|
+
|
|
17
|
+
/** Default option id: the listbox id plus the value, made safe for an id. */
|
|
18
|
+
export function defaultOptionId(listboxId: string, value: string): string {
|
|
19
|
+
return `${listboxId}-option-${value.replace(/[^\w-]/g, (ch) => `_${ch.charCodeAt(0).toString(16)}`)}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ListboxAnchorProps {
|
|
23
|
+
role?: 'combobox'
|
|
24
|
+
'aria-controls': string | undefined
|
|
25
|
+
'aria-expanded': boolean
|
|
26
|
+
'aria-activedescendant': string | undefined
|
|
27
|
+
'aria-autocomplete': 'list'
|
|
28
|
+
'aria-haspopup': 'listbox'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The web's anchor ARIA, kept for one API: RSD drops these props on native. */
|
|
32
|
+
export function getAnchorProps({
|
|
33
|
+
id,
|
|
34
|
+
open,
|
|
35
|
+
highlightedId,
|
|
36
|
+
}: {
|
|
37
|
+
id: string
|
|
38
|
+
open: boolean
|
|
39
|
+
highlightedId: string | null | undefined
|
|
40
|
+
}): ListboxAnchorProps {
|
|
41
|
+
return {
|
|
42
|
+
'aria-controls': open ? id : undefined,
|
|
43
|
+
'aria-expanded': open,
|
|
44
|
+
'aria-activedescendant': open && highlightedId ? highlightedId : undefined,
|
|
45
|
+
'aria-autocomplete': 'list',
|
|
46
|
+
'aria-haspopup': 'listbox',
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ListboxContextValue {
|
|
51
|
+
onSelect?: (value: string) => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ListboxContext = createContext<ListboxContextValue | null>(null)
|
|
55
|
+
|
|
56
|
+
function useListbox(part: string) {
|
|
57
|
+
const ctx = useContext(ListboxContext)
|
|
58
|
+
if (!ctx) throw new Error(`Listbox.${part} must be used within Listbox.Root`)
|
|
59
|
+
return ctx
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface RootProps {
|
|
63
|
+
id: string
|
|
64
|
+
open: boolean
|
|
65
|
+
/** A press on an option. */
|
|
66
|
+
onSelect?: (value: string) => void
|
|
67
|
+
/** Accessible name of the list. */
|
|
68
|
+
'aria-label'?: string
|
|
69
|
+
children: ReactNode
|
|
70
|
+
// The web's placement and keyboard props; a native list flows in place.
|
|
71
|
+
anchor?: () => unknown
|
|
72
|
+
matchAnchorWidth?: boolean
|
|
73
|
+
highlightedId?: string | null
|
|
74
|
+
onHighlight?: (id: string | null) => void
|
|
75
|
+
getOptionId?: (value: string) => string
|
|
76
|
+
maxHeight?: ListboxMaxHeight
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function Root({id, open, onSelect, 'aria-label': ariaLabel, children}: RootProps) {
|
|
80
|
+
if (!open) return null
|
|
81
|
+
return (
|
|
82
|
+
<ListboxContext.Provider value={{onSelect}}>
|
|
83
|
+
<html.div id={id} role="listbox" aria-label={ariaLabel} style={sheetStyles.list}>
|
|
84
|
+
{children}
|
|
85
|
+
</html.div>
|
|
86
|
+
</ListboxContext.Provider>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface OptionProps {
|
|
91
|
+
value: string
|
|
92
|
+
id?: string
|
|
93
|
+
/** The committed choice: filled, with a trailing check. */
|
|
94
|
+
selected?: boolean
|
|
95
|
+
disabled?: boolean
|
|
96
|
+
children: ReactNode
|
|
97
|
+
ref?: Ref<unknown>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function Option({value, selected = false, disabled = false, children, ref}: OptionProps) {
|
|
101
|
+
const {onSelect} = useListbox('Option')
|
|
102
|
+
const choose = () => {
|
|
103
|
+
if (!disabled) onSelect?.(value)
|
|
104
|
+
}
|
|
105
|
+
// Two hosts, one job each. The View is what a screen reader reads: a
|
|
106
|
+
// selected button named by its text alone, activated by a double-tap.
|
|
107
|
+
// Gesture Handler's Pressable takes the touch natively, since an RN
|
|
108
|
+
// Pressable loses the first tap with the keyboard up to any ScrollView
|
|
109
|
+
// above the sheet's Modal (keyboardShouldPersistTaps 'never', the default);
|
|
110
|
+
// its native button does not expose a role or state, so it is hidden.
|
|
111
|
+
return (
|
|
112
|
+
<View
|
|
113
|
+
ref={ref as Ref<never>}
|
|
114
|
+
accessible
|
|
115
|
+
accessibilityRole="button"
|
|
116
|
+
accessibilityLabel={textOf(children) || undefined}
|
|
117
|
+
accessibilityState={{selected, disabled}}
|
|
118
|
+
accessibilityActions={ACTIVATE}
|
|
119
|
+
onAccessibilityAction={(e) => {
|
|
120
|
+
if (e.nativeEvent.actionName === 'activate') choose()
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<Pressable accessible={false} disabled={disabled} onPress={choose}>
|
|
124
|
+
{({pressed}) => (
|
|
125
|
+
<html.div style={[sheetStyles.option, (selected || pressed) && sheetStyles.optionFilled]}>
|
|
126
|
+
<html.span style={[sheetStyles.label, disabled && sheetStyles.labelDisabled]}>
|
|
127
|
+
{children}
|
|
128
|
+
</html.span>
|
|
129
|
+
{selected && (
|
|
130
|
+
<html.span aria-hidden style={sheetStyles.check}>
|
|
131
|
+
✓
|
|
132
|
+
</html.span>
|
|
133
|
+
)}
|
|
134
|
+
</html.div>
|
|
135
|
+
)}
|
|
136
|
+
</Pressable>
|
|
137
|
+
</View>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ACTIVATE = [{name: 'activate'}] as const
|
|
142
|
+
|
|
143
|
+
/** Shown in place of options when there are none, or none match. */
|
|
144
|
+
function Empty({children}: {children: ReactNode}) {
|
|
145
|
+
return <html.div style={sheetStyles.empty}>{children}</html.div>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const Listbox = {
|
|
149
|
+
Root,
|
|
150
|
+
Option,
|
|
151
|
+
Empty,
|
|
152
|
+
getAnchorProps,
|
|
153
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
+
import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
|
+
import {typography} from '@duro-app/tokens/tokens/typography.css'
|
|
5
|
+
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
6
|
+
import {borders} from '@duro-app/tokens/tokens/borders.css'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A picker sheet's rows on native (plan N0, C0 direction A "Check"): a
|
|
10
|
+
* touch-target row per option, the chosen one filled and checked.
|
|
11
|
+
*/
|
|
12
|
+
export const sheetStyles = css.create({
|
|
13
|
+
list: {
|
|
14
|
+
display: 'flex',
|
|
15
|
+
flexDirection: 'column',
|
|
16
|
+
},
|
|
17
|
+
option: {
|
|
18
|
+
display: 'flex',
|
|
19
|
+
flexDirection: 'row',
|
|
20
|
+
alignItems: 'center',
|
|
21
|
+
gap: spacing.sm,
|
|
22
|
+
minHeight: sizes.touchTarget,
|
|
23
|
+
paddingLeft: spacing.md,
|
|
24
|
+
paddingRight: spacing.md,
|
|
25
|
+
borderBottomWidth: borders.hairline,
|
|
26
|
+
borderBottomStyle: 'solid',
|
|
27
|
+
borderBottomColor: colors.border,
|
|
28
|
+
},
|
|
29
|
+
// The chosen option, and a row under the finger.
|
|
30
|
+
optionFilled: {
|
|
31
|
+
backgroundColor: colors.bgCardHover,
|
|
32
|
+
},
|
|
33
|
+
label: {
|
|
34
|
+
flex: 1,
|
|
35
|
+
color: colors.text,
|
|
36
|
+
fontFamily: typography.fontFamily,
|
|
37
|
+
fontSize: typography.fontSizeSm,
|
|
38
|
+
},
|
|
39
|
+
labelDisabled: {
|
|
40
|
+
color: colors.textMuted,
|
|
41
|
+
},
|
|
42
|
+
check: {
|
|
43
|
+
color: colors.accent,
|
|
44
|
+
fontFamily: typography.fontFamily,
|
|
45
|
+
fontSize: typography.fontSizeSm,
|
|
46
|
+
fontWeight: typography.fontWeightSemibold,
|
|
47
|
+
},
|
|
48
|
+
empty: {
|
|
49
|
+
display: 'flex',
|
|
50
|
+
flexDirection: 'column',
|
|
51
|
+
alignItems: 'center',
|
|
52
|
+
gap: spacing.xs,
|
|
53
|
+
paddingTop: spacing.xl,
|
|
54
|
+
paddingBottom: spacing.xl,
|
|
55
|
+
paddingLeft: spacing.md,
|
|
56
|
+
paddingRight: spacing.md,
|
|
57
|
+
color: colors.textMuted,
|
|
58
|
+
fontFamily: typography.fontFamily,
|
|
59
|
+
fontSize: typography.fontSizeSm,
|
|
60
|
+
textAlign: 'center',
|
|
61
|
+
},
|
|
62
|
+
// The search field pinned at a Combobox sheet's top, under its header.
|
|
63
|
+
search: {
|
|
64
|
+
display: 'flex',
|
|
65
|
+
flexDirection: 'row',
|
|
66
|
+
alignItems: 'center',
|
|
67
|
+
gap: spacing.sm,
|
|
68
|
+
minHeight: sizes.touchTarget,
|
|
69
|
+
marginLeft: spacing.md,
|
|
70
|
+
marginRight: spacing.md,
|
|
71
|
+
marginBottom: spacing.sm,
|
|
72
|
+
paddingLeft: spacing.sm,
|
|
73
|
+
paddingRight: spacing.sm,
|
|
74
|
+
borderRadius: radii.sm,
|
|
75
|
+
borderWidth: borders.hairline,
|
|
76
|
+
borderStyle: 'solid',
|
|
77
|
+
borderColor: colors.border,
|
|
78
|
+
backgroundColor: colors.bg,
|
|
79
|
+
},
|
|
80
|
+
searchInput: {
|
|
81
|
+
flex: 1,
|
|
82
|
+
color: colors.text,
|
|
83
|
+
fontFamily: typography.fontFamily,
|
|
84
|
+
fontSize: typography.fontSizeSm,
|
|
85
|
+
backgroundColor: 'transparent',
|
|
86
|
+
borderWidth: 0,
|
|
87
|
+
'::placeholder': {
|
|
88
|
+
color: colors.textMuted,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
})
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Custom scrollbar region with draggable thumb. Supports vertical and horizontal scrolling. Compound component — Root is required. Inside a Drawer, a drag in the viewport scrolls it; the drawer only takes a drag toward its edge from the scroll start.',
|
|
5
|
+
'Custom scrollbar region with draggable thumb. Supports vertical and horizontal scrolling. Compound component — Root is required. Inside a Drawer, a drag in the viewport scrolls it; the drawer only takes a drag toward its edge from the scroll start. On React Native the Viewport is an RN ScrollView with the platform scroll indicator, capped by the same maxHeight token key; Scrollbar and Thumb render nothing.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Custom-styled scrollbars for content overflow areas',
|
|
8
8
|
'Chat logs, code blocks, or long lists with constrained height',
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type {ComponentProps, ReactNode} from 'react'
|
|
2
|
+
import {ScrollView, StyleSheet} from 'react-native'
|
|
3
|
+
import {html} from 'react-strict-dom'
|
|
4
|
+
import {styles} from './styles.css'
|
|
5
|
+
import {resolveLength, type Length} from '../../shared/length'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Native ScrollArea (plan N0, Slice C): Viewport is an RN ScrollView with the
|
|
9
|
+
* platform's own scroll indicator, so Scrollbar and Thumb draw nothing. The
|
|
10
|
+
* cap (`maxHeight`, a size-token key) sits on the view around it, where
|
|
11
|
+
* react-strict-dom resolves the token.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function Root({children}: {children: ReactNode}) {
|
|
15
|
+
return <html.div style={styles.root}>{children}</html.div>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ViewportProps {
|
|
19
|
+
children: ReactNode
|
|
20
|
+
maxHeight?: Length
|
|
21
|
+
/** Extra styles for the viewport (e.g. a pointer-aware max height). */
|
|
22
|
+
style?: ComponentProps<typeof html.div>['style']
|
|
23
|
+
/** The web's roving-focus flag; native has no tab stops. */
|
|
24
|
+
rovingOwner?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function Viewport({children, maxHeight, style}: ViewportProps) {
|
|
28
|
+
return (
|
|
29
|
+
<html.div
|
|
30
|
+
style={[maxHeight != null && styles.viewportMaxHeight(resolveLength(maxHeight)), style]}
|
|
31
|
+
>
|
|
32
|
+
<ScrollView style={layout.scroll}>{children}</ScrollView>
|
|
33
|
+
</html.div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function Content({children}: {children: ReactNode}) {
|
|
38
|
+
return <html.div>{children}</html.div>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type ScrollbarOrientation = 'vertical' | 'horizontal'
|
|
42
|
+
|
|
43
|
+
/** The platform draws its own scroll indicator. */
|
|
44
|
+
function Scrollbar(_props: {orientation?: ScrollbarOrientation; children?: ReactNode}) {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function Thumb(_props: {orientation?: ScrollbarOrientation}) {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The ScrollView shrinks to its capped parent instead of growing past it.
|
|
53
|
+
const layout = StyleSheet.create({scroll: {flexGrow: 0, flexShrink: 1}})
|
|
54
|
+
|
|
55
|
+
export const ScrollArea = {
|
|
56
|
+
Root,
|
|
57
|
+
Viewport,
|
|
58
|
+
Content,
|
|
59
|
+
Scrollbar,
|
|
60
|
+
Thumb,
|
|
61
|
+
}
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Dropdown select for choosing one value from a list. Compound component — Root is required (throws without it). Select.Trigger takes size="small" to match small Toggles and Buttons in a toolbar.',
|
|
5
|
+
'Dropdown select for choosing one value from a list. Compound component — Root is required (throws without it). Select.Trigger takes size="small" to match small Toggles and Buttons in a toolbar. Select.Popup takes label (the list\'s accessible name) and empty (shown in place of the list when it has no items). On React Native the trigger opens a bottom sheet (a Drawer at the medium and large detents) titled by label, with a Cancel text button (cancelLabel); the chosen option is filled with a trailing check, and a press picks an option and closes the sheet. Native has no arrow keys: each option is its own screen-reader stop.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'User must pick one option from a predefined list',
|
|
8
8
|
'Form field for country, category, role selection',
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
createContext,
|
|
4
|
+
type ReactNode,
|
|
5
|
+
type RefObject,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useId,
|
|
9
|
+
useRef,
|
|
10
|
+
} from 'react'
|
|
11
|
+
import {Pressable} from 'react-native'
|
|
12
|
+
import {html} from 'react-strict-dom'
|
|
13
|
+
import {styles} from './styles.css'
|
|
14
|
+
import {useSelectState} from './selectState'
|
|
15
|
+
import {Drawer} from '../Drawer/Drawer'
|
|
16
|
+
import {Listbox} from '../Listbox/Listbox'
|
|
17
|
+
import {Button} from '../Button/Button'
|
|
18
|
+
import {nativeStyles} from './native.css'
|
|
19
|
+
import {Chevron} from '../SideNav/Chevron'
|
|
20
|
+
import {useTooltipHint} from '../Tooltip/TooltipHint'
|
|
21
|
+
import {focusForAccessibility} from '../../shared/accessibilityFocus'
|
|
22
|
+
import {textOf} from '../../shared/textOf'
|
|
23
|
+
import type {ControlSize} from '../../shared/types'
|
|
24
|
+
|
|
25
|
+
/** The picker sheet's detents (plan N0, Decision 5). */
|
|
26
|
+
const SNAP_POINTS = ['medium', 'large'] as const
|
|
27
|
+
|
|
28
|
+
interface NativeSelectContextValue {
|
|
29
|
+
value: string | null
|
|
30
|
+
open: boolean
|
|
31
|
+
setOpen: (open: boolean) => void
|
|
32
|
+
choose: (value: string) => void
|
|
33
|
+
labels: Record<string, string>
|
|
34
|
+
registerLabel: (value: string, label: string) => void
|
|
35
|
+
listboxId: string
|
|
36
|
+
triggerRef: RefObject<unknown>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SelectContext = createContext<NativeSelectContextValue | null>(null)
|
|
40
|
+
|
|
41
|
+
function useSelect() {
|
|
42
|
+
const ctx = useContext(SelectContext)
|
|
43
|
+
if (!ctx) throw new Error('Select compound components must be used within Select.Root')
|
|
44
|
+
return ctx
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* True while Popup renders its items unseen, so each registers its label
|
|
49
|
+
* before the sheet first opens (the web reads them from the hidden list).
|
|
50
|
+
*/
|
|
51
|
+
const CollectContext = createContext(false)
|
|
52
|
+
|
|
53
|
+
interface RootProps {
|
|
54
|
+
/** The web's hidden form input; native has no form post. */
|
|
55
|
+
name?: string
|
|
56
|
+
defaultValue?: string
|
|
57
|
+
value?: string
|
|
58
|
+
onValueChange?: (value: string | null) => void
|
|
59
|
+
initialLabels?: Record<string, string>
|
|
60
|
+
children: ReactNode
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Native Select (plan N0, Slice C): the trigger opens a bottom sheet (a
|
|
65
|
+
* Drawer at the medium and large detents) listing the options; a press picks
|
|
66
|
+
* one and closes the sheet (C0 direction A, "Check"). The screen reader's
|
|
67
|
+
* focus returns to the trigger.
|
|
68
|
+
*/
|
|
69
|
+
function Root({defaultValue, value, onValueChange, initialLabels, children}: RootProps) {
|
|
70
|
+
const state = useSelectState({defaultValue, value, onValueChange, initialLabels})
|
|
71
|
+
const {open, setOpen, setValue} = state
|
|
72
|
+
const listboxId = useId()
|
|
73
|
+
const triggerRef = useRef<unknown>(null)
|
|
74
|
+
|
|
75
|
+
const wasOpen = useRef(open)
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (wasOpen.current && !open) focusForAccessibility(triggerRef.current, 'Select')
|
|
78
|
+
wasOpen.current = open
|
|
79
|
+
}, [open])
|
|
80
|
+
|
|
81
|
+
const ctx: NativeSelectContextValue = {
|
|
82
|
+
value: state.value,
|
|
83
|
+
open,
|
|
84
|
+
setOpen,
|
|
85
|
+
choose: (v) => {
|
|
86
|
+
setValue(v)
|
|
87
|
+
setOpen(false)
|
|
88
|
+
},
|
|
89
|
+
labels: state.labels,
|
|
90
|
+
registerLabel: state.registerLabel,
|
|
91
|
+
listboxId,
|
|
92
|
+
triggerRef,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<SelectContext.Provider value={ctx}>
|
|
97
|
+
<Drawer.Root open={open} onOpenChange={setOpen} anchor="bottom" snapPoints={SNAP_POINTS}>
|
|
98
|
+
<html.div style={[styles.root, nativeStyles.nativeFlex]}>{children}</html.div>
|
|
99
|
+
</Drawer.Root>
|
|
100
|
+
</SelectContext.Provider>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* An RN Pressable host, so it carries an enclosing Tooltip's text as its
|
|
106
|
+
* accessibility hint (RSD drops `accessibilityHint` on html.*).
|
|
107
|
+
*/
|
|
108
|
+
function Trigger({
|
|
109
|
+
children,
|
|
110
|
+
'aria-label': ariaLabel,
|
|
111
|
+
size = 'default',
|
|
112
|
+
}: {
|
|
113
|
+
children: ReactNode
|
|
114
|
+
'aria-label'?: string
|
|
115
|
+
size?: ControlSize
|
|
116
|
+
}) {
|
|
117
|
+
const {open, setOpen, triggerRef} = useSelect()
|
|
118
|
+
const hint = useTooltipHint()
|
|
119
|
+
return (
|
|
120
|
+
<Pressable
|
|
121
|
+
ref={triggerRef as RefObject<never>}
|
|
122
|
+
onPress={() => setOpen(true)}
|
|
123
|
+
accessibilityRole="combobox"
|
|
124
|
+
accessibilityLabel={ariaLabel}
|
|
125
|
+
accessibilityHint={hint}
|
|
126
|
+
accessibilityState={{expanded: open}}
|
|
127
|
+
>
|
|
128
|
+
<html.div
|
|
129
|
+
style={[styles.trigger, nativeStyles.nativeFlex, size === 'small' && styles.triggerSmall]}
|
|
130
|
+
>
|
|
131
|
+
{children}
|
|
132
|
+
</html.div>
|
|
133
|
+
</Pressable>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function Value({placeholder}: {placeholder?: string}) {
|
|
138
|
+
const {value, labels} = useSelect()
|
|
139
|
+
const display = value ? (labels[value] ?? value) : null
|
|
140
|
+
return (
|
|
141
|
+
<html.span style={display ? styles.value : styles.placeholder}>
|
|
142
|
+
{display ?? placeholder}
|
|
143
|
+
</html.span>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function Icon({children}: {children?: ReactNode}) {
|
|
148
|
+
return (
|
|
149
|
+
<html.div style={[styles.icon, nativeStyles.nativeFlex, nativeStyles.iconDown]}>
|
|
150
|
+
{children ?? <Chevron color="textMuted" />}
|
|
151
|
+
</html.div>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface PopupProps {
|
|
156
|
+
children: ReactNode
|
|
157
|
+
/** The sheet's title (native) and the list's accessible name. */
|
|
158
|
+
label?: string
|
|
159
|
+
/** The sheet header's text button that closes it unchanged (native). Default: 'Cancel' */
|
|
160
|
+
cancelLabel?: string
|
|
161
|
+
/** Shown in place of the list when there are no items. */
|
|
162
|
+
empty?: ReactNode
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function Popup({children, label, cancelLabel = 'Cancel', empty}: PopupProps) {
|
|
166
|
+
const {open, choose, listboxId} = useSelect()
|
|
167
|
+
const hasItems = Children.toArray(children).length > 0
|
|
168
|
+
return (
|
|
169
|
+
<>
|
|
170
|
+
<CollectContext.Provider value={true}>{children}</CollectContext.Provider>
|
|
171
|
+
<Drawer.Portal>
|
|
172
|
+
<Drawer.Header titleAlign="center">
|
|
173
|
+
{label ? <Drawer.Title>{label}</Drawer.Title> : null}
|
|
174
|
+
<Drawer.Close>
|
|
175
|
+
<Button variant="link">{cancelLabel}</Button>
|
|
176
|
+
</Drawer.Close>
|
|
177
|
+
</Drawer.Header>
|
|
178
|
+
{!hasItems && empty != null ? (
|
|
179
|
+
<Listbox.Empty>{empty}</Listbox.Empty>
|
|
180
|
+
) : (
|
|
181
|
+
<Drawer.Body flush>
|
|
182
|
+
<Listbox.Root id={listboxId} open={open} onSelect={choose} aria-label={label}>
|
|
183
|
+
{children}
|
|
184
|
+
</Listbox.Root>
|
|
185
|
+
</Drawer.Body>
|
|
186
|
+
)}
|
|
187
|
+
</Drawer.Portal>
|
|
188
|
+
</>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface ItemProps {
|
|
193
|
+
value: string
|
|
194
|
+
children: ReactNode
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function Item({value: itemValue, children}: ItemProps) {
|
|
198
|
+
const collecting = useContext(CollectContext)
|
|
199
|
+
const {value, registerLabel} = useSelect()
|
|
200
|
+
const label = textOf(children)
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (collecting && label) registerLabel(itemValue, label)
|
|
203
|
+
}, [collecting, itemValue, label, registerLabel])
|
|
204
|
+
if (collecting) return null
|
|
205
|
+
return (
|
|
206
|
+
<Listbox.Option value={itemValue} selected={value === itemValue}>
|
|
207
|
+
{children}
|
|
208
|
+
</Listbox.Option>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function ItemText({children}: {children: ReactNode}) {
|
|
213
|
+
return <html.span>{children}</html.span>
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export const Select = {
|
|
217
|
+
Root,
|
|
218
|
+
Trigger,
|
|
219
|
+
Value,
|
|
220
|
+
Icon,
|
|
221
|
+
Popup,
|
|
222
|
+
Item,
|
|
223
|
+
ItemText,
|
|
224
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
-
import {expect, screen} from 'storybook/test'
|
|
2
|
+
import {expect, fn, screen} from 'storybook/test'
|
|
3
3
|
import {Dialog} from '../Dialog/Dialog'
|
|
4
4
|
import {Select} from './Select'
|
|
5
5
|
|
|
@@ -213,3 +213,51 @@ export const ArrowKeysOpen: Story = {
|
|
|
213
213
|
await userEvent.keyboard('{Escape}')
|
|
214
214
|
},
|
|
215
215
|
}
|
|
216
|
+
|
|
217
|
+
const MODELS = ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5', 'gpt-6', 'gemini-3-pro']
|
|
218
|
+
|
|
219
|
+
interface LabelledArgs {
|
|
220
|
+
label: string
|
|
221
|
+
empty: string
|
|
222
|
+
itemCount: number
|
|
223
|
+
onValueChange?: (value: string | null) => void
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Popup's label names the list (and titles the native sheet); empty replaces
|
|
228
|
+
* the list when there are no items. Set itemCount to 0 for the empty state.
|
|
229
|
+
*/
|
|
230
|
+
export const LabelledAndEmpty: StoryObj<LabelledArgs> = {
|
|
231
|
+
args: {
|
|
232
|
+
label: 'Model',
|
|
233
|
+
empty: 'No models. Connect a provider to choose a model.',
|
|
234
|
+
itemCount: 0,
|
|
235
|
+
onValueChange: fn(),
|
|
236
|
+
},
|
|
237
|
+
argTypes: {
|
|
238
|
+
label: {control: 'text', description: "Select.Popup: the list's accessible name"},
|
|
239
|
+
empty: {control: 'text', description: 'Select.Popup: shown when there are no items'},
|
|
240
|
+
itemCount: {control: {type: 'range', min: 0, max: MODELS.length, step: 1}},
|
|
241
|
+
},
|
|
242
|
+
render: ({label, empty, itemCount, onValueChange}) => (
|
|
243
|
+
<Select.Root onValueChange={onValueChange}>
|
|
244
|
+
<Select.Trigger aria-label={label}>
|
|
245
|
+
<Select.Value placeholder="Choose a model" />
|
|
246
|
+
<Select.Icon />
|
|
247
|
+
</Select.Trigger>
|
|
248
|
+
<Select.Popup label={label} empty={empty}>
|
|
249
|
+
{MODELS.slice(0, itemCount).map((m) => (
|
|
250
|
+
<Select.Item key={m} value={m}>
|
|
251
|
+
{m}
|
|
252
|
+
</Select.Item>
|
|
253
|
+
))}
|
|
254
|
+
</Select.Popup>
|
|
255
|
+
</Select.Root>
|
|
256
|
+
),
|
|
257
|
+
play: async ({canvas, userEvent}) => {
|
|
258
|
+
await userEvent.click(canvas.getByRole('combobox'))
|
|
259
|
+
const list = screen.getByRole('listbox', {name: 'Model'})
|
|
260
|
+
await expect(list).toHaveTextContent(/No models/)
|
|
261
|
+
await expect(screen.queryAllByRole('option')).toHaveLength(0)
|
|
262
|
+
},
|
|
263
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {type ReactNode, useRef, useId, useEffect, useLayoutEffect, useState} from 'react'
|
|
1
|
+
import {Children, type ReactNode, useRef, useId, useEffect, useLayoutEffect, useState} from 'react'
|
|
2
2
|
import {createPortal} from 'react-dom'
|
|
3
3
|
import {html} from 'react-strict-dom'
|
|
4
4
|
import {styles} from './styles.css'
|
|
@@ -126,7 +126,17 @@ function Icon({children}: {children?: ReactNode}) {
|
|
|
126
126
|
// `overflow: hidden` / `transform` containing blocks (notably Dialog/Drawer).
|
|
127
127
|
// The listbox stays mounted while closed (display: none) so Items can register
|
|
128
128
|
// their labels from DOM text before the popup is ever opened.
|
|
129
|
-
|
|
129
|
+
interface PopupProps {
|
|
130
|
+
children: ReactNode
|
|
131
|
+
/** The list's accessible name; on native also the sheet's title. */
|
|
132
|
+
label?: string
|
|
133
|
+
/** Native only: the sheet header's text button that closes it unchanged. Default: 'Cancel' */
|
|
134
|
+
cancelLabel?: string
|
|
135
|
+
/** Shown in place of the list when there are no items. */
|
|
136
|
+
empty?: ReactNode
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function Popup({children, label, empty}: PopupProps) {
|
|
130
140
|
const {open, close, listboxId, triggerRef} = useSelect()
|
|
131
141
|
const mount = usePortalMount()
|
|
132
142
|
const [coords, setCoords] = useState<{top: number; left: number; width: number} | null>(null)
|
|
@@ -164,6 +174,7 @@ function Popup({children}: {children: ReactNode}) {
|
|
|
164
174
|
ref={listboxRef}
|
|
165
175
|
id={listboxId}
|
|
166
176
|
role="listbox"
|
|
177
|
+
aria-label={label}
|
|
167
178
|
aria-hidden={!open}
|
|
168
179
|
style={[
|
|
169
180
|
styles.popup,
|
|
@@ -171,7 +182,13 @@ function Popup({children}: {children: ReactNode}) {
|
|
|
171
182
|
coords && styles.popupPosition(coords.top, coords.left, coords.width),
|
|
172
183
|
]}
|
|
173
184
|
>
|
|
174
|
-
<ControlContextBoundary>
|
|
185
|
+
<ControlContextBoundary>
|
|
186
|
+
{empty != null && Children.toArray(children).length === 0 ? (
|
|
187
|
+
<html.div style={styles.empty}>{empty}</html.div>
|
|
188
|
+
) : (
|
|
189
|
+
children
|
|
190
|
+
)}
|
|
191
|
+
</ControlContextBoundary>
|
|
175
192
|
</html.div>
|
|
176
193
|
</>
|
|
177
194
|
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
|
|
3
|
+
/** Native Select additions to the shared styles. */
|
|
4
|
+
export const nativeStyles = css.create({
|
|
5
|
+
// RSD on native lays out only display: flex, not the web's inline-flex.
|
|
6
|
+
nativeFlex: {
|
|
7
|
+
display: 'flex',
|
|
8
|
+
},
|
|
9
|
+
// SideNav's chevron points right; a picker's points down.
|
|
10
|
+
iconDown: {
|
|
11
|
+
transform: 'rotate(90deg)',
|
|
12
|
+
},
|
|
13
|
+
})
|