@passui/react 0.1.19 → 0.1.20

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 (82) hide show
  1. package/README.md +38 -0
  2. package/THIRD_PARTY_NOTICES.md +25 -0
  3. package/dist/types/src/components/accordion/accordion.d.ts +19 -0
  4. package/dist/types/src/components/aspect-ratio/aspect-ratio.d.ts +7 -0
  5. package/dist/types/src/components/breadcrumb/breadcrumb.d.ts +16 -0
  6. package/dist/types/src/components/button/button.d.ts +2 -0
  7. package/dist/types/src/components/code-block/code-block.d.ts +10 -0
  8. package/dist/types/src/components/combo-box/combo-box.d.ts +9 -0
  9. package/dist/types/src/components/command/command.d.ts +18 -0
  10. package/dist/types/src/components/content-unavailable-view/content-unavailable-view.d.ts +9 -0
  11. package/dist/types/src/components/context-menu/context-menu-store.d.ts +2 -2
  12. package/dist/types/src/components/context-menu/menu-reveal.d.ts +3 -8
  13. package/dist/types/src/components/dialog/dialog-store.d.ts +3 -1
  14. package/dist/types/src/components/disclosure-group/disclosure-group.d.ts +3 -1
  15. package/dist/types/src/components/keyboard-shortcut/keyboard-shortcut.d.ts +5 -0
  16. package/dist/types/src/components/menu/menu.d.ts +1 -1
  17. package/dist/types/src/components/one-time-code/one-time-code.d.ts +18 -0
  18. package/dist/types/src/components/picker/picker.d.ts +7 -1
  19. package/dist/types/src/components/popover/anchored-surface.d.ts +13 -0
  20. package/dist/types/src/components/popover/popover.d.ts +5 -1
  21. package/dist/types/src/components/progress-view/progress-view.d.ts +1 -1
  22. package/dist/types/src/components/progress-view/use-liquid-progress.d.ts +3 -0
  23. package/dist/types/src/components/radio-group/radio-group.d.ts +17 -0
  24. package/dist/types/src/components/search-field/expanding-search-field.d.ts +5 -0
  25. package/dist/types/src/components/search-field/search-field.d.ts +5 -1
  26. package/dist/types/src/components/sidebar/sidebar.d.ts +3 -2
  27. package/dist/types/src/components/skeleton/skeleton.d.ts +6 -0
  28. package/dist/types/src/components/slosh-slider/liquid-step.d.ts +6 -0
  29. package/dist/types/src/components/slosh-slider/slosh-slider.d.ts +18 -0
  30. package/dist/types/src/components/stepper/rolling-digit.d.ts +11 -0
  31. package/dist/types/src/components/text-field/text-field.d.ts +3 -1
  32. package/dist/types/src/components/wake-slider/wake-slider.d.ts +12 -0
  33. package/dist/types/src/hooks/use-anchored-disclosure.d.ts +9 -0
  34. package/dist/types/src/hooks/use-anchored-placement.d.ts +1 -1
  35. package/dist/types/src/index.d.ts +13 -0
  36. package/dist/types/src/lib/anchored-placement.d.ts +2 -2
  37. package/package.json +60 -4
  38. package/src/components/accordion/accordion.tsx +48 -0
  39. package/src/components/aspect-ratio/aspect-ratio.tsx +22 -0
  40. package/src/components/breadcrumb/breadcrumb.tsx +53 -0
  41. package/src/components/button/button.tsx +5 -1
  42. package/src/components/code-block/code-block.tsx +41 -0
  43. package/src/components/combo-box/combo-box.tsx +13 -0
  44. package/src/components/command/command.tsx +119 -0
  45. package/src/components/compact-date-picker/compact-date-picker.tsx +40 -84
  46. package/src/components/compact-time-picker/compact-time-picker.tsx +44 -81
  47. package/src/components/content-unavailable-view/content-unavailable-view.tsx +37 -0
  48. package/src/components/context-menu/context-menu-host.tsx +28 -24
  49. package/src/components/context-menu/context-menu-store.ts +3 -4
  50. package/src/components/context-menu/menu-reveal.ts +5 -2
  51. package/src/components/dialog/dialog-host.tsx +13 -7
  52. package/src/components/dialog/dialog-store.ts +15 -4
  53. package/src/components/disclosure-group/disclosure-group.tsx +19 -10
  54. package/src/components/keyboard-shortcut/keyboard-shortcut.tsx +17 -0
  55. package/src/components/menu/menu.tsx +1 -1
  56. package/src/components/one-time-code/one-time-code.tsx +168 -0
  57. package/src/components/picker/picker.tsx +121 -108
  58. package/src/components/popover/anchored-surface.tsx +223 -0
  59. package/src/components/popover/popover.tsx +149 -20
  60. package/src/components/progress-view/progress-view.tsx +6 -1
  61. package/src/components/progress-view/use-liquid-progress.ts +43 -0
  62. package/src/components/radio-group/radio-group.tsx +63 -0
  63. package/src/components/search-field/expanding-search-field.tsx +183 -0
  64. package/src/components/search-field/search-field.tsx +41 -25
  65. package/src/components/search-list/search-list.tsx +16 -7
  66. package/src/components/sidebar/sidebar.tsx +21 -12
  67. package/src/components/skeleton/skeleton.tsx +19 -0
  68. package/src/components/slosh-slider/liquid-step.ts +42 -0
  69. package/src/components/slosh-slider/slosh-slider.tsx +251 -0
  70. package/src/components/stepper/rolling-digit.tsx +77 -0
  71. package/src/components/stepper/stepper.tsx +50 -29
  72. package/src/components/text-field/text-field.tsx +45 -7
  73. package/src/components/toggle/toggle.tsx +147 -30
  74. package/src/components/wake-slider/wake-slider.tsx +237 -0
  75. package/src/hooks/use-anchored-disclosure.ts +23 -0
  76. package/src/hooks/use-anchored-placement.ts +7 -4
  77. package/src/icons/icons.tsx +150 -150
  78. package/src/index.ts +18 -0
  79. package/src/lib/anchored-placement.ts +7 -3
  80. package/src/styles/code-block.css +33 -0
  81. package/src/styles/motion-controls.css +189 -0
  82. package/src/styles/styles.css +3 -0
@@ -0,0 +1,168 @@
1
+ import { type CSSProperties, type InputHTMLAttributes, useEffect, useId, useRef } from "react"
2
+ import { CheckIcon } from "../../icons/icons"
3
+ import { cn } from "../../lib/class-names"
4
+
5
+ export type OneTimeCodeStatus = "idle" | "verifying" | "success" | "error"
6
+ export interface OneTimeCodeProps
7
+ extends Omit<
8
+ InputHTMLAttributes<HTMLInputElement>,
9
+ "value" | "defaultValue" | "onChange" | "type" | "maxLength" | "size"
10
+ > {
11
+ label: string
12
+ value: string
13
+ onChange: (value: string) => void
14
+ /** Called when a complete code is entered, including paste and autofill. */
15
+ onComplete?: (value: string) => void
16
+ /** Number of digits, 3–8. @default 6 */
17
+ length?: number
18
+ status?: OneTimeCodeStatus
19
+ /** Localized verification feedback announced without reading decorative cells. */
20
+ statusLabel?: string
21
+ }
22
+
23
+ /** Normalize common numeric keyboards without splitting the browser's input into cells. */
24
+ export function normalizeCode(value: string, length: number) {
25
+ return Array.from(value, (digit) => {
26
+ const point = digit.codePointAt(0) ?? 0
27
+ for (const base of [0x30, 0x660, 0x6f0, 0xff10]) {
28
+ if (point >= base && point < base + 10) return String(point - base)
29
+ }
30
+ return ""
31
+ })
32
+ .join("")
33
+ .slice(0, length)
34
+ }
35
+
36
+ /** Six cells, one field: paste, autofill, selection and Backspace stay native. */
37
+ export function OneTimeCode({
38
+ label,
39
+ value,
40
+ onChange,
41
+ onComplete,
42
+ length = 6,
43
+ status = "idle",
44
+ statusLabel,
45
+ className,
46
+ disabled,
47
+ id: suppliedId,
48
+ ...props
49
+ }: OneTimeCodeProps) {
50
+ const generated = useId()
51
+ const id = suppliedId ?? generated
52
+ const filter = `otp-goo-${generated.replace(/:/g, "")}`
53
+ const input = useRef<HTMLInputElement>(null)
54
+ const count = Number.isFinite(length) ? Math.max(3, Math.min(8, Math.round(length))) : 6
55
+ const code = normalizeCode(value, count)
56
+ const locked = disabled || props.readOnly || status === "verifying" || status === "success"
57
+ useEffect(() => {
58
+ if (status === "error") input.current?.select()
59
+ }, [status])
60
+ const update = (next: string) => {
61
+ const normalized = normalizeCode(next, count)
62
+ onChange(normalized)
63
+ if (normalized.length === count && normalized !== code) onComplete?.(normalized)
64
+ }
65
+ return (
66
+ <div
67
+ className={cn("passui-one-time-code not-typeset", className)}
68
+ data-status={status}
69
+ style={{ "--otp-length": count, "--otp-middle": (count - 1) / 2 } as CSSProperties}
70
+ >
71
+ <svg aria-hidden="true" width="0" height="0" className="absolute">
72
+ <defs>
73
+ <filter
74
+ id={filter}
75
+ x="-20%"
76
+ y="-50%"
77
+ width="140%"
78
+ height="200%"
79
+ colorInterpolationFilters="sRGB"
80
+ >
81
+ <feGaussianBlur stdDeviation="3" result="smear" />
82
+ <feColorMatrix
83
+ in="smear"
84
+ type="matrix"
85
+ values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 24 -10"
86
+ />
87
+ </filter>
88
+ </defs>
89
+ </svg>
90
+ <div className="passui-otp-row" dir="ltr">
91
+ {/* A right code fuses: cells converge on one point while a capsule
92
+ grows there. The filter joins the material, never the digits. */}
93
+ <div
94
+ aria-hidden="true"
95
+ className="passui-otp-materials"
96
+ style={{ filter: `url(#${filter})` }}
97
+ >
98
+ {Array.from({ length: count }, (_, index) => (
99
+ <span
100
+ key={index}
101
+ className="passui-otp-cell"
102
+ style={{ "--otp-index": index } as CSSProperties}
103
+ />
104
+ ))}
105
+ <span className="passui-otp-capsule" />
106
+ </div>
107
+ <div aria-hidden="true" className="passui-otp-digits">
108
+ {Array.from({ length: count }, (_, index) => (
109
+ <span
110
+ key={index}
111
+ className="passui-otp-digit"
112
+ data-active={index === Math.min(code.length, count - 1) || undefined}
113
+ style={{ "--otp-index": index } as CSSProperties}
114
+ >
115
+ {code[index] && (
116
+ <span key={`${index}-${code[index]}`} className="passui-otp-glyph">
117
+ {code[index]}
118
+ </span>
119
+ )}
120
+ </span>
121
+ ))}
122
+ </div>
123
+ <CheckIcon aria-hidden="true" className="passui-otp-check" size={24} />
124
+ <input
125
+ {...props}
126
+ ref={input}
127
+ id={id}
128
+ type="text"
129
+ inputMode="numeric"
130
+ autoComplete="one-time-code"
131
+ pattern="[0-9]*"
132
+ maxLength={count}
133
+ aria-label={label}
134
+ value={code}
135
+ disabled={disabled}
136
+ readOnly={locked}
137
+ aria-busy={status === "verifying" || undefined}
138
+ aria-invalid={status === "error" || props["aria-invalid"]}
139
+ aria-describedby={statusLabel ? `${id}-status` : props["aria-describedby"]}
140
+ onChange={(event) => update(event.target.value)}
141
+ onPaste={(event) => {
142
+ props.onPaste?.(event)
143
+ if (event.defaultPrevented || locked) return
144
+ const pasted = event.clipboardData.getData("text")
145
+ const normalized = normalizeCode(pasted, count)
146
+ if (normalized && normalized !== pasted) {
147
+ event.preventDefault()
148
+ update(normalized)
149
+ }
150
+ }}
151
+ className="passui-otp-input"
152
+ />
153
+ </div>
154
+ {statusLabel && (
155
+ <p
156
+ id={`${id}-status`}
157
+ role="status"
158
+ className={cn(
159
+ "mt-2 text-footnote",
160
+ status === "error" ? "text-danger" : "text-label-secondary"
161
+ )}
162
+ >
163
+ {statusLabel}
164
+ </p>
165
+ )}
166
+ </div>
167
+ )
168
+ }
@@ -1,29 +1,18 @@
1
- import { AnimatePresence, motion, useReducedMotion } from "motion/react"
2
- import {
3
- type KeyboardEvent,
4
- useCallback,
5
- useEffect,
6
- useId,
7
- useLayoutEffect,
8
- useMemo,
9
- useRef,
10
- useState,
11
- } from "react"
1
+ import { useAnchoredDisclosure } from "../../hooks/use-anchored-disclosure"
2
+ import { type KeyboardEvent, useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
12
3
  import { createPortal } from "react-dom"
13
- import { useAnchoredPlacement } from "../../hooks/use-anchored-placement"
14
- import { useFloatingDismiss } from "../../hooks/use-floating-dismiss"
15
- import { FLOATING_LAYER } from "../../hooks/use-dismiss"
16
4
  import { CheckIcon, SearchIcon, SelectorIcon } from "../../icons/icons"
17
5
  import { cn } from "../../lib/class-names"
18
- import { registerFloatingLayer } from "../../lib/floating-layers"
19
6
  import { haptic } from "../../lib/haptics"
20
- import { floatingPanelMotion } from "../../motion/transitions"
7
+ import { AnchoredSurface } from "../popover/anchored-surface"
21
8
  import { pickerTriggerStyle } from "./picker-trigger"
22
9
 
23
10
  export interface PickerOption<T extends string> {
24
11
  value: T
25
12
  label: string
26
13
  /** A quieter second line or trailing note, such as "Free" or a region. */
14
+ disabled?: boolean
15
+ keywords?: string[]
27
16
  detail?: string
28
17
  /** The option's own language, so it is spoken and shaped right. */
29
18
  lang?: string
@@ -47,6 +36,10 @@ export interface PickerProps<T extends string> {
47
36
  searchPlaceholder?: string
48
37
  /** Shown when the filter matches nothing. @default "No matches" */
49
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
50
43
  className?: string
51
44
  /** Prevents opening or choosing an option. @default false */
52
45
  disabled?: boolean
@@ -71,22 +64,28 @@ export function Picker<T extends string>({
71
64
  searchPlaceholder = "Search",
72
65
  emptyLabel = "No matches",
73
66
  className,
67
+ onCreate,
68
+ createLabel,
74
69
  disabled = false,
75
70
  }: PickerProps<T>) {
76
- const [open, setOpen] = useState(false)
71
+ const panel = useAnchoredDisclosure(disabled)
72
+ const { open } = panel
77
73
  const trigger = useRef<HTMLButtonElement>(null)
78
74
  const listId = useId()
79
75
  const selected = options.find((option) => option.value === value)
80
76
 
81
- const close = useCallback((refocus: boolean) => {
82
- setOpen(false)
83
- if (refocus) trigger.current?.focus({ preventScroll: true })
84
- }, [])
77
+ const close = useCallback(
78
+ (refocus: boolean) => {
79
+ panel.close()
80
+ if (refocus) trigger.current?.focus({ preventScroll: true })
81
+ },
82
+ [panel.close]
83
+ )
85
84
 
86
85
  const onTriggerKey = (event: KeyboardEvent<HTMLButtonElement>) => {
87
86
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
88
87
  event.preventDefault()
89
- setOpen(true)
88
+ panel.show()
90
89
  }
91
90
  }
92
91
 
@@ -101,7 +100,7 @@ export function Picker<T extends string>({
101
100
  aria-expanded={open && !disabled}
102
101
  aria-controls={open && !disabled ? listId : undefined}
103
102
  disabled={disabled}
104
- onClick={() => setOpen((current) => !current)}
103
+ onClick={() => panel.toggle()}
105
104
  onKeyDown={onTriggerKey}
106
105
  className={pickerTriggerStyle(className)}
107
106
  >
@@ -115,27 +114,31 @@ export function Picker<T extends string>({
115
114
  </button>
116
115
  {typeof document !== "undefined" &&
117
116
  createPortal(
118
- <AnimatePresence>
119
- {open && !disabled && trigger.current && (
120
- <SelectList
121
- id={listId}
122
- anchor={trigger.current}
123
- options={options}
124
- value={value}
125
- label={label}
126
- searchable={searchable ?? options.length > SEARCH_FROM}
127
- searchPlaceholder={searchPlaceholder}
128
- emptyLabel={emptyLabel}
129
- onPick={(next) => {
130
- close(true)
131
- if (next === value) return
132
- haptic("selection")
133
- onChange(next)
134
- }}
135
- onClose={close}
136
- />
137
- )}
138
- </AnimatePresence>,
117
+ panel.mounted && trigger.current ? (
118
+ <SelectList
119
+ open={open}
120
+ onExited={panel.onExited}
121
+ id={listId}
122
+ anchor={trigger.current}
123
+ options={options}
124
+ value={value}
125
+ label={label}
126
+ searchable={
127
+ searchable ?? (Boolean(onCreate) || options.length >= SEARCH_FROM)
128
+ }
129
+ searchPlaceholder={searchPlaceholder}
130
+ emptyLabel={emptyLabel}
131
+ onCreate={onCreate}
132
+ createLabel={createLabel}
133
+ onPick={(next) => {
134
+ close(true)
135
+ if (next === value) return
136
+ haptic("selection")
137
+ onChange(next)
138
+ }}
139
+ onClose={close}
140
+ />
141
+ ) : null,
139
142
  document.body
140
143
  )}
141
144
  </>
@@ -143,6 +146,8 @@ export function Picker<T extends string>({
143
146
  }
144
147
 
145
148
  function SelectList<T extends string>({
149
+ open,
150
+ onExited,
146
151
  id,
147
152
  anchor,
148
153
  options,
@@ -153,7 +158,11 @@ function SelectList<T extends string>({
153
158
  emptyLabel,
154
159
  onPick,
155
160
  onClose,
161
+ onCreate,
162
+ createLabel,
156
163
  }: {
164
+ open: boolean
165
+ onExited: () => void
157
166
  id: string
158
167
  anchor: HTMLElement
159
168
  options: ReadonlyArray<PickerOption<T>>
@@ -162,18 +171,13 @@ function SelectList<T extends string>({
162
171
  searchable: boolean
163
172
  searchPlaceholder: string
164
173
  emptyLabel: string
174
+ onCreate?: (query: string) => void
175
+ createLabel?: (query: string) => string
165
176
  onPick: (value: T) => void
166
177
  onClose: (refocus: boolean) => void
167
178
  }) {
168
- const root = useRef<HTMLDivElement>(null)
169
- const reducedMotion = useReducedMotion()
170
179
  const list = useRef<HTMLDivElement>(null)
171
180
  const search = useRef<HTMLInputElement>(null)
172
- const placement = useAnchoredPlacement(
173
- anchor,
174
- Math.min(Math.max(anchor.getBoundingClientRect().width, 220), 360),
175
- 240
176
- )
177
181
  const [query, setQuery] = useState("")
178
182
  const typed = useRef({ text: "", at: 0 })
179
183
 
@@ -181,27 +185,48 @@ function SelectList<T extends string>({
181
185
  const needle = query.trim().toLocaleLowerCase()
182
186
  if (!needle) return options
183
187
  return options.filter((option) =>
184
- `${option.label} ${option.detail ?? ""} ${option.value}`
188
+ `${option.label} ${option.detail ?? ""} ${option.value} ${(option.keywords ?? []).join(" ")}`
185
189
  .toLocaleLowerCase()
186
190
  .includes(needle)
187
191
  )
188
192
  }, [options, query])
189
193
 
194
+ const enabled = shown.filter((option) => !option.disabled)
195
+ const trimmed = query.trim()
196
+ const canCreate = Boolean(
197
+ onCreate &&
198
+ trimmed &&
199
+ !options.some(
200
+ (option) =>
201
+ option.label.toLocaleLowerCase() === trimmed.toLocaleLowerCase() ||
202
+ option.value.toLocaleLowerCase() === trimmed.toLocaleLowerCase()
203
+ )
204
+ )
205
+ const create = () => {
206
+ if (!canCreate) return
207
+ onClose(true)
208
+ onCreate?.(trimmed)
209
+ }
190
210
  const [active, setActive] = useState(() =>
191
211
  Math.max(
192
212
  0,
193
- options.findIndex((option) => option.value === value)
213
+ options
214
+ .filter((option) => !option.disabled)
215
+ .findIndex((option) => option.value === value)
194
216
  )
195
217
  )
196
- const activeOption = shown[Math.min(active, shown.length - 1)]
218
+ const last = enabled.length + (canCreate ? 1 : 0) - 1
219
+ const activeOption = enabled[Math.min(active, last)]
220
+ const activeId = activeOption
221
+ ? `${id}-${activeOption.value}`
222
+ : canCreate
223
+ ? `${id}-create`
224
+ : undefined
197
225
 
198
- useLayoutEffect(() => {
199
- if (!root.current) return
200
- const unregister = registerFloatingLayer(root.current, anchor)
226
+ useEffect(() => {
201
227
  if (searchable) search.current?.focus()
202
228
  else list.current?.focus()
203
- return unregister
204
- }, [searchable, anchor])
229
+ }, [searchable])
205
230
 
206
231
  // The chosen option starts in view, then follows the keyboard.
207
232
  useEffect(() => {
@@ -211,10 +236,7 @@ function SelectList<T extends string>({
211
236
  ?.scrollIntoView({ block: "nearest" })
212
237
  }, [activeOption])
213
238
 
214
- useFloatingDismiss(root, anchor, onClose)
215
-
216
239
  const onKeyDown = (event: KeyboardEvent) => {
217
- const last = shown.length - 1
218
240
  const step = (to: number) => {
219
241
  event.preventDefault()
220
242
  setActive(Math.max(0, Math.min(last, to)))
@@ -229,6 +251,7 @@ function SelectList<T extends string>({
229
251
  else if (event.key === "Enter" || (event.key === " " && !searchable)) {
230
252
  event.preventDefault()
231
253
  if (activeOption) onPick(activeOption.value)
254
+ else if (canCreate) create()
232
255
  } else if (event.key === "Escape") {
233
256
  event.preventDefault()
234
257
  event.stopPropagation()
@@ -240,7 +263,7 @@ function SelectList<T extends string>({
240
263
  const now = Date.now()
241
264
  const text = (now - typed.current.at < 700 ? typed.current.text : "") + event.key
242
265
  typed.current = { text, at: now }
243
- const match = shown.findIndex((option) =>
266
+ const match = enabled.findIndex((option) =>
244
267
  option.label.toLocaleLowerCase().startsWith(text.toLocaleLowerCase())
245
268
  )
246
269
  if (match >= 0) setActive(match)
@@ -248,43 +271,15 @@ function SelectList<T extends string>({
248
271
  }
249
272
 
250
273
  return (
251
- <motion.div
252
- ref={root}
253
- initial={
254
- reducedMotion
255
- ? { opacity: 0 }
256
- : {
257
- opacity: 0,
258
- scale: 1,
259
- y: placement.above ? 6 : -6,
260
- }
261
- }
262
- animate={{ opacity: 1, scale: 1, y: 0 }}
263
- exit={
264
- reducedMotion
265
- ? { opacity: 0 }
266
- : {
267
- opacity: 0,
268
- scale: 1,
269
- y: placement.above ? 4 : -4,
270
- }
271
- }
272
- transition={
273
- reducedMotion
274
- ? { duration: 0 }
275
- : floatingPanelMotion(placement.above ? "above" : "below").transition
276
- }
277
- style={{
278
- left: placement.left,
279
- width: placement.width,
280
- top: placement.top,
281
- bottom: placement.bottom,
282
- maxHeight: Math.min(placement.maxHeight ?? 420, 420),
283
- transformOrigin: placement.above ? "50% 100%" : "50% 0%",
284
- }}
285
- className="glass-menu fixed z-[80] flex flex-col overflow-hidden rounded-3xl"
286
- {...{ [FLOATING_LAYER]: "" }}
274
+ <AnchoredSurface
275
+ open={open}
276
+ onExited={onExited}
277
+ anchor={anchor}
278
+ width={Math.min(Math.max(anchor.getBoundingClientRect().width, 220), 360)}
279
+ needed={240}
280
+ onClose={onClose}
287
281
  onKeyDown={onKeyDown}
282
+ contentClassName="overflow-hidden"
288
283
  >
289
284
  {searchable && (
290
285
  <div className="flex shrink-0 items-center gap-2 border-b border-separator/70 px-3 py-2">
@@ -295,9 +290,7 @@ function SelectList<T extends string>({
295
290
  placeholder={searchPlaceholder}
296
291
  aria-label={searchPlaceholder}
297
292
  aria-controls={id}
298
- aria-activedescendant={
299
- activeOption ? `${id}-${activeOption.value}` : undefined
300
- }
293
+ aria-activedescendant={activeId}
301
294
  onChange={(event) => {
302
295
  setQuery(event.target.value)
303
296
  setActive(0)
@@ -312,10 +305,10 @@ function SelectList<T extends string>({
312
305
  role="listbox"
313
306
  aria-label={label}
314
307
  tabIndex={-1}
315
- aria-activedescendant={activeOption ? `${id}-${activeOption.value}` : undefined}
308
+ aria-activedescendant={activeId}
316
309
  className="no-scrollbar flex min-h-0 flex-col overflow-y-auto overscroll-contain p-1.5 outline-none"
317
310
  >
318
- {shown.map((option, index) => {
311
+ {shown.map((option) => {
319
312
  const chosen = option.value === value
320
313
  const focused = option === activeOption
321
314
  return (
@@ -326,12 +319,16 @@ function SelectList<T extends string>({
326
319
  role="option"
327
320
  tabIndex={-1}
328
321
  aria-selected={chosen}
322
+ aria-disabled={option.disabled || undefined}
329
323
  lang={option.lang}
330
- onPointerMove={() => setActive(index)}
331
- onClick={() => onPick(option.value)}
324
+ onPointerMove={() =>
325
+ !option.disabled && setActive(enabled.indexOf(option))
326
+ }
327
+ onClick={() => !option.disabled && onPick(option.value)}
332
328
  className={cn(
333
329
  "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",
334
330
  focused ? "bg-fill" : "",
331
+ option.disabled && "opacity-40",
335
332
  chosen ? "font-semibold text-label" : "text-label"
336
333
  )}
337
334
  >
@@ -353,10 +350,26 @@ function SelectList<T extends string>({
353
350
  </div>
354
351
  )
355
352
  })}
356
- {shown.length === 0 && (
353
+ {canCreate && (
354
+ <div
355
+ id={`${id}-create`}
356
+ role="option"
357
+ aria-selected={false}
358
+ tabIndex={-1}
359
+ onPointerMove={() => setActive(enabled.length)}
360
+ onClick={create}
361
+ className={cn(
362
+ "flex min-h-9 cursor-default items-center rounded-lg px-2 py-1.5 text-subheadline text-accent-text",
363
+ !activeOption && "bg-fill"
364
+ )}
365
+ >
366
+ {createLabel?.(trimmed) ?? trimmed}
367
+ </div>
368
+ )}
369
+ {shown.length === 0 && !canCreate && (
357
370
  <p className="px-3 py-2 text-footnote text-label-tertiary">{emptyLabel}</p>
358
371
  )}
359
372
  </div>
360
- </motion.div>
373
+ </AnchoredSurface>
361
374
  )
362
375
  }