@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.
- package/README.md +38 -0
- package/THIRD_PARTY_NOTICES.md +25 -0
- package/dist/types/src/components/accordion/accordion.d.ts +19 -0
- package/dist/types/src/components/aspect-ratio/aspect-ratio.d.ts +7 -0
- package/dist/types/src/components/breadcrumb/breadcrumb.d.ts +16 -0
- package/dist/types/src/components/button/button.d.ts +2 -0
- package/dist/types/src/components/code-block/code-block.d.ts +10 -0
- package/dist/types/src/components/combo-box/combo-box.d.ts +9 -0
- package/dist/types/src/components/command/command.d.ts +18 -0
- package/dist/types/src/components/content-unavailable-view/content-unavailable-view.d.ts +9 -0
- package/dist/types/src/components/context-menu/context-menu-store.d.ts +2 -2
- package/dist/types/src/components/context-menu/menu-reveal.d.ts +3 -8
- package/dist/types/src/components/dialog/dialog-store.d.ts +3 -1
- package/dist/types/src/components/disclosure-group/disclosure-group.d.ts +3 -1
- package/dist/types/src/components/keyboard-shortcut/keyboard-shortcut.d.ts +5 -0
- package/dist/types/src/components/menu/menu.d.ts +1 -1
- package/dist/types/src/components/one-time-code/one-time-code.d.ts +18 -0
- package/dist/types/src/components/picker/picker.d.ts +7 -1
- package/dist/types/src/components/popover/anchored-surface.d.ts +13 -0
- package/dist/types/src/components/popover/popover.d.ts +5 -1
- package/dist/types/src/components/progress-view/progress-view.d.ts +1 -1
- package/dist/types/src/components/progress-view/use-liquid-progress.d.ts +3 -0
- package/dist/types/src/components/radio-group/radio-group.d.ts +17 -0
- package/dist/types/src/components/search-field/expanding-search-field.d.ts +5 -0
- package/dist/types/src/components/search-field/search-field.d.ts +5 -1
- package/dist/types/src/components/sidebar/sidebar.d.ts +3 -2
- package/dist/types/src/components/skeleton/skeleton.d.ts +6 -0
- package/dist/types/src/components/slosh-slider/liquid-step.d.ts +6 -0
- package/dist/types/src/components/slosh-slider/slosh-slider.d.ts +18 -0
- package/dist/types/src/components/stepper/rolling-digit.d.ts +11 -0
- package/dist/types/src/components/text-field/text-field.d.ts +3 -1
- package/dist/types/src/components/wake-slider/wake-slider.d.ts +12 -0
- package/dist/types/src/hooks/use-anchored-disclosure.d.ts +9 -0
- package/dist/types/src/hooks/use-anchored-placement.d.ts +1 -1
- package/dist/types/src/index.d.ts +13 -0
- package/dist/types/src/lib/anchored-placement.d.ts +2 -2
- package/package.json +60 -4
- package/src/components/accordion/accordion.tsx +48 -0
- package/src/components/aspect-ratio/aspect-ratio.tsx +22 -0
- package/src/components/breadcrumb/breadcrumb.tsx +53 -0
- package/src/components/button/button.tsx +5 -1
- package/src/components/code-block/code-block.tsx +41 -0
- package/src/components/combo-box/combo-box.tsx +13 -0
- package/src/components/command/command.tsx +119 -0
- package/src/components/compact-date-picker/compact-date-picker.tsx +40 -84
- package/src/components/compact-time-picker/compact-time-picker.tsx +44 -81
- package/src/components/content-unavailable-view/content-unavailable-view.tsx +37 -0
- package/src/components/context-menu/context-menu-host.tsx +28 -24
- package/src/components/context-menu/context-menu-store.ts +3 -4
- package/src/components/context-menu/menu-reveal.ts +5 -2
- package/src/components/dialog/dialog-host.tsx +13 -7
- package/src/components/dialog/dialog-store.ts +15 -4
- package/src/components/disclosure-group/disclosure-group.tsx +19 -10
- package/src/components/keyboard-shortcut/keyboard-shortcut.tsx +17 -0
- package/src/components/menu/menu.tsx +1 -1
- package/src/components/one-time-code/one-time-code.tsx +168 -0
- package/src/components/picker/picker.tsx +121 -108
- package/src/components/popover/anchored-surface.tsx +223 -0
- package/src/components/popover/popover.tsx +149 -20
- package/src/components/progress-view/progress-view.tsx +6 -1
- package/src/components/progress-view/use-liquid-progress.ts +43 -0
- package/src/components/radio-group/radio-group.tsx +63 -0
- package/src/components/search-field/expanding-search-field.tsx +183 -0
- package/src/components/search-field/search-field.tsx +41 -25
- package/src/components/search-list/search-list.tsx +16 -7
- package/src/components/sidebar/sidebar.tsx +21 -12
- package/src/components/skeleton/skeleton.tsx +19 -0
- package/src/components/slosh-slider/liquid-step.ts +42 -0
- package/src/components/slosh-slider/slosh-slider.tsx +251 -0
- package/src/components/stepper/rolling-digit.tsx +77 -0
- package/src/components/stepper/stepper.tsx +50 -29
- package/src/components/text-field/text-field.tsx +45 -7
- package/src/components/toggle/toggle.tsx +147 -30
- package/src/components/wake-slider/wake-slider.tsx +237 -0
- package/src/hooks/use-anchored-disclosure.ts +23 -0
- package/src/hooks/use-anchored-placement.ts +7 -4
- package/src/icons/icons.tsx +150 -150
- package/src/index.ts +18 -0
- package/src/lib/anchored-placement.ts +7 -3
- package/src/styles/code-block.css +33 -0
- package/src/styles/motion-controls.css +189 -0
- 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 {
|
|
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 {
|
|
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
|
|
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(
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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={() =>
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
213
|
+
options
|
|
214
|
+
.filter((option) => !option.disabled)
|
|
215
|
+
.findIndex((option) => option.value === value)
|
|
194
216
|
)
|
|
195
217
|
)
|
|
196
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
<
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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={
|
|
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
|
|
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={() =>
|
|
331
|
-
|
|
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
|
-
{
|
|
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
|
-
</
|
|
373
|
+
</AnchoredSurface>
|
|
361
374
|
)
|
|
362
375
|
}
|