@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.
Files changed (128) hide show
  1. package/README.md +49 -0
  2. package/dist/{Table-Bms2ftt3.js → Table-DnaoH-h5.js} +747 -635
  3. package/dist/Table-DnaoH-h5.js.map +1 -0
  4. package/dist/components/Button/Button.d.ts.map +1 -1
  5. package/dist/components/ColorMode/ColorMode.d.ts +2 -1
  6. package/dist/components/ColorMode/ColorMode.d.ts.map +1 -1
  7. package/dist/components/ColorMode/storage.d.ts +4 -0
  8. package/dist/components/ColorMode/storage.d.ts.map +1 -0
  9. package/dist/components/ColorMode/styles.css.d.ts +4 -0
  10. package/dist/components/ColorMode/styles.css.d.ts.map +1 -1
  11. package/dist/components/ColorMode/useSystemScheme.d.ts +7 -0
  12. package/dist/components/ColorMode/useSystemScheme.d.ts.map +1 -0
  13. package/dist/components/Combobox/Combobox.d.ts +7 -2
  14. package/dist/components/Combobox/Combobox.d.ts.map +1 -1
  15. package/dist/components/Combobox/comboboxState.d.ts +29 -0
  16. package/dist/components/Combobox/comboboxState.d.ts.map +1 -0
  17. package/dist/components/Combobox/native.css.d.ts +27 -0
  18. package/dist/components/Combobox/native.css.d.ts.map +1 -0
  19. package/dist/components/Combobox/useComboboxRoot.d.ts +6 -10
  20. package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -1
  21. package/dist/components/Drawer/Drawer.d.ts +5 -2
  22. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  23. package/dist/components/Drawer/DrawerSnap.stories.d.ts +2 -0
  24. package/dist/components/Drawer/DrawerSnap.stories.d.ts.map +1 -1
  25. package/dist/components/Drawer/native.css.d.ts +4 -0
  26. package/dist/components/Drawer/native.css.d.ts.map +1 -1
  27. package/dist/components/Drawer/styles.css.d.ts +4 -0
  28. package/dist/components/Drawer/styles.css.d.ts.map +1 -1
  29. package/dist/components/Form/FormSubmitContext.d.ts +8 -0
  30. package/dist/components/Form/FormSubmitContext.d.ts.map +1 -0
  31. package/dist/components/Listbox/native.css.d.ts +84 -0
  32. package/dist/components/Listbox/native.css.d.ts.map +1 -0
  33. package/dist/components/QrCode/QrCode.d.ts +8 -0
  34. package/dist/components/QrCode/QrCode.d.ts.map +1 -0
  35. package/dist/components/QrCode/QrCode.meta.d.ts +3 -0
  36. package/dist/components/QrCode/QrCode.meta.d.ts.map +1 -0
  37. package/dist/components/QrCode/QrCode.stories.d.ts +8 -0
  38. package/dist/components/QrCode/QrCode.stories.d.ts.map +1 -0
  39. package/dist/components/QrCode/geometry.d.ts +14 -0
  40. package/dist/components/QrCode/geometry.d.ts.map +1 -0
  41. package/dist/components/QrCode/styles.css.d.ts +9 -0
  42. package/dist/components/QrCode/styles.css.d.ts.map +1 -0
  43. package/dist/components/QrCode/types.d.ts +7 -0
  44. package/dist/components/QrCode/types.d.ts.map +1 -0
  45. package/dist/components/Select/Select.d.ts +9 -2
  46. package/dist/components/Select/Select.d.ts.map +1 -1
  47. package/dist/components/Select/Select.stories.d.ts +11 -0
  48. package/dist/components/Select/Select.stories.d.ts.map +1 -1
  49. package/dist/components/Select/native.css.d.ts +11 -0
  50. package/dist/components/Select/native.css.d.ts.map +1 -0
  51. package/dist/components/Select/selectState.d.ts +21 -0
  52. package/dist/components/Select/selectState.d.ts.map +1 -0
  53. package/dist/components/Select/styles.css.d.ts +10 -0
  54. package/dist/components/Select/styles.css.d.ts.map +1 -1
  55. package/dist/components/Select/useSelectRoot.d.ts +6 -8
  56. package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
  57. package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts +13 -0
  58. package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts.map +1 -0
  59. package/dist/components/ThemeProvider/ThemeProvider.d.ts +9 -2
  60. package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
  61. package/dist/components/ThemeProvider/overrideRule.d.ts +7 -0
  62. package/dist/components/ThemeProvider/overrideRule.d.ts.map +1 -0
  63. package/dist/components/ThemeProvider/overrides.d.ts +16 -0
  64. package/dist/components/ThemeProvider/overrides.d.ts.map +1 -0
  65. package/dist/index.css +1 -1
  66. package/dist/index.d.ts +2 -1
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +6230 -4656
  69. package/dist/index.js.map +1 -1
  70. package/dist/shared/textOf.d.ts +7 -0
  71. package/dist/shared/textOf.d.ts.map +1 -0
  72. package/dist/table.js +1 -1
  73. package/package.json +5 -2
  74. package/src/components/Button/Button.tsx +12 -1
  75. package/src/components/ColorMode/ColorMode.meta.ts +1 -1
  76. package/src/components/ColorMode/ColorMode.tsx +16 -37
  77. package/src/components/ColorMode/storage.native.ts +8 -0
  78. package/src/components/ColorMode/storage.ts +19 -0
  79. package/src/components/ColorMode/styles.css.ts +6 -0
  80. package/src/components/ColorMode/useSystemScheme.native.ts +6 -0
  81. package/src/components/ColorMode/useSystemScheme.ts +19 -0
  82. package/src/components/Combobox/Combobox.meta.ts +1 -1
  83. package/src/components/Combobox/Combobox.native.tsx +295 -0
  84. package/src/components/Combobox/Combobox.tsx +14 -8
  85. package/src/components/Combobox/comboboxState.ts +107 -0
  86. package/src/components/Combobox/native.css.ts +33 -0
  87. package/src/components/Combobox/useComboboxRoot.ts +17 -91
  88. package/src/components/Drawer/Drawer.meta.ts +1 -1
  89. package/src/components/Drawer/Drawer.native.tsx +29 -7
  90. package/src/components/Drawer/Drawer.tsx +11 -2
  91. package/src/components/Drawer/DrawerSnap.stories.tsx +22 -2
  92. package/src/components/Drawer/native.css.ts +4 -0
  93. package/src/components/Drawer/styles.css.ts +4 -0
  94. package/src/components/Form/Form.meta.ts +1 -1
  95. package/src/components/Form/Form.native.tsx +120 -0
  96. package/src/components/Form/FormSubmitContext.ts +12 -0
  97. package/src/components/Listbox/Listbox.meta.ts +1 -1
  98. package/src/components/Listbox/Listbox.native.tsx +153 -0
  99. package/src/components/Listbox/native.css.ts +91 -0
  100. package/src/components/QrCode/QrCode.meta.ts +18 -0
  101. package/src/components/QrCode/QrCode.native.tsx +31 -0
  102. package/src/components/QrCode/QrCode.stories.tsx +44 -0
  103. package/src/components/QrCode/QrCode.tsx +30 -0
  104. package/src/components/QrCode/geometry.ts +34 -0
  105. package/src/components/QrCode/styles.css.ts +12 -0
  106. package/src/components/QrCode/types.ts +6 -0
  107. package/src/components/ScrollArea/ScrollArea.meta.ts +1 -1
  108. package/src/components/ScrollArea/ScrollArea.native.tsx +61 -0
  109. package/src/components/Select/Select.meta.ts +1 -1
  110. package/src/components/Select/Select.native.tsx +224 -0
  111. package/src/components/Select/Select.stories.tsx +49 -1
  112. package/src/components/Select/Select.tsx +20 -3
  113. package/src/components/Select/native.css.ts +13 -0
  114. package/src/components/Select/selectState.ts +36 -0
  115. package/src/components/Select/styles.css.ts +11 -0
  116. package/src/components/Select/useSelectRoot.ts +9 -30
  117. package/src/components/ThemeProvider/ThemeOverrides.stories.tsx +113 -0
  118. package/src/components/ThemeProvider/ThemeProvider.meta.ts +1 -1
  119. package/src/components/ThemeProvider/ThemeProvider.native.tsx +36 -7
  120. package/src/components/ThemeProvider/ThemeProvider.tsx +42 -6
  121. package/src/components/ThemeProvider/overrideRule.ts +39 -0
  122. package/src/components/ThemeProvider/overrides.ts +27 -0
  123. package/src/form.native.ts +7 -0
  124. package/src/index.native.ts +16 -0
  125. package/src/index.ts +2 -0
  126. package/src/shared/focusInput.native.ts +18 -0
  127. package/src/shared/textOf.ts +13 -0
  128. 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, p as gt, R as it, H as kt, q as pt, t as bt, v as ft, w as mt, b as ht, f as yt, r as St, x as Tt } from "./Table-Bms2ftt3.js";
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.13.0",
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
- "@duro-app/tokens": "^5.13.0"
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={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
- if (!key || isNative || typeof window === 'undefined') return null
74
- try {
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
- writeStored(storageKey, next)
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. The initial value MUST be the
146
- // SSR-stable default ('dark', what the server renders without a DOM) so the
147
- // first client render matches the server — reading matchMedia in the
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 type="button" aria-label={label} onClick={cycle} style={styles.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,6 @@
1
+ import {useColorScheme} from 'react-native'
2
+
3
+ /** The OS colour scheme, live, from RN Appearance; no answer reads as dark. */
4
+ export function useSystemScheme(): 'light' | 'dark' {
5
+ return useColorScheme() === 'light' ? 'light' : 'dark'
6
+ }
@@ -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
- 'Searchable dropdown for selecting a value from a filterable list. Combines a text input with a popup listbox. Compound component — Root is required.',
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
- function Popup({children}: {children: ReactNode}) {
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 label = labels[itemValue] ?? itemValue
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 query = inputValue.toLowerCase()
209
- const hasVisibleItems = query
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