@passui/react 0.1.45 → 0.1.46

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 (140) hide show
  1. package/README.md +57 -46
  2. package/dist/types/src/components/accordion/accordion.d.ts +1 -1
  3. package/dist/types/src/components/breadcrumb/breadcrumb.d.ts +1 -1
  4. package/dist/types/src/components/inline-disclosure/inline-disclosure.d.ts +1 -1
  5. package/dist/types/src/components/popover/anchored-surface.d.ts +5 -1
  6. package/dist/types/src/components/popover/index.d.ts +41 -0
  7. package/dist/types/src/components/popover/popover-content.d.ts +32 -0
  8. package/dist/types/src/components/popover/popover-root.d.ts +22 -0
  9. package/dist/types/src/components/popover/popover-trigger.d.ts +23 -0
  10. package/dist/types/src/components/stepper/rolling-digit.d.ts +1 -1
  11. package/dist/types/src/components/table/table.d.ts +1 -1
  12. package/dist/types/src/components/view-controller/view-controller.d.ts +2 -2
  13. package/dist/types/src/index.d.ts +1 -1
  14. package/dist/types/src/machines/popover.machine.d.ts +30 -0
  15. package/package.json +307 -307
  16. package/src/appearance/index.ts +2 -2
  17. package/src/appearance/platform.tsx +76 -60
  18. package/src/appearance/theme.tsx +409 -377
  19. package/src/components/accordion/accordion.tsx +58 -49
  20. package/src/components/ambient/ambient.tsx +25 -25
  21. package/src/components/ambient/index.ts +1 -1
  22. package/src/components/aspect-ratio/aspect-ratio.tsx +21 -16
  23. package/src/components/breadcrumb/breadcrumb.tsx +63 -57
  24. package/src/components/button/button-styles.ts +39 -32
  25. package/src/components/button/button.tsx +91 -84
  26. package/src/components/button/icon-button.tsx +27 -25
  27. package/src/components/button/pending-spinner.tsx +8 -8
  28. package/src/components/button/use-pending-indicator.ts +11 -11
  29. package/src/components/chip/chip.tsx +35 -30
  30. package/src/components/code-block/code-block.tsx +40 -39
  31. package/src/components/combo-box/combo-box.tsx +5 -5
  32. package/src/components/command/command.tsx +124 -94
  33. package/src/components/compact-date-picker/calendar-month.ts +75 -53
  34. package/src/components/compact-date-picker/compact-date-picker.tsx +308 -289
  35. package/src/components/compact-time-picker/compact-time-picker.tsx +288 -281
  36. package/src/components/compact-time-picker/time-value.ts +32 -29
  37. package/src/components/content-unavailable-view/content-unavailable-view.tsx +64 -48
  38. package/src/components/content-view/content-view.tsx +168 -159
  39. package/src/components/content-view/index.ts +1 -1
  40. package/src/components/context-menu/conceal-menu-source.ts +25 -25
  41. package/src/components/context-menu/context-menu-host.tsx +439 -402
  42. package/src/components/context-menu/context-menu-store.ts +125 -123
  43. package/src/components/context-menu/menu-navigation.ts +25 -18
  44. package/src/components/context-menu/menu-position.ts +33 -29
  45. package/src/components/context-menu/menu-reveal.ts +32 -32
  46. package/src/components/context-menu/use-context-menu.ts +63 -56
  47. package/src/components/conversation/conversation.tsx +265 -263
  48. package/src/components/conversation/index.ts +1 -1
  49. package/src/components/date-picker/date-picker.tsx +95 -94
  50. package/src/components/date-picker/date-value.ts +20 -10
  51. package/src/components/dialog/dialog-host.tsx +105 -103
  52. package/src/components/dialog/dialog-store.ts +37 -36
  53. package/src/components/dialog/dialog.tsx +192 -179
  54. package/src/components/dialog/index.ts +1 -1
  55. package/src/components/disclosure-group/disclosure-group.tsx +93 -86
  56. package/src/components/divider/divider.tsx +24 -22
  57. package/src/components/inline-disclosure/inline-disclosure.tsx +40 -36
  58. package/src/components/keyboard-shortcut/keyboard-shortcut.tsx +23 -19
  59. package/src/components/labeled-content/labeled-content.tsx +68 -68
  60. package/src/components/locale-switcher/index.ts +1 -1
  61. package/src/components/locale-switcher/locale-switcher.tsx +279 -251
  62. package/src/components/menu/menu.tsx +75 -58
  63. package/src/components/morph/index.ts +2 -2
  64. package/src/components/morph/morph.tsx +227 -201
  65. package/src/components/morph/use-morph-disclosure.ts +19 -19
  66. package/src/components/one-time-code/one-time-code.tsx +216 -200
  67. package/src/components/picker/picker-trigger.ts +16 -13
  68. package/src/components/picker/picker.tsx +371 -345
  69. package/src/components/popover/anchored-surface.tsx +253 -229
  70. package/src/components/popover/index.ts +50 -0
  71. package/src/components/popover/popover-content.tsx +334 -0
  72. package/src/components/popover/popover-fit.ts +58 -53
  73. package/src/components/popover/popover-root.tsx +97 -0
  74. package/src/components/popover/popover-trigger.tsx +71 -0
  75. package/src/components/profile-image/profile-image.tsx +80 -64
  76. package/src/components/progress-view/progress-view.tsx +61 -59
  77. package/src/components/progress-view/use-liquid-progress.ts +38 -38
  78. package/src/components/radio-group/radio-group.tsx +71 -68
  79. package/src/components/search-field/expanding-search-field.tsx +191 -177
  80. package/src/components/search-field/search-field.tsx +59 -50
  81. package/src/components/search-list/search-list.tsx +246 -236
  82. package/src/components/section/section.tsx +58 -58
  83. package/src/components/secure-field/secure-field.tsx +3 -3
  84. package/src/components/segmented-control/segmented-control.tsx +136 -136
  85. package/src/components/sheet/modal-environment.ts +71 -64
  86. package/src/components/sheet/sheet-portal.tsx +58 -53
  87. package/src/components/sheet/sheet.tsx +146 -141
  88. package/src/components/sheet/use-sheet-focus.ts +97 -82
  89. package/src/components/sidebar/sidebar.tsx +78 -78
  90. package/src/components/skeleton/skeleton.tsx +30 -21
  91. package/src/components/slider/slider.tsx +113 -104
  92. package/src/components/slosh-slider/liquid-step.ts +32 -31
  93. package/src/components/slosh-slider/slosh-slider.tsx +265 -228
  94. package/src/components/stat-tile/index.ts +1 -1
  95. package/src/components/stat-tile/stat-tile.tsx +79 -68
  96. package/src/components/status-indicator/status-indicator.tsx +40 -37
  97. package/src/components/stepper/rolling-digit.tsx +82 -68
  98. package/src/components/stepper/stepper.tsx +122 -113
  99. package/src/components/swatch-picker/swatch-picker.tsx +120 -120
  100. package/src/components/tab-bar/tab-bar.tsx +149 -148
  101. package/src/components/tab-view/tab-view.tsx +140 -137
  102. package/src/components/table/table.tsx +117 -98
  103. package/src/components/text-editor/text-editor.tsx +34 -29
  104. package/src/components/text-field/text-field.tsx +182 -180
  105. package/src/components/toast/toast-store.ts +127 -115
  106. package/src/components/toast/toaster.tsx +361 -347
  107. package/src/components/toggle/toggle.tsx +297 -282
  108. package/src/components/tooltip/tooltip-host.tsx +157 -150
  109. package/src/components/view-controller/index.ts +1 -1
  110. package/src/components/view-controller/view-controller.tsx +64 -53
  111. package/src/components/wake-slider/wake-slider.tsx +220 -205
  112. package/src/hooks/scroll/minimize-decision.ts +11 -7
  113. package/src/hooks/use-anchored-disclosure.ts +19 -19
  114. package/src/hooks/use-anchored-placement.ts +29 -25
  115. package/src/hooks/use-dismiss.ts +33 -29
  116. package/src/hooks/use-floating-dismiss.ts +24 -22
  117. package/src/hooks/use-media-query.ts +67 -60
  118. package/src/hooks/use-minimize-on-scroll.ts +69 -60
  119. package/src/hooks/use-visual-viewport.ts +63 -63
  120. package/src/hooks/viewport/types.ts +16 -16
  121. package/src/hooks/viewport/viewport-metrics.ts +16 -10
  122. package/src/icons/icons.tsx +440 -328
  123. package/src/index.ts +91 -91
  124. package/src/lib/anchored-placement.ts +70 -65
  125. package/src/lib/class-names.ts +14 -13
  126. package/src/lib/clipboard.ts +8 -7
  127. package/src/lib/date.ts +61 -60
  128. package/src/lib/floating-layers.ts +39 -24
  129. package/src/lib/haptics.ts +10 -10
  130. package/src/lib/language.ts +9 -9
  131. package/src/lib/range-value.ts +27 -21
  132. package/src/lib/screen-edges.ts +33 -30
  133. package/src/lib/shapes.ts +38 -34
  134. package/src/machines/popover.machine.ts +51 -0
  135. package/src/motion/transitions.ts +46 -43
  136. package/src/styles/code-block.css +69 -43
  137. package/src/styles/motion-controls.css +393 -191
  138. package/src/tokens.ts +1 -1
  139. package/dist/types/src/components/popover/popover.d.ts +0 -21
  140. package/src/components/popover/popover.tsx +0 -334
@@ -1,54 +1,62 @@
1
- import { useAnchoredDisclosure } from "../../hooks/use-anchored-disclosure"
2
- import { type KeyboardEvent, useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
3
- import { createPortal } from "react-dom"
4
- import { CheckIcon, SearchIcon, SelectorIcon } from "../../icons/icons"
5
- import { cn } from "../../lib/class-names"
6
- import { haptic } from "../../lib/haptics"
7
- import { AnchoredSurface } from "../popover/anchored-surface"
8
- import { pickerTriggerStyle, type PickerSize } from "./picker-trigger"
1
+ import { useAnchoredDisclosure } from "../../hooks/use-anchored-disclosure";
2
+ import {
3
+ type KeyboardEvent,
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+ import { createPortal } from "react-dom";
12
+ import { CheckIcon, SearchIcon, SelectorIcon } from "../../icons/icons";
13
+ import { cn } from "../../lib/class-names";
14
+ import { haptic } from "../../lib/haptics";
15
+ import { AnchoredSurface } from "../popover/anchored-surface";
16
+ import { pickerTriggerStyle, type PickerSize } from "./picker-trigger";
9
17
 
10
18
  export interface PickerOption<T extends string> {
11
- value: T
12
- label: string
13
- /** A quieter second line or trailing note, such as "Free" or a region. */
14
- disabled?: boolean
15
- keywords?: string[]
16
- detail?: string
17
- /** The option's own language, so it is spoken and shaped right. */
18
- lang?: string
19
+ value: T;
20
+ label: string;
21
+ /** A quieter second line or trailing note, such as "Free" or a region. */
22
+ disabled?: boolean;
23
+ keywords?: string[];
24
+ detail?: string;
25
+ /** The option's own language, so it is spoken and shaped right. */
26
+ lang?: string;
19
27
  }
20
28
 
21
29
  /** Values and callbacks share the option value type, including literal unions. */
22
30
  export interface PickerProps<T extends string> {
23
- /** The selected option's value. */
24
- value: T
25
- /** Available choices. Use a literal union for a fixed set of values. */
26
- options: ReadonlyArray<PickerOption<T>>
27
- /** Called with the selected option value. */
28
- onChange: (value: NoInfer<T>) => void
29
- /** Accessible name for the trigger. */
30
- label: string
31
- /** Shown when the selected value has no matching option. */
32
- placeholder?: string
33
- /** Defaults to on for lists of ten or more options. */
34
- searchable?: boolean
35
- /** Label for the filter field. @default "Search" */
36
- searchPlaceholder?: string
37
- /** Shown when the filter matches nothing. @default "No matches" */
38
- emptyLabel?: string
39
- /** Offers a new value when no option has the same label or value. */
40
- onCreate?: (query: string) => void
41
- /** Localized creation action; receives the trimmed query. */
42
- createLabel?: (query: string) => string
43
- className?: string
44
- /** Prevents opening or choosing an option. @default false */
45
- disabled?: boolean
46
- /** Trigger height and text scale. @default "md" */
47
- size?: PickerSize
31
+ /** The selected option's value. */
32
+ value: T;
33
+ /** Available choices. Use a literal union for a fixed set of values. */
34
+ options: ReadonlyArray<PickerOption<T>>;
35
+ /** Called with the selected option value. */
36
+ onChange: (value: NoInfer<T>) => void;
37
+ /** Accessible name for the trigger. */
38
+ label: string;
39
+ /** Shown when the selected value has no matching option. */
40
+ placeholder?: string;
41
+ /** Defaults to on for lists of ten or more options. */
42
+ searchable?: boolean;
43
+ /** Label for the filter field. @default "Search" */
44
+ searchPlaceholder?: string;
45
+ /** Shown when the filter matches nothing. @default "No matches" */
46
+ emptyLabel?: string;
47
+ /** Offers a new value when no option has the same label or value. */
48
+ onCreate?: (query: string) => void;
49
+ /** Localized creation action; receives the trimmed query. */
50
+ createLabel?: (query: string) => string;
51
+ className?: string;
52
+ /** Prevents opening or choosing an option. @default false */
53
+ disabled?: boolean;
54
+ /** Trigger height and text scale. @default "md" */
55
+ size?: PickerSize;
48
56
  }
49
57
 
50
58
  /** Long lists get a filter field, as in the system pickers. */
51
- const SEARCH_FROM = 10
59
+ const SEARCH_FROM = 10;
52
60
 
53
61
  /**
54
62
  * A pop-up button: shows the chosen value and opens a list that floats
@@ -57,322 +65,340 @@ const SEARCH_FROM = 10
57
65
  * platform.
58
66
  */
59
67
  export function Picker<T extends string>({
60
- value,
61
- options,
62
- onChange,
63
- label,
64
- placeholder,
65
- searchable,
66
- searchPlaceholder = "Search",
67
- emptyLabel = "No matches",
68
- className,
69
- onCreate,
70
- createLabel,
71
- disabled = false,
72
- size = "md",
68
+ value,
69
+ options,
70
+ onChange,
71
+ label,
72
+ placeholder,
73
+ searchable,
74
+ searchPlaceholder = "Search",
75
+ emptyLabel = "No matches",
76
+ className,
77
+ onCreate,
78
+ createLabel,
79
+ disabled = false,
80
+ size = "md",
73
81
  }: PickerProps<T>) {
74
- const panel = useAnchoredDisclosure(disabled)
75
- const { open } = panel
76
- const trigger = useRef<HTMLButtonElement>(null)
77
- const listId = useId()
78
- const selected = options.find((option) => option.value === value)
82
+ const panel = useAnchoredDisclosure(disabled);
83
+ const { open } = panel;
84
+ const trigger = useRef<HTMLButtonElement>(null);
85
+ const listId = useId();
86
+ const selected = options.find((option) => option.value === value);
79
87
 
80
- const close = useCallback(
81
- (refocus: boolean) => {
82
- panel.close()
83
- if (refocus) trigger.current?.focus({ preventScroll: true })
84
- },
85
- [panel.close]
86
- )
88
+ const close = useCallback(
89
+ (refocus: boolean) => {
90
+ panel.close();
91
+ if (refocus) trigger.current?.focus({ preventScroll: true });
92
+ },
93
+ [panel.close],
94
+ );
87
95
 
88
- const onTriggerKey = (event: KeyboardEvent<HTMLButtonElement>) => {
89
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
90
- event.preventDefault()
91
- panel.show()
92
- }
93
- }
96
+ const onTriggerKey = (event: KeyboardEvent<HTMLButtonElement>) => {
97
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
98
+ event.preventDefault();
99
+ panel.show();
100
+ }
101
+ };
94
102
 
95
- return (
96
- <>
97
- <button
98
- ref={trigger}
99
- type="button"
100
- role="combobox"
101
- aria-label={label}
102
- aria-haspopup="listbox"
103
- aria-expanded={open && !disabled}
104
- aria-controls={open && !disabled ? listId : undefined}
105
- disabled={disabled}
106
- onClick={() => panel.toggle()}
107
- onKeyDown={onTriggerKey}
108
- className={pickerTriggerStyle(className, size)}
109
- >
110
- <span
111
- lang={selected?.lang}
112
- className={cn("min-w-0 grow truncate", !selected && "text-label-tertiary")}
113
- >
114
- {selected?.label ?? placeholder ?? ""}
115
- </span>
116
- <SelectorIcon size={15} strokeWidth={2} className="shrink-0 text-label-tertiary" />
117
- </button>
118
- {typeof document !== "undefined" &&
119
- createPortal(
120
- panel.mounted && trigger.current ? (
121
- <SelectList
122
- open={open}
123
- onExited={panel.onExited}
124
- id={listId}
125
- anchor={trigger.current}
126
- options={options}
127
- value={value}
128
- label={label}
129
- searchable={
130
- searchable ?? (Boolean(onCreate) || options.length >= SEARCH_FROM)
131
- }
132
- searchPlaceholder={searchPlaceholder}
133
- emptyLabel={emptyLabel}
134
- onCreate={onCreate}
135
- createLabel={createLabel}
136
- onPick={(next) => {
137
- close(true)
138
- if (next === value) return
139
- haptic("selection")
140
- onChange(next)
141
- }}
142
- onClose={close}
143
- />
144
- ) : null,
145
- document.body
146
- )}
147
- </>
148
- )
103
+ return (
104
+ <>
105
+ <button
106
+ ref={trigger}
107
+ type="button"
108
+ role="combobox"
109
+ aria-label={label}
110
+ aria-haspopup="listbox"
111
+ aria-expanded={open && !disabled}
112
+ aria-controls={open && !disabled ? listId : undefined}
113
+ disabled={disabled}
114
+ onClick={() => panel.toggle()}
115
+ onKeyDown={onTriggerKey}
116
+ className={pickerTriggerStyle(className, size)}
117
+ >
118
+ <span
119
+ lang={selected?.lang}
120
+ className={cn(
121
+ "min-w-0 grow truncate",
122
+ !selected && "text-label-tertiary",
123
+ )}
124
+ >
125
+ {selected?.label ?? placeholder ?? ""}
126
+ </span>
127
+ <SelectorIcon
128
+ size={15}
129
+ strokeWidth={2}
130
+ className="text-label-tertiary shrink-0"
131
+ />
132
+ </button>
133
+ {typeof document !== "undefined" &&
134
+ createPortal(
135
+ panel.mounted && trigger.current ? (
136
+ <SelectList
137
+ open={open}
138
+ onExited={panel.onExited}
139
+ id={listId}
140
+ anchor={trigger.current}
141
+ options={options}
142
+ value={value}
143
+ label={label}
144
+ searchable={
145
+ searchable ??
146
+ (Boolean(onCreate) || options.length >= SEARCH_FROM)
147
+ }
148
+ searchPlaceholder={searchPlaceholder}
149
+ emptyLabel={emptyLabel}
150
+ onCreate={onCreate}
151
+ createLabel={createLabel}
152
+ onPick={(next) => {
153
+ close(true);
154
+ if (next === value) return;
155
+ haptic("selection");
156
+ onChange(next);
157
+ }}
158
+ onClose={close}
159
+ />
160
+ ) : null,
161
+ document.body,
162
+ )}
163
+ </>
164
+ );
149
165
  }
150
166
 
151
167
  function SelectList<T extends string>({
152
- open,
153
- onExited,
154
- id,
155
- anchor,
156
- options,
157
- value,
158
- label,
159
- searchable,
160
- searchPlaceholder,
161
- emptyLabel,
162
- onPick,
163
- onClose,
164
- onCreate,
165
- createLabel,
168
+ open,
169
+ onExited,
170
+ id,
171
+ anchor,
172
+ options,
173
+ value,
174
+ label,
175
+ searchable,
176
+ searchPlaceholder,
177
+ emptyLabel,
178
+ onPick,
179
+ onClose,
180
+ onCreate,
181
+ createLabel,
166
182
  }: {
167
- open: boolean
168
- onExited: () => void
169
- id: string
170
- anchor: HTMLElement
171
- options: ReadonlyArray<PickerOption<T>>
172
- value: T
173
- label: string
174
- searchable: boolean
175
- searchPlaceholder: string
176
- emptyLabel: string
177
- onCreate?: (query: string) => void
178
- createLabel?: (query: string) => string
179
- onPick: (value: T) => void
180
- onClose: (refocus: boolean) => void
183
+ open: boolean;
184
+ onExited: () => void;
185
+ id: string;
186
+ anchor: HTMLElement;
187
+ options: ReadonlyArray<PickerOption<T>>;
188
+ value: T;
189
+ label: string;
190
+ searchable: boolean;
191
+ searchPlaceholder: string;
192
+ emptyLabel: string;
193
+ onCreate?: (query: string) => void;
194
+ createLabel?: (query: string) => string;
195
+ onPick: (value: T) => void;
196
+ onClose: (refocus: boolean) => void;
181
197
  }) {
182
- const list = useRef<HTMLDivElement>(null)
183
- const search = useRef<HTMLInputElement>(null)
184
- const [query, setQuery] = useState("")
185
- const typed = useRef({ text: "", at: 0 })
198
+ const list = useRef<HTMLDivElement>(null);
199
+ const search = useRef<HTMLInputElement>(null);
200
+ const [query, setQuery] = useState("");
201
+ const typed = useRef({ text: "", at: 0 });
186
202
 
187
- const shown = useMemo(() => {
188
- const needle = query.trim().toLocaleLowerCase()
189
- if (!needle) return options
190
- return options.filter((option) =>
191
- `${option.label} ${option.detail ?? ""} ${option.value} ${(option.keywords ?? []).join(" ")}`
192
- .toLocaleLowerCase()
193
- .includes(needle)
194
- )
195
- }, [options, query])
203
+ const shown = useMemo(() => {
204
+ const needle = query.trim().toLocaleLowerCase();
205
+ if (!needle) return options;
206
+ return options.filter((option) =>
207
+ `${option.label} ${option.detail ?? ""} ${option.value} ${(option.keywords ?? []).join(" ")}`
208
+ .toLocaleLowerCase()
209
+ .includes(needle),
210
+ );
211
+ }, [options, query]);
196
212
 
197
- const enabled = shown.filter((option) => !option.disabled)
198
- const trimmed = query.trim()
199
- const canCreate = Boolean(
200
- onCreate &&
201
- trimmed &&
202
- !options.some(
203
- (option) =>
204
- option.label.toLocaleLowerCase() === trimmed.toLocaleLowerCase() ||
205
- option.value.toLocaleLowerCase() === trimmed.toLocaleLowerCase()
206
- )
207
- )
208
- const create = () => {
209
- if (!canCreate) return
210
- onClose(true)
211
- onCreate?.(trimmed)
212
- }
213
- const [active, setActive] = useState(() =>
214
- Math.max(
215
- 0,
216
- options
217
- .filter((option) => !option.disabled)
218
- .findIndex((option) => option.value === value)
219
- )
220
- )
221
- const last = enabled.length + (canCreate ? 1 : 0) - 1
222
- const activeOption = enabled[Math.min(active, last)]
223
- const activeId = activeOption
224
- ? `${id}-${activeOption.value}`
225
- : canCreate
226
- ? `${id}-create`
227
- : undefined
213
+ const enabled = shown.filter((option) => !option.disabled);
214
+ const trimmed = query.trim();
215
+ const canCreate = Boolean(
216
+ onCreate &&
217
+ trimmed &&
218
+ !options.some(
219
+ (option) =>
220
+ option.label.toLocaleLowerCase() === trimmed.toLocaleLowerCase() ||
221
+ option.value.toLocaleLowerCase() === trimmed.toLocaleLowerCase(),
222
+ ),
223
+ );
224
+ const create = () => {
225
+ if (!canCreate) return;
226
+ onClose(true);
227
+ onCreate?.(trimmed);
228
+ };
229
+ const [active, setActive] = useState(() =>
230
+ Math.max(
231
+ 0,
232
+ options
233
+ .filter((option) => !option.disabled)
234
+ .findIndex((option) => option.value === value),
235
+ ),
236
+ );
237
+ const last = enabled.length + (canCreate ? 1 : 0) - 1;
238
+ const activeOption = enabled[Math.min(active, last)];
239
+ const activeId = activeOption
240
+ ? `${id}-${activeOption.value}`
241
+ : canCreate
242
+ ? `${id}-create`
243
+ : undefined;
228
244
 
229
- useEffect(() => {
230
- if (searchable) search.current?.focus()
231
- else list.current?.focus()
232
- }, [searchable])
245
+ useEffect(() => {
246
+ if (searchable) search.current?.focus();
247
+ else list.current?.focus();
248
+ }, [searchable]);
233
249
 
234
- // The chosen option starts in view, then follows the keyboard.
235
- useEffect(() => {
236
- if (!activeOption) return
237
- list.current
238
- ?.querySelector(`[data-value="${CSS.escape(activeOption.value)}"]`)
239
- ?.scrollIntoView({ block: "nearest" })
240
- }, [activeOption])
250
+ // The chosen option starts in view, then follows the keyboard.
251
+ useEffect(() => {
252
+ if (!activeOption) return;
253
+ list.current
254
+ ?.querySelector(`[data-value="${CSS.escape(activeOption.value)}"]`)
255
+ ?.scrollIntoView({ block: "nearest" });
256
+ }, [activeOption]);
241
257
 
242
- const onKeyDown = (event: KeyboardEvent) => {
243
- const step = (to: number) => {
244
- event.preventDefault()
245
- setActive(Math.max(0, Math.min(last, to)))
246
- }
247
- const current = Math.min(active, last)
248
- if (event.key === "ArrowDown") step(current + 1)
249
- else if (event.key === "ArrowUp") step(current - 1)
250
- else if (event.key === "Home" && !searchable) step(0)
251
- else if (event.key === "End" && !searchable) step(last)
252
- else if (event.key === "PageDown") step(current + 8)
253
- else if (event.key === "PageUp") step(current - 8)
254
- else if (event.key === "Enter" || (event.key === " " && !searchable)) {
255
- event.preventDefault()
256
- if (activeOption) onPick(activeOption.value)
257
- else if (canCreate) create()
258
- } else if (event.key === "Escape") {
259
- event.preventDefault()
260
- event.stopPropagation()
261
- onClose(true)
262
- } else if (event.key === "Tab") {
263
- onClose(false)
264
- } else if (!searchable && event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
265
- // Type to jump, as in a native pop-up button.
266
- const now = Date.now()
267
- const text = (now - typed.current.at < 700 ? typed.current.text : "") + event.key
268
- typed.current = { text, at: now }
269
- const match = enabled.findIndex((option) =>
270
- option.label.toLocaleLowerCase().startsWith(text.toLocaleLowerCase())
271
- )
272
- if (match >= 0) setActive(match)
273
- }
274
- }
258
+ const onKeyDown = (event: KeyboardEvent) => {
259
+ const step = (to: number) => {
260
+ event.preventDefault();
261
+ setActive(Math.max(0, Math.min(last, to)));
262
+ };
263
+ const current = Math.min(active, last);
264
+ if (event.key === "ArrowDown") step(current + 1);
265
+ else if (event.key === "ArrowUp") step(current - 1);
266
+ else if (event.key === "Home" && !searchable) step(0);
267
+ else if (event.key === "End" && !searchable) step(last);
268
+ else if (event.key === "PageDown") step(current + 8);
269
+ else if (event.key === "PageUp") step(current - 8);
270
+ else if (event.key === "Enter" || (event.key === " " && !searchable)) {
271
+ event.preventDefault();
272
+ if (activeOption) onPick(activeOption.value);
273
+ else if (canCreate) create();
274
+ } else if (event.key === "Escape") {
275
+ event.preventDefault();
276
+ event.stopPropagation();
277
+ onClose(true);
278
+ } else if (event.key === "Tab") {
279
+ onClose(false);
280
+ } else if (
281
+ !searchable &&
282
+ event.key.length === 1 &&
283
+ !event.metaKey &&
284
+ !event.ctrlKey
285
+ ) {
286
+ // Type to jump, as in a native pop-up button.
287
+ const now = Date.now();
288
+ const text =
289
+ (now - typed.current.at < 700 ? typed.current.text : "") + event.key;
290
+ typed.current = { text, at: now };
291
+ const match = enabled.findIndex((option) =>
292
+ option.label.toLocaleLowerCase().startsWith(text.toLocaleLowerCase()),
293
+ );
294
+ if (match >= 0) setActive(match);
295
+ }
296
+ };
275
297
 
276
- return (
277
- <AnchoredSurface
278
- open={open}
279
- onExited={onExited}
280
- anchor={anchor}
281
- width={Math.min(Math.max(anchor.getBoundingClientRect().width, 220), 360)}
282
- needed={240}
283
- onClose={onClose}
284
- onKeyDown={onKeyDown}
285
- contentClassName="overflow-hidden"
286
- >
287
- {searchable && (
288
- <div className="flex shrink-0 items-center gap-2 border-b border-separator/70 px-3 py-2">
289
- <SearchIcon size={15} className="shrink-0 text-label-tertiary" />
290
- <input
291
- ref={search}
292
- value={query}
293
- placeholder={searchPlaceholder}
294
- aria-label={searchPlaceholder}
295
- aria-controls={id}
296
- aria-activedescendant={activeId}
297
- onChange={(event) => {
298
- setQuery(event.target.value)
299
- setActive(0)
300
- }}
301
- className="min-w-0 grow bg-transparent text-subheadline text-label outline-none placeholder:text-label-tertiary"
302
- />
303
- </div>
304
- )}
305
- <div
306
- ref={list}
307
- id={id}
308
- role="listbox"
309
- aria-label={label}
310
- tabIndex={-1}
311
- aria-activedescendant={activeId}
312
- className="no-scrollbar flex min-h-0 flex-col overflow-y-auto overscroll-contain p-1.5 outline-none"
313
- >
314
- {shown.map((option) => {
315
- const chosen = option.value === value
316
- const focused = option === activeOption
317
- return (
318
- <div
319
- key={option.value}
320
- id={`${id}-${option.value}`}
321
- data-value={option.value}
322
- role="option"
323
- tabIndex={-1}
324
- aria-selected={chosen}
325
- aria-disabled={option.disabled || undefined}
326
- lang={option.lang}
327
- onPointerMove={() =>
328
- !option.disabled && setActive(enabled.indexOf(option))
329
- }
330
- onClick={() => !option.disabled && onPick(option.value)}
331
- className={cn(
332
- "flex min-h-8 cursor-default items-center gap-2 rounded-lg px-2 py-1.5 text-subheadline [@media(pointer:coarse)]:min-h-11",
333
- focused ? "bg-fill" : "",
334
- option.disabled && "opacity-40",
335
- chosen ? "font-semibold text-label" : "text-label"
336
- )}
337
- >
338
- <span className="flex w-4 shrink-0 justify-center">
339
- {chosen && (
340
- <CheckIcon
341
- size={15}
342
- strokeWidth={2.4}
343
- className="text-accent-text"
344
- />
345
- )}
346
- </span>
347
- <span className="min-w-0 grow break-words">{option.label}</span>
348
- {option.detail && (
349
- <span className="shrink-0 text-caption font-normal text-label-tertiary">
350
- {option.detail}
351
- </span>
352
- )}
353
- </div>
354
- )
355
- })}
356
- {canCreate && (
357
- <div
358
- id={`${id}-create`}
359
- role="option"
360
- aria-selected={false}
361
- tabIndex={-1}
362
- onPointerMove={() => setActive(enabled.length)}
363
- onClick={create}
364
- className={cn(
365
- "flex min-h-9 cursor-default items-center rounded-lg px-2 py-1.5 text-subheadline text-accent-text",
366
- !activeOption && "bg-fill"
367
- )}
368
- >
369
- {createLabel?.(trimmed) ?? trimmed}
370
- </div>
371
- )}
372
- {shown.length === 0 && !canCreate && (
373
- <p className="px-3 py-2 text-footnote text-label-tertiary">{emptyLabel}</p>
374
- )}
375
- </div>
376
- </AnchoredSurface>
377
- )
298
+ return (
299
+ <AnchoredSurface
300
+ open={open}
301
+ onExited={onExited}
302
+ anchor={anchor}
303
+ width={Math.min(Math.max(anchor.getBoundingClientRect().width, 220), 360)}
304
+ needed={240}
305
+ onClose={onClose}
306
+ onKeyDown={onKeyDown}
307
+ contentClassName="overflow-hidden"
308
+ >
309
+ {searchable && (
310
+ <div className="border-separator/70 flex shrink-0 items-center gap-2 border-b px-3 py-2">
311
+ <SearchIcon size={15} className="text-label-tertiary shrink-0" />
312
+ <input
313
+ ref={search}
314
+ value={query}
315
+ placeholder={searchPlaceholder}
316
+ aria-label={searchPlaceholder}
317
+ aria-controls={id}
318
+ aria-activedescendant={activeId}
319
+ onChange={(event) => {
320
+ setQuery(event.target.value);
321
+ setActive(0);
322
+ }}
323
+ className="text-subheadline text-label placeholder:text-label-tertiary min-w-0 grow bg-transparent outline-none"
324
+ />
325
+ </div>
326
+ )}
327
+ <div
328
+ ref={list}
329
+ id={id}
330
+ role="listbox"
331
+ aria-label={label}
332
+ tabIndex={-1}
333
+ aria-activedescendant={activeId}
334
+ className="no-scrollbar flex min-h-0 flex-col overflow-y-auto overscroll-contain p-1.5 outline-none"
335
+ >
336
+ {shown.map((option) => {
337
+ const chosen = option.value === value;
338
+ const focused = option === activeOption;
339
+ return (
340
+ // biome-ignore lint/a11y/useKeyWithClickEvents: option div handles keyboard via role=option and aria-selected
341
+ <div
342
+ key={option.value}
343
+ id={`${id}-${option.value}`}
344
+ data-value={option.value}
345
+ role="option"
346
+ tabIndex={-1}
347
+ aria-selected={chosen}
348
+ aria-disabled={option.disabled || undefined}
349
+ lang={option.lang}
350
+ onPointerMove={() =>
351
+ !option.disabled && setActive(enabled.indexOf(option))
352
+ }
353
+ onClick={() => !option.disabled && onPick(option.value)}
354
+ className={cn(
355
+ "text-subheadline flex min-h-8 cursor-default items-center gap-2 rounded-lg px-2 py-1.5 [@media(pointer:coarse)]:min-h-11",
356
+ focused ? "bg-fill" : "",
357
+ option.disabled && "opacity-40",
358
+ chosen ? "text-label font-semibold" : "text-label",
359
+ )}
360
+ >
361
+ <span className="flex w-4 shrink-0 justify-center">
362
+ {chosen && (
363
+ <CheckIcon
364
+ size={15}
365
+ strokeWidth={2.4}
366
+ className="text-accent-text"
367
+ />
368
+ )}
369
+ </span>
370
+ <span className="min-w-0 grow break-words">{option.label}</span>
371
+ {option.detail && (
372
+ <span className="text-caption text-label-tertiary shrink-0 font-normal">
373
+ {option.detail}
374
+ </span>
375
+ )}
376
+ </div>
377
+ );
378
+ })}
379
+ {canCreate && (
380
+ // biome-ignore lint/a11y/useKeyWithClickEvents: create div handles keyboard via role=option
381
+ <div
382
+ id={`${id}-create`}
383
+ role="option"
384
+ aria-selected={false}
385
+ tabIndex={-1}
386
+ onPointerMove={() => setActive(enabled.length)}
387
+ onClick={create}
388
+ className={cn(
389
+ "text-subheadline text-accent-text flex min-h-9 cursor-default items-center rounded-lg px-2 py-1.5",
390
+ !activeOption && "bg-fill",
391
+ )}
392
+ >
393
+ {createLabel?.(trimmed) ?? trimmed}
394
+ </div>
395
+ )}
396
+ {shown.length === 0 && !canCreate && (
397
+ <p className="text-footnote text-label-tertiary px-3 py-2">
398
+ {emptyLabel}
399
+ </p>
400
+ )}
401
+ </div>
402
+ </AnchoredSurface>
403
+ );
378
404
  }