@duro-app/ui 5.13.0 → 5.15.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/QrCode/QrCode.d.ts +8 -0
- package/dist/components/QrCode/QrCode.d.ts.map +1 -0
- package/dist/components/QrCode/QrCode.meta.d.ts +3 -0
- package/dist/components/QrCode/QrCode.meta.d.ts.map +1 -0
- package/dist/components/QrCode/QrCode.stories.d.ts +8 -0
- package/dist/components/QrCode/QrCode.stories.d.ts.map +1 -0
- package/dist/components/QrCode/geometry.d.ts +14 -0
- package/dist/components/QrCode/geometry.d.ts.map +1 -0
- package/dist/components/QrCode/styles.css.d.ts +9 -0
- package/dist/components/QrCode/styles.css.d.ts.map +1 -0
- package/dist/components/QrCode/types.d.ts +7 -0
- package/dist/components/QrCode/types.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 +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6230 -4656
- 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 +5 -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/QrCode/QrCode.meta.ts +18 -0
- package/src/components/QrCode/QrCode.native.tsx +31 -0
- package/src/components/QrCode/QrCode.stories.tsx +44 -0
- package/src/components/QrCode/QrCode.tsx +30 -0
- package/src/components/QrCode/geometry.ts +34 -0
- package/src/components/QrCode/styles.css.ts +12 -0
- package/src/components/QrCode/types.ts +6 -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 +16 -0
- package/src/index.ts +2 -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,7 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* The text a React node would render, read from its props: a native picker's
|
|
4
|
+
* option label, where there is no DOM `textContent` to read it from.
|
|
5
|
+
*/
|
|
6
|
+
export declare function textOf(node: ReactNode): string;
|
|
7
|
+
//# sourceMappingURL=textOf.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"textOf.d.ts","sourceRoot":"","sources":["../../src/shared/textOf.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,SAAS,EAAC,MAAM,OAAO,CAAA;AAEpD;;;GAGG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,SAAS,GAAG,MAAM,CAM9C"}
|
package/dist/table.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { M as F,
|
|
1
|
+
import { M as F, q as gt, R as it, H as kt, t as pt, v as bt, w as ft, x as mt, b as ht, g as yt, r as St, y as Tt } from "./Table-DnaoH-h5.js";
|
|
2
2
|
import { jsx as u, jsxs as q } from "react/jsx-runtime";
|
|
3
3
|
import { c as H, m as p, d as b } from "./compiler-runtime-BuugS4D0.js";
|
|
4
4
|
import { flexRender as E, useReactTable as rt, getPaginationRowModel as Ct, getFilteredRowModel as vt, getSortedRowModel as at, getCoreRowModel as st } from "@tanstack/react-table";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"types": "./dist/table.d.ts"
|
|
40
40
|
},
|
|
41
41
|
"./form": {
|
|
42
|
+
"react-native": "./src/form.native.ts",
|
|
42
43
|
"source": "./src/form.ts",
|
|
43
44
|
"import": "./dist/form.js",
|
|
44
45
|
"types": "./dist/form.d.ts"
|
|
@@ -90,11 +91,13 @@
|
|
|
90
91
|
},
|
|
91
92
|
"dependencies": {
|
|
92
93
|
"@tanstack/react-virtual": "^3.14.6",
|
|
93
|
-
"
|
|
94
|
+
"qrcode": "^1.5.4",
|
|
95
|
+
"@duro-app/tokens": "^5.15.0"
|
|
94
96
|
},
|
|
95
97
|
"devDependencies": {
|
|
96
98
|
"@babel/preset-typescript": "^7.28.0",
|
|
97
99
|
"@hookform/resolvers": "^5.0.0",
|
|
100
|
+
"@types/qrcode": "^1.5.6",
|
|
98
101
|
"@types/react": "^19.0.0",
|
|
99
102
|
"@types/react-dom": "^19.0.0",
|
|
100
103
|
"@vitejs/plugin-react": "^4.0.0",
|
|
@@ -4,6 +4,7 @@ import {mergeRefs} from '../../shared/mergeRefs'
|
|
|
4
4
|
import {ButtonHost} from '../../shared/ButtonHost'
|
|
5
5
|
import type {ControlSize} from '../../shared/types'
|
|
6
6
|
import {useGroupedControl} from '../ButtonGroup/useGroupedControl'
|
|
7
|
+
import {useFormSubmit} from '../Form/FormSubmitContext'
|
|
7
8
|
import {styles} from './styles.css'
|
|
8
9
|
|
|
9
10
|
export type ButtonVariant = 'primary' | 'secondary' | 'inverseSecondary' | 'link' | 'danger'
|
|
@@ -50,6 +51,9 @@ export function Button({
|
|
|
50
51
|
children,
|
|
51
52
|
}: ButtonProps) {
|
|
52
53
|
const grouped = useGroupedControl<HTMLButtonElement>({disabled})
|
|
54
|
+
// Native Form only (no DOM submit event); the web Form provides none.
|
|
55
|
+
const formSubmit = useFormSubmit()
|
|
56
|
+
const submit = type === 'submit' ? formSubmit : null
|
|
53
57
|
return (
|
|
54
58
|
<ButtonHost
|
|
55
59
|
ref={isNative ? ref : mergeRefs(ref, grouped.ref)}
|
|
@@ -57,7 +61,14 @@ export function Button({
|
|
|
57
61
|
onFocus={grouped.onFocus}
|
|
58
62
|
type={type}
|
|
59
63
|
disabled={disabled}
|
|
60
|
-
onClick={
|
|
64
|
+
onClick={
|
|
65
|
+
submit
|
|
66
|
+
? () => {
|
|
67
|
+
onClick?.()
|
|
68
|
+
submit()
|
|
69
|
+
}
|
|
70
|
+
: onClick
|
|
71
|
+
}
|
|
61
72
|
aria-label={ariaLabel}
|
|
62
73
|
aria-expanded={ariaExpanded}
|
|
63
74
|
aria-controls={ariaControls}
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Color-mode controller + toggle. ColorModeProvider owns the preference (system/light/dark/high-contrast), resolves "system" against the OS scheme, persists it to localStorage, and applies the resolved theme via ThemeProvider. ColorModeToggle is an icon button that cycles the preference; useColorMode() reads it.',
|
|
5
|
+
'Color-mode controller + toggle. ColorModeProvider owns the preference (system/light/dark/high-contrast), resolves "system" against the OS scheme, persists it to localStorage, and applies the resolved theme via ThemeProvider. ColorModeToggle is an icon button that cycles the preference; useColorMode() reads it. On React Native "system" follows the OS appearance (useColorScheme) and nothing is stored: persist the preference yourself through preference and onPreferenceChange.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'App-level light/dark (and high-contrast) switching that persists across reloads',
|
|
8
8
|
'Respecting the OS prefers-color-scheme with a user override',
|
|
@@ -12,6 +12,8 @@ import {isNative} from '../../platform'
|
|
|
12
12
|
import {Icon, type IconName, type IconSize} from '../Icon/Icon'
|
|
13
13
|
import {ThemeProvider, type ThemeName} from '../ThemeProvider/ThemeProvider'
|
|
14
14
|
import {styles} from './styles.css'
|
|
15
|
+
import {useSystemScheme} from './useSystemScheme'
|
|
16
|
+
import {readStoredRaw, writeStoredRaw} from './storage'
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* The user's *preference*. `'system'` follows the OS `prefers-color-scheme`;
|
|
@@ -62,31 +64,13 @@ export function useColorMode(): ColorModeContextValue {
|
|
|
62
64
|
return ctx
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
const canUseDom =
|
|
66
|
-
!isNative && typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
|
67
|
-
|
|
68
67
|
function isPreference(value: unknown): value is ColorModePreference {
|
|
69
68
|
return typeof value === 'string' && (PREFERENCES as readonly string[]).includes(value)
|
|
70
69
|
}
|
|
71
70
|
|
|
72
71
|
function readStored(key: string | null): ColorModePreference | null {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const raw = window.localStorage.getItem(key)
|
|
76
|
-
return isPreference(raw) ? raw : null
|
|
77
|
-
} catch {
|
|
78
|
-
// Private mode / blocked storage — degrade to no persistence.
|
|
79
|
-
return null
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function writeStored(key: string | null, value: ColorModePreference): void {
|
|
84
|
-
if (!key || isNative || typeof window === 'undefined') return
|
|
85
|
-
try {
|
|
86
|
-
window.localStorage.setItem(key, value)
|
|
87
|
-
} catch {
|
|
88
|
-
/* ignore */
|
|
89
|
-
}
|
|
72
|
+
const raw = readStoredRaw(key)
|
|
73
|
+
return isPreference(raw) ? raw : null
|
|
90
74
|
}
|
|
91
75
|
|
|
92
76
|
interface ColorModeProviderProps {
|
|
@@ -100,7 +84,8 @@ interface ColorModeProviderProps {
|
|
|
100
84
|
onPreferenceChange?: (preference: ColorModePreference) => void
|
|
101
85
|
/** Cycle order for `cycle()` / `<ColorModeToggle>`. Default `['light', 'dark']`. */
|
|
102
86
|
modes?: readonly ColorModePreference[]
|
|
103
|
-
/** localStorage key for persistence; `null` disables it. Default `'duro-color-mode'`.
|
|
87
|
+
/** localStorage key for persistence; `null` disables it. Default `'duro-color-mode'`.
|
|
88
|
+
* Native has no localStorage: persist through `preference` and `onPreferenceChange`. */
|
|
104
89
|
storageKey?: string | null
|
|
105
90
|
/** Wrap children in the DS `ThemeProvider` with the resolved theme. Default `true`.
|
|
106
91
|
* Set `false` to drive your own ThemeProvider from `useColorMode().theme`. */
|
|
@@ -136,26 +121,15 @@ export function ColorModeProvider({
|
|
|
136
121
|
const setPreference = useCallback(
|
|
137
122
|
(next: ColorModePreference) => {
|
|
138
123
|
if (controlled === undefined) setInternal(next)
|
|
139
|
-
|
|
124
|
+
writeStoredRaw(storageKey, next)
|
|
140
125
|
onPreferenceChange?.(next)
|
|
141
126
|
},
|
|
142
127
|
[controlled, storageKey, onPreferenceChange],
|
|
143
128
|
)
|
|
144
129
|
|
|
145
|
-
// Track the OS scheme so `'system'` stays live
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
// initializer would diverge (server 'dark' vs client's real scheme) and trip
|
|
149
|
-
// a hydration mismatch. The effect reconciles to the real scheme after mount.
|
|
150
|
-
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>('dark')
|
|
151
|
-
useEffect(() => {
|
|
152
|
-
if (!canUseDom) return
|
|
153
|
-
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
|
154
|
-
const onChange = () => setSystemTheme(mq.matches ? 'dark' : 'light')
|
|
155
|
-
onChange()
|
|
156
|
-
mq.addEventListener('change', onChange)
|
|
157
|
-
return () => mq.removeEventListener('change', onChange)
|
|
158
|
-
}, [])
|
|
130
|
+
// Track the OS scheme so `'system'` stays live (web: prefers-color-scheme,
|
|
131
|
+
// after mount so hydration matches; native: RN Appearance).
|
|
132
|
+
const systemTheme = useSystemScheme()
|
|
159
133
|
|
|
160
134
|
const theme: ThemeName = preference === 'system' ? systemTheme : preference
|
|
161
135
|
|
|
@@ -196,7 +170,12 @@ export function ColorModeToggle({size = 'md', 'aria-label': ariaLabel}: ColorMod
|
|
|
196
170
|
const label = ariaLabel ?? `Color mode: ${LABEL[preference]}. Switch to ${LABEL[next]}.`
|
|
197
171
|
|
|
198
172
|
return (
|
|
199
|
-
<html.button
|
|
173
|
+
<html.button
|
|
174
|
+
type="button"
|
|
175
|
+
aria-label={label}
|
|
176
|
+
onClick={cycle}
|
|
177
|
+
style={[styles.button, isNative && styles.buttonNative]}
|
|
178
|
+
>
|
|
200
179
|
<Icon name={ICON[preference]} size={size} />
|
|
201
180
|
</html.button>
|
|
202
181
|
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Native has no localStorage: an app persists the preference itself, through
|
|
2
|
+
// ColorModeProvider's `preference` and `onPreferenceChange`.
|
|
3
|
+
|
|
4
|
+
export function readStoredRaw(_key: string | null): string | null {
|
|
5
|
+
return null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function writeStoredRaw(_key: string | null, _value: string): void {}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** The stored preference, as a raw string; null without storage or a value. */
|
|
2
|
+
export function readStoredRaw(key: string | null): string | null {
|
|
3
|
+
if (!key || typeof window === 'undefined') return null
|
|
4
|
+
try {
|
|
5
|
+
return window.localStorage.getItem(key)
|
|
6
|
+
} catch {
|
|
7
|
+
// Private mode / blocked storage — degrade to no persistence.
|
|
8
|
+
return null
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function writeStoredRaw(key: string | null, value: string): void {
|
|
13
|
+
if (!key || typeof window === 'undefined') return
|
|
14
|
+
try {
|
|
15
|
+
window.localStorage.setItem(key, value)
|
|
16
|
+
} catch {
|
|
17
|
+
/* ignore */
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -31,4 +31,10 @@ export const styles = css.create({
|
|
|
31
31
|
outlineColor: colors.accent,
|
|
32
32
|
outlineOffset: borders.focusOffset,
|
|
33
33
|
},
|
|
34
|
+
// RSD on native lays out only display: flex (not inline-flex), and a colour
|
|
35
|
+
// transition there ends near-black: keep the layout, drop the colour easing.
|
|
36
|
+
buttonNative: {
|
|
37
|
+
display: 'flex',
|
|
38
|
+
transitionProperty: 'opacity',
|
|
39
|
+
},
|
|
34
40
|
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {useEffect, useState} from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The OS colour scheme, live. The first render answers `'dark'`, what the
|
|
5
|
+
* server renders without a DOM, so hydration matches; the effect then reads
|
|
6
|
+
* `prefers-color-scheme` and follows its changes.
|
|
7
|
+
*/
|
|
8
|
+
export function useSystemScheme(): 'light' | 'dark' {
|
|
9
|
+
const [scheme, setScheme] = useState<'light' | 'dark'>('dark')
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
|
|
12
|
+
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
|
13
|
+
const onChange = () => setScheme(mq.matches ? 'dark' : 'light')
|
|
14
|
+
onChange()
|
|
15
|
+
mq.addEventListener('change', onChange)
|
|
16
|
+
return () => mq.removeEventListener('change', onChange)
|
|
17
|
+
}, [])
|
|
18
|
+
return scheme
|
|
19
|
+
}
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
|
|
5
|
+
"Searchable dropdown for selecting a value from a filterable list. Combines a text input with a popup listbox. Compound component — Root is required. Combobox.Popup takes label (the list's accessible name). On React Native the field opens a bottom sheet at its large detent, above the keyboard, titled by label with a Cancel text button (cancelLabel): a search field is pinned under the header and focused, the list below filters as you type, Combobox.Empty shows when nothing matches, and a press picks an option and closes the sheet. The sheet's search text starts empty each time; Combobox.Trigger renders nothing on native.",
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Large option lists where users benefit from typing to filter (e.g. country, city, user picker)',
|
|
8
8
|
'Autocomplete or typeahead scenarios',
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
type Ref,
|
|
5
|
+
type RefObject,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useEffect,
|
|
9
|
+
useId,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from 'react'
|
|
13
|
+
import {Pressable} from 'react-native'
|
|
14
|
+
import {html} from 'react-strict-dom'
|
|
15
|
+
import {DURATION_MS} from '@duro-app/tokens/keys'
|
|
16
|
+
import {styles} from './styles.css'
|
|
17
|
+
import {nativeStyles} from './native.css'
|
|
18
|
+
import {matchesQuery, useComboboxState} from './comboboxState'
|
|
19
|
+
import {Drawer} from '../Drawer/Drawer'
|
|
20
|
+
import {Listbox} from '../Listbox/Listbox'
|
|
21
|
+
import {sheetStyles} from '../Listbox/native.css'
|
|
22
|
+
import {Button} from '../Button/Button'
|
|
23
|
+
import {Icon} from '../Icon/Icon'
|
|
24
|
+
import {Chevron} from '../SideNav/Chevron'
|
|
25
|
+
import {focusForAccessibility} from '../../shared/accessibilityFocus'
|
|
26
|
+
import {focusInput} from '../../shared/focusInput'
|
|
27
|
+
import {textOf} from '../../shared/textOf'
|
|
28
|
+
|
|
29
|
+
/** The picker sheet's detents (plan N0, Decision 5); a Combobox opens at large. */
|
|
30
|
+
const SNAP_POINTS = ['medium', 'large'] as const
|
|
31
|
+
|
|
32
|
+
interface NativeComboboxContextValue {
|
|
33
|
+
value: string | null
|
|
34
|
+
open: boolean
|
|
35
|
+
setOpen: (open: boolean) => void
|
|
36
|
+
choose: (value: string) => void
|
|
37
|
+
labels: Record<string, string>
|
|
38
|
+
registerLabel: (value: string, label: string) => void
|
|
39
|
+
/** The labels of the Items rendered now, by value: what Empty matches against */
|
|
40
|
+
items: ReadonlyMap<string, string>
|
|
41
|
+
registerItem: (value: string, label: string) => () => void
|
|
42
|
+
/** What is typed in the sheet's search field */
|
|
43
|
+
query: string
|
|
44
|
+
setQuery: (query: string) => void
|
|
45
|
+
/** The field's placeholder, also the search field's */
|
|
46
|
+
placeholder: string | undefined
|
|
47
|
+
setPlaceholder: (placeholder: string | undefined) => void
|
|
48
|
+
listboxId: string
|
|
49
|
+
triggerRef: RefObject<unknown>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ComboboxContext = createContext<NativeComboboxContextValue | null>(null)
|
|
53
|
+
|
|
54
|
+
function useCombobox() {
|
|
55
|
+
const ctx = useContext(ComboboxContext)
|
|
56
|
+
if (!ctx) throw new Error('Combobox compound components must be used within Combobox.Root')
|
|
57
|
+
return ctx
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True while Popup renders its items unseen, so each registers its label. */
|
|
61
|
+
const CollectContext = createContext(false)
|
|
62
|
+
|
|
63
|
+
interface RootProps {
|
|
64
|
+
/** The web's hidden form input; native has no form post. */
|
|
65
|
+
name?: string
|
|
66
|
+
defaultValue?: string
|
|
67
|
+
value?: string
|
|
68
|
+
onValueChange?: (value: string | null) => void
|
|
69
|
+
/** The field's text: the chosen option's label on native */
|
|
70
|
+
inputValue?: string
|
|
71
|
+
onInputChange?: (value: string) => void
|
|
72
|
+
initialLabels?: Record<string, string>
|
|
73
|
+
children: ReactNode
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Native Combobox (plan N0, Slice C): the field opens a bottom sheet at its
|
|
78
|
+
* large detent, above the keyboard, with a search field pinned under the
|
|
79
|
+
* header and focused; the list below filters as you type, and a press picks
|
|
80
|
+
* an option and closes the sheet (C0 direction A, "Check"). The search text
|
|
81
|
+
* is the sheet's own and starts empty each time it opens.
|
|
82
|
+
*/
|
|
83
|
+
function Root({
|
|
84
|
+
defaultValue,
|
|
85
|
+
value,
|
|
86
|
+
onValueChange,
|
|
87
|
+
inputValue,
|
|
88
|
+
onInputChange,
|
|
89
|
+
initialLabels,
|
|
90
|
+
children,
|
|
91
|
+
}: RootProps) {
|
|
92
|
+
const state = useComboboxState({
|
|
93
|
+
defaultValue,
|
|
94
|
+
value,
|
|
95
|
+
onValueChange,
|
|
96
|
+
inputValue,
|
|
97
|
+
onInputChange,
|
|
98
|
+
initialLabels,
|
|
99
|
+
})
|
|
100
|
+
const {open, setOpen, chooseValue} = state
|
|
101
|
+
const [query, setQuery] = useState('')
|
|
102
|
+
const [items, setItems] = useState<ReadonlyMap<string, string>>(new Map())
|
|
103
|
+
const registerItem = useCallback((itemValue: string, label: string) => {
|
|
104
|
+
setItems((prev) => new Map(prev).set(itemValue, label))
|
|
105
|
+
return () =>
|
|
106
|
+
setItems((prev) => {
|
|
107
|
+
const next = new Map(prev)
|
|
108
|
+
next.delete(itemValue)
|
|
109
|
+
return next
|
|
110
|
+
})
|
|
111
|
+
}, [])
|
|
112
|
+
const [placeholder, setPlaceholder] = useState<string | undefined>(undefined)
|
|
113
|
+
const listboxId = useId()
|
|
114
|
+
const triggerRef = useRef<unknown>(null)
|
|
115
|
+
|
|
116
|
+
const wasOpen = useRef(open)
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (open !== wasOpen.current) setQuery('')
|
|
119
|
+
if (wasOpen.current && !open) focusForAccessibility(triggerRef.current, 'Combobox')
|
|
120
|
+
wasOpen.current = open
|
|
121
|
+
}, [open])
|
|
122
|
+
|
|
123
|
+
const ctx: NativeComboboxContextValue = {
|
|
124
|
+
value: state.value,
|
|
125
|
+
open,
|
|
126
|
+
setOpen,
|
|
127
|
+
choose: chooseValue,
|
|
128
|
+
labels: state.labels,
|
|
129
|
+
registerLabel: state.registerLabel,
|
|
130
|
+
items,
|
|
131
|
+
registerItem,
|
|
132
|
+
query,
|
|
133
|
+
setQuery,
|
|
134
|
+
placeholder,
|
|
135
|
+
setPlaceholder,
|
|
136
|
+
listboxId,
|
|
137
|
+
triggerRef,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<ComboboxContext.Provider value={ctx}>
|
|
142
|
+
<Drawer.Root
|
|
143
|
+
open={open}
|
|
144
|
+
onOpenChange={setOpen}
|
|
145
|
+
anchor="bottom"
|
|
146
|
+
snapPoints={SNAP_POINTS}
|
|
147
|
+
defaultSnapPoint="large"
|
|
148
|
+
>
|
|
149
|
+
<html.div style={[styles.root, nativeStyles.nativeFlex]}>{children}</html.div>
|
|
150
|
+
</Drawer.Root>
|
|
151
|
+
</ComboboxContext.Provider>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The closed field: the chosen option's label, or the placeholder. A press
|
|
157
|
+
* opens the sheet, where the typing happens.
|
|
158
|
+
*/
|
|
159
|
+
function Input({placeholder, children}: {placeholder?: string; children?: ReactNode}) {
|
|
160
|
+
const {value, labels, open, setOpen, setPlaceholder, triggerRef} = useCombobox()
|
|
161
|
+
const display = value ? (labels[value] ?? value) : null
|
|
162
|
+
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
setPlaceholder(placeholder)
|
|
165
|
+
}, [placeholder, setPlaceholder])
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<Pressable
|
|
169
|
+
ref={triggerRef as RefObject<never>}
|
|
170
|
+
onPress={() => setOpen(true)}
|
|
171
|
+
accessibilityRole="combobox"
|
|
172
|
+
accessibilityLabel={display ?? placeholder}
|
|
173
|
+
accessibilityState={{expanded: open}}
|
|
174
|
+
>
|
|
175
|
+
<html.div style={[styles.inputWrapper, nativeStyles.field]}>
|
|
176
|
+
<html.span style={[nativeStyles.fieldText, !display && nativeStyles.fieldPlaceholder]}>
|
|
177
|
+
{display ?? placeholder}
|
|
178
|
+
</html.span>
|
|
179
|
+
{children}
|
|
180
|
+
<html.div style={nativeStyles.chevronDown}>
|
|
181
|
+
<Chevron color="textMuted" />
|
|
182
|
+
</html.div>
|
|
183
|
+
</html.div>
|
|
184
|
+
</Pressable>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The web's open/close button; on native the whole field opens the sheet. */
|
|
189
|
+
function Trigger(_props: {children?: ReactNode}) {
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface PopupProps {
|
|
194
|
+
children: ReactNode
|
|
195
|
+
/** The sheet's title (native) and the list's accessible name. */
|
|
196
|
+
label?: string
|
|
197
|
+
/** The sheet header's text button that closes it unchanged (native). Default: 'Cancel' */
|
|
198
|
+
cancelLabel?: string
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function Popup({children, label, cancelLabel = 'Cancel'}: PopupProps) {
|
|
202
|
+
const {open, choose, query, setQuery, placeholder, listboxId} = useCombobox()
|
|
203
|
+
const inputRef = useRef<unknown>(null)
|
|
204
|
+
|
|
205
|
+
// Focus the search field once the sheet has entered: a TextInput's
|
|
206
|
+
// autoFocus fires on mount, before iOS has presented the Modal.
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (!open) return
|
|
209
|
+
const timer = setTimeout(() => focusInput(inputRef.current), DURATION_MS.base)
|
|
210
|
+
return () => clearTimeout(timer)
|
|
211
|
+
}, [open])
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<>
|
|
215
|
+
<CollectContext.Provider value={true}>{children}</CollectContext.Provider>
|
|
216
|
+
<Drawer.Portal>
|
|
217
|
+
<Drawer.Header titleAlign="center">
|
|
218
|
+
{label ? <Drawer.Title>{label}</Drawer.Title> : null}
|
|
219
|
+
<Drawer.Close>
|
|
220
|
+
<Button variant="link">{cancelLabel}</Button>
|
|
221
|
+
</Drawer.Close>
|
|
222
|
+
</Drawer.Header>
|
|
223
|
+
<html.div style={sheetStyles.search}>
|
|
224
|
+
<Icon name="search" size="sm" color="textMuted" />
|
|
225
|
+
<html.input
|
|
226
|
+
ref={inputRef as Ref<never>}
|
|
227
|
+
type="search"
|
|
228
|
+
autoComplete="off"
|
|
229
|
+
autoCapitalize="none"
|
|
230
|
+
spellCheck={false}
|
|
231
|
+
value={query}
|
|
232
|
+
placeholder={placeholder}
|
|
233
|
+
aria-label={label ?? placeholder}
|
|
234
|
+
onChange={(e: {target: {value: string}}) => setQuery(e.target.value)}
|
|
235
|
+
style={sheetStyles.searchInput}
|
|
236
|
+
/>
|
|
237
|
+
</html.div>
|
|
238
|
+
<Drawer.Body flush>
|
|
239
|
+
<Listbox.Root id={listboxId} open={open} onSelect={choose} aria-label={label}>
|
|
240
|
+
{children}
|
|
241
|
+
</Listbox.Root>
|
|
242
|
+
</Drawer.Body>
|
|
243
|
+
</Drawer.Portal>
|
|
244
|
+
</>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface ItemProps {
|
|
249
|
+
value: string
|
|
250
|
+
children: ReactNode
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function Item({value: itemValue, children}: ItemProps) {
|
|
254
|
+
const collecting = useContext(CollectContext)
|
|
255
|
+
const {value, labels, query, registerLabel, registerItem} = useCombobox()
|
|
256
|
+
const label = textOf(children)
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
if (collecting && label) registerLabel(itemValue, label)
|
|
259
|
+
}, [collecting, itemValue, label, registerLabel])
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
if (collecting) return registerItem(itemValue, label || itemValue)
|
|
262
|
+
}, [collecting, itemValue, label, registerItem])
|
|
263
|
+
if (collecting || !matchesQuery(labels[itemValue] ?? label, query)) return null
|
|
264
|
+
return (
|
|
265
|
+
<Listbox.Option value={itemValue} selected={value === itemValue}>
|
|
266
|
+
{children}
|
|
267
|
+
</Listbox.Option>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function ItemText({children}: {children: ReactNode}) {
|
|
272
|
+
return <html.span>{children}</html.span>
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Shown under the search field when nothing matches what is typed. */
|
|
276
|
+
function Empty({children}: {children: ReactNode}) {
|
|
277
|
+
const collecting = useContext(CollectContext)
|
|
278
|
+
const {query, items} = useCombobox()
|
|
279
|
+
if (collecting) return null
|
|
280
|
+
// Only the Items rendered now count: an initialLabels entry with no Item
|
|
281
|
+
// would otherwise hide Empty while the list shows nothing.
|
|
282
|
+
const anyMatch = [...items.values()].some((label) => matchesQuery(label, query))
|
|
283
|
+
if (anyMatch) return null
|
|
284
|
+
return <Listbox.Empty>{children}</Listbox.Empty>
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export const Combobox = {
|
|
288
|
+
Root,
|
|
289
|
+
Input,
|
|
290
|
+
Trigger,
|
|
291
|
+
Popup,
|
|
292
|
+
Item,
|
|
293
|
+
ItemText,
|
|
294
|
+
Empty,
|
|
295
|
+
}
|
|
@@ -3,6 +3,7 @@ import {html} from 'react-strict-dom'
|
|
|
3
3
|
import {styles} from './styles.css'
|
|
4
4
|
import {ComboboxContext, useCombobox} from './ComboboxContext'
|
|
5
5
|
import {useComboboxRoot} from './useComboboxRoot'
|
|
6
|
+
import {matchesQuery} from './comboboxState'
|
|
6
7
|
import {Listbox, getAnchorProps} from '../Listbox/Listbox'
|
|
7
8
|
|
|
8
9
|
// --- Root ---
|
|
@@ -134,7 +135,15 @@ function Trigger({children}: {children?: ReactNode}) {
|
|
|
134
135
|
// A Listbox placed under the input, as wide as the field: rendered into the
|
|
135
136
|
// ThemeProvider portal mount so it escapes ancestor `overflow: hidden` /
|
|
136
137
|
// `transform` containing blocks (notably Dialog and Drawer).
|
|
137
|
-
|
|
138
|
+
interface PopupProps {
|
|
139
|
+
children: ReactNode
|
|
140
|
+
/** The list's accessible name; on native also the sheet's title. */
|
|
141
|
+
label?: string
|
|
142
|
+
/** Native only: the sheet header's text button that closes it unchanged. Default: 'Cancel' */
|
|
143
|
+
cancelLabel?: string
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function Popup({children, label}: PopupProps) {
|
|
138
147
|
const {open, listboxId, rootRef, highlightedId, setHighlightedId, setValue} = useCombobox()
|
|
139
148
|
const anchor = useCallback(() => rootRef.current?.getBoundingClientRect(), [rootRef])
|
|
140
149
|
|
|
@@ -147,6 +156,7 @@ function Popup({children}: {children: ReactNode}) {
|
|
|
147
156
|
highlightedId={highlightedId}
|
|
148
157
|
onHighlight={setHighlightedId}
|
|
149
158
|
onSelect={setValue}
|
|
159
|
+
aria-label={label}
|
|
150
160
|
maxHeight="listMaxHSm"
|
|
151
161
|
>
|
|
152
162
|
{children}
|
|
@@ -167,9 +177,7 @@ function Item({value: itemValue, children}: ItemProps) {
|
|
|
167
177
|
const isSelected = selectedValue === itemValue
|
|
168
178
|
|
|
169
179
|
// Filter: check if this item matches the input
|
|
170
|
-
const
|
|
171
|
-
const query = inputValue.toLowerCase()
|
|
172
|
-
const isVisible = !query || label.toLowerCase().includes(query)
|
|
180
|
+
const isVisible = matchesQuery(labels[itemValue] ?? itemValue, inputValue)
|
|
173
181
|
|
|
174
182
|
useEffect(() => {
|
|
175
183
|
const el = ref.current
|
|
@@ -205,10 +213,8 @@ function Empty({children}: {children: ReactNode}) {
|
|
|
205
213
|
if (!open) return null
|
|
206
214
|
|
|
207
215
|
// Check if any items are visible
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
? Object.values(labels).some((label) => label.toLowerCase().includes(query))
|
|
211
|
-
: true
|
|
216
|
+
const hasVisibleItems =
|
|
217
|
+
!inputValue || Object.values(labels).some((label) => matchesQuery(label, inputValue))
|
|
212
218
|
|
|
213
219
|
if (hasVisibleItems) return null
|
|
214
220
|
|