@a4ui/core 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/elements.css +299 -0
- package/dist/full.css +299 -0
- package/dist/index.d.ts +27 -1
- package/dist/index.js +9140 -5065
- package/dist/ui/ActivityFeed.d.ts +49 -0
- package/dist/ui/AudioWaveform.d.ts +22 -0
- package/dist/ui/AvailabilityPicker.d.ts +35 -0
- package/dist/ui/CodeEditor.d.ts +25 -0
- package/dist/ui/CouponField.d.ts +33 -0
- package/dist/ui/EmojiPicker.d.ts +18 -0
- package/dist/ui/EventScheduler.d.ts +41 -0
- package/dist/ui/FollowingPointer.d.ts +24 -0
- package/dist/ui/GanttChart.d.ts +37 -0
- package/dist/ui/InteractiveMap.d.ts +50 -0
- package/dist/ui/JsonViewer.d.ts +24 -0
- package/dist/ui/Kanban.d.ts +49 -0
- package/dist/ui/Lamp.d.ts +22 -0
- package/dist/ui/Lightbox.d.ts +41 -0
- package/dist/ui/LocationPicker.d.ts +25 -0
- package/dist/ui/MaskedInput.d.ts +26 -0
- package/dist/ui/OnboardingChecklist.d.ts +51 -0
- package/dist/ui/OtpInput.d.ts +29 -0
- package/dist/ui/PivotTable.d.ts +37 -0
- package/dist/ui/PresenceAvatars.d.ts +54 -0
- package/dist/ui/QueryBuilder.d.ts +56 -0
- package/dist/ui/SheetSnap.d.ts +28 -0
- package/dist/ui/SignaturePad.d.ts +19 -0
- package/dist/ui/SpreadsheetGrid.d.ts +27 -0
- package/dist/ui/TreeTable.d.ts +52 -0
- package/dist/ui/VideoPlayerShell.d.ts +19 -0
- package/package.json +1 -1
- package/src/index.ts +60 -1
- package/src/ui/ActivityFeed.tsx +178 -0
- package/src/ui/AudioWaveform.tsx +190 -0
- package/src/ui/AvailabilityPicker.tsx +163 -0
- package/src/ui/CodeEditor.tsx +277 -0
- package/src/ui/CouponField.tsx +120 -0
- package/src/ui/EmojiPicker.tsx +424 -0
- package/src/ui/EventScheduler.tsx +280 -0
- package/src/ui/FollowingPointer.tsx +112 -0
- package/src/ui/GanttChart.tsx +283 -0
- package/src/ui/InteractiveMap.tsx +349 -0
- package/src/ui/JsonViewer.tsx +189 -0
- package/src/ui/Kanban.tsx +250 -0
- package/src/ui/Lamp.tsx +88 -0
- package/src/ui/Lightbox.tsx +261 -0
- package/src/ui/LocationPicker.tsx +96 -0
- package/src/ui/MaskedInput.tsx +108 -0
- package/src/ui/OnboardingChecklist.tsx +163 -0
- package/src/ui/OtpInput.tsx +153 -0
- package/src/ui/PivotTable.tsx +0 -0
- package/src/ui/PresenceAvatars.tsx +142 -0
- package/src/ui/QueryBuilder.tsx +280 -0
- package/src/ui/SheetSnap.tsx +264 -0
- package/src/ui/SignaturePad.tsx +221 -0
- package/src/ui/SpreadsheetGrid.tsx +304 -0
- package/src/ui/TreeTable.tsx +172 -0
- package/src/ui/VideoPlayerShell.tsx +282 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Nested AND/OR rule builder — renders a QueryGroup tree recursively.
|
|
2
|
+
import { Plus, Trash2 } from 'lucide-solid'
|
|
3
|
+
import type { JSX } from 'solid-js'
|
|
4
|
+
import { createMemo, createSignal, For, Show } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
import { Button } from './Button'
|
|
8
|
+
import { Input } from './Input'
|
|
9
|
+
import { Select } from './Select'
|
|
10
|
+
|
|
11
|
+
/** A field the query builder can filter on. */
|
|
12
|
+
export interface QueryField {
|
|
13
|
+
/** Value stored in a rule's `field`; must be unique across `fields`. */
|
|
14
|
+
name: string
|
|
15
|
+
/** Label shown in the field `<Select>`. */
|
|
16
|
+
label: string
|
|
17
|
+
/** Determines which operators are offered and how the value is entered. Defaults to `'text'`. */
|
|
18
|
+
type?: 'text' | 'number' | 'select'
|
|
19
|
+
/** Options for the value `<Select>` when `type` is `'select'`. */
|
|
20
|
+
options?: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A single leaf condition: `field operator value`. */
|
|
24
|
+
export type QueryRule = { field: string; operator: string; value: string }
|
|
25
|
+
|
|
26
|
+
/** A group of rules and/or nested groups, combined with `and`/`or`. */
|
|
27
|
+
export type QueryGroup = { combinator: 'and' | 'or'; rules: (QueryRule | QueryGroup)[] }
|
|
28
|
+
|
|
29
|
+
export interface QueryBuilderProps {
|
|
30
|
+
/** Fields available to filter on; drives the per-field operator and value-input set. */
|
|
31
|
+
fields: QueryField[]
|
|
32
|
+
/** Controlled tree. Omit to manage state internally, seeded with one empty rule. */
|
|
33
|
+
value?: QueryGroup
|
|
34
|
+
/** Called with the full, updated tree on every edit. */
|
|
35
|
+
onChange?: (group: QueryGroup) => void
|
|
36
|
+
class?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const OPERATORS: Record<NonNullable<QueryField['type']>, string[]> = {
|
|
40
|
+
text: ['is', 'contains', 'starts with'],
|
|
41
|
+
number: ['=', '>', '<', 'between'],
|
|
42
|
+
select: ['is', 'is not'],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isGroup(node: QueryRule | QueryGroup): node is QueryGroup {
|
|
46
|
+
return 'combinator' in node
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function emptyRule(fields: QueryField[]): QueryRule {
|
|
50
|
+
const field = fields[0]
|
|
51
|
+
const type = field?.type ?? 'text'
|
|
52
|
+
return { field: field?.name ?? '', operator: OPERATORS[type][0], value: '' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function emptyGroup(fields: QueryField[]): QueryGroup {
|
|
56
|
+
return { combinator: 'and', rules: [emptyRule(fields)] }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Nested AND/OR rule builder: each group has a combinator toggle plus "+ Rule"
|
|
61
|
+
* / "+ Group" buttons, and rules/groups can be removed individually. Groups
|
|
62
|
+
* nest recursively, indented with a left border. Controlled via `value` +
|
|
63
|
+
* `onChange`, or uncontrolled (seeded with one empty rule) when `value` is
|
|
64
|
+
* omitted.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```tsx
|
|
68
|
+
* const [query, setQuery] = createSignal<QueryGroup>({
|
|
69
|
+
* combinator: 'and',
|
|
70
|
+
* rules: [{ field: 'status', operator: 'is', value: 'open' }],
|
|
71
|
+
* })
|
|
72
|
+
* <QueryBuilder
|
|
73
|
+
* fields={[
|
|
74
|
+
* { name: 'status', label: 'Status', type: 'select', options: ['open', 'closed'] },
|
|
75
|
+
* { name: 'age', label: 'Age', type: 'number' },
|
|
76
|
+
* ]}
|
|
77
|
+
* value={query()}
|
|
78
|
+
* onChange={setQuery}
|
|
79
|
+
* />
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export function QueryBuilder(props: QueryBuilderProps): JSX.Element {
|
|
83
|
+
const [internal, setInternal] = createSignal<QueryGroup>(props.value ?? emptyGroup(props.fields))
|
|
84
|
+
|
|
85
|
+
const group = createMemo(() => props.value ?? internal())
|
|
86
|
+
|
|
87
|
+
const emit = (next: QueryGroup): void => {
|
|
88
|
+
if (props.value === undefined) setInternal(next)
|
|
89
|
+
props.onChange?.(next)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const fieldByName = (name: string): QueryField | undefined => props.fields.find((f) => f.name === name)
|
|
93
|
+
|
|
94
|
+
const operatorsFor = (fieldName: string): string[] => OPERATORS[fieldByName(fieldName)?.type ?? 'text']
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<GroupView
|
|
98
|
+
group={group()}
|
|
99
|
+
fields={props.fields}
|
|
100
|
+
operatorsFor={operatorsFor}
|
|
101
|
+
fieldByName={fieldByName}
|
|
102
|
+
onChange={emit}
|
|
103
|
+
depth={0}
|
|
104
|
+
onRemove={undefined}
|
|
105
|
+
/>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface GroupViewProps {
|
|
110
|
+
group: QueryGroup
|
|
111
|
+
fields: QueryField[]
|
|
112
|
+
operatorsFor: (fieldName: string) => string[]
|
|
113
|
+
fieldByName: (name: string) => QueryField | undefined
|
|
114
|
+
onChange: (next: QueryGroup) => void
|
|
115
|
+
depth: number
|
|
116
|
+
/** Absent for the root group, which cannot remove itself. */
|
|
117
|
+
onRemove: (() => void) | undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function GroupView(props: GroupViewProps): JSX.Element {
|
|
121
|
+
const updateNode = (index: number, next: QueryRule | QueryGroup): void => {
|
|
122
|
+
const rules = props.group.rules.slice()
|
|
123
|
+
rules[index] = next
|
|
124
|
+
props.onChange({ ...props.group, rules })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const removeNode = (index: number): void => {
|
|
128
|
+
const rules = props.group.rules.slice()
|
|
129
|
+
rules.splice(index, 1)
|
|
130
|
+
props.onChange({ ...props.group, rules })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const addRule = (): void => {
|
|
134
|
+
props.onChange({ ...props.group, rules: [...props.group.rules, emptyRule(props.fields)] })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const addGroup = (): void => {
|
|
138
|
+
props.onChange({ ...props.group, rules: [...props.group.rules, emptyGroup(props.fields)] })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const setCombinator = (combinator: 'and' | 'or'): void => {
|
|
142
|
+
props.onChange({ ...props.group, combinator })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div class={cn('flex flex-col gap-2 rounded-md p-2', props.depth > 0 && 'border-l-2 border-border pl-3')}>
|
|
147
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
148
|
+
<div
|
|
149
|
+
class="inline-flex overflow-hidden rounded-md border border-input"
|
|
150
|
+
role="group"
|
|
151
|
+
aria-label="Combinator"
|
|
152
|
+
>
|
|
153
|
+
<For each={['and', 'or'] as const}>
|
|
154
|
+
{(c) => (
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
class={cn(
|
|
158
|
+
'px-2 py-1 text-xs font-semibold uppercase transition-colors',
|
|
159
|
+
props.group.combinator === c
|
|
160
|
+
? 'bg-primary text-primary-foreground'
|
|
161
|
+
: 'bg-background text-muted-foreground hover:bg-muted',
|
|
162
|
+
)}
|
|
163
|
+
aria-pressed={props.group.combinator === c}
|
|
164
|
+
onClick={() => setCombinator(c)}
|
|
165
|
+
>
|
|
166
|
+
{c}
|
|
167
|
+
</button>
|
|
168
|
+
)}
|
|
169
|
+
</For>
|
|
170
|
+
</div>
|
|
171
|
+
<Button variant="outline" onClick={addRule}>
|
|
172
|
+
<Plus class="mr-1 h-3.5 w-3.5" aria-hidden="true" />
|
|
173
|
+
Rule
|
|
174
|
+
</Button>
|
|
175
|
+
<Button variant="outline" onClick={addGroup}>
|
|
176
|
+
<Plus class="mr-1 h-3.5 w-3.5" aria-hidden="true" />
|
|
177
|
+
Group
|
|
178
|
+
</Button>
|
|
179
|
+
<Show when={props.onRemove}>
|
|
180
|
+
{(remove) => (
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
class="ml-auto rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
184
|
+
aria-label="Remove group"
|
|
185
|
+
onClick={() => remove()()}
|
|
186
|
+
>
|
|
187
|
+
<Trash2 class="h-3.5 w-3.5" aria-hidden="true" />
|
|
188
|
+
</button>
|
|
189
|
+
)}
|
|
190
|
+
</Show>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<For each={props.group.rules}>
|
|
194
|
+
{(node, index) => (
|
|
195
|
+
<Show
|
|
196
|
+
when={isGroup(node)}
|
|
197
|
+
fallback={
|
|
198
|
+
<RuleRow
|
|
199
|
+
rule={node as QueryRule}
|
|
200
|
+
fields={props.fields}
|
|
201
|
+
operatorsFor={props.operatorsFor}
|
|
202
|
+
fieldByName={props.fieldByName}
|
|
203
|
+
onChange={(next) => updateNode(index(), next)}
|
|
204
|
+
onRemove={() => removeNode(index())}
|
|
205
|
+
/>
|
|
206
|
+
}
|
|
207
|
+
>
|
|
208
|
+
<GroupView
|
|
209
|
+
group={node as QueryGroup}
|
|
210
|
+
fields={props.fields}
|
|
211
|
+
operatorsFor={props.operatorsFor}
|
|
212
|
+
fieldByName={props.fieldByName}
|
|
213
|
+
onChange={(next) => updateNode(index(), next)}
|
|
214
|
+
depth={props.depth + 1}
|
|
215
|
+
onRemove={() => removeNode(index())}
|
|
216
|
+
/>
|
|
217
|
+
</Show>
|
|
218
|
+
)}
|
|
219
|
+
</For>
|
|
220
|
+
</div>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
interface RuleRowProps {
|
|
225
|
+
rule: QueryRule
|
|
226
|
+
fields: QueryField[]
|
|
227
|
+
operatorsFor: (fieldName: string) => string[]
|
|
228
|
+
fieldByName: (name: string) => QueryField | undefined
|
|
229
|
+
onChange: (next: QueryRule) => void
|
|
230
|
+
onRemove: () => void
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function RuleRow(props: RuleRowProps): JSX.Element {
|
|
234
|
+
const field = createMemo(() => props.fieldByName(props.rule.field))
|
|
235
|
+
|
|
236
|
+
const setField = (name: string): void => {
|
|
237
|
+
const operators = props.operatorsFor(name)
|
|
238
|
+
props.onChange({ field: name, operator: operators[0], value: '' })
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const setOperator = (operator: string): void => props.onChange({ ...props.rule, operator })
|
|
242
|
+
const setValue = (value: string): void => props.onChange({ ...props.rule, value })
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
246
|
+
<Select class="w-40" value={props.rule.field} onChange={setField}>
|
|
247
|
+
<For each={props.fields}>{(f) => <option value={f.name}>{f.label}</option>}</For>
|
|
248
|
+
</Select>
|
|
249
|
+
<Select class="w-32" value={props.rule.operator} onChange={setOperator}>
|
|
250
|
+
<For each={props.operatorsFor(props.rule.field)}>{(op) => <option value={op}>{op}</option>}</For>
|
|
251
|
+
</Select>
|
|
252
|
+
<Show
|
|
253
|
+
when={field()?.type === 'select'}
|
|
254
|
+
fallback={
|
|
255
|
+
<Input
|
|
256
|
+
class="w-40"
|
|
257
|
+
type={field()?.type === 'number' ? 'number' : 'text'}
|
|
258
|
+
value={props.rule.value}
|
|
259
|
+
onInput={setValue}
|
|
260
|
+
/>
|
|
261
|
+
}
|
|
262
|
+
>
|
|
263
|
+
<Select class="w-40" value={props.rule.value} onChange={setValue}>
|
|
264
|
+
<option value="" disabled>
|
|
265
|
+
Select…
|
|
266
|
+
</option>
|
|
267
|
+
<For each={field()?.options ?? []}>{(opt) => <option value={opt}>{opt}</option>}</For>
|
|
268
|
+
</Select>
|
|
269
|
+
</Show>
|
|
270
|
+
<button
|
|
271
|
+
type="button"
|
|
272
|
+
class="rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
273
|
+
aria-label="Remove rule"
|
|
274
|
+
onClick={props.onRemove}
|
|
275
|
+
>
|
|
276
|
+
<Trash2 class="h-3.5 w-3.5" aria-hidden="true" />
|
|
277
|
+
</button>
|
|
278
|
+
</div>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Bottom sheet with drag-to-resize snap points (mobile filter/detail panels,
|
|
2
|
+
// map bottom sheets). Kobalte's Dialog (used by Drawer/Modal) has no notion of
|
|
3
|
+
// intermediate heights, so this rolls its own portal + backdrop + focus/Escape/
|
|
4
|
+
// body-scroll-lock (mirroring Drawer.tsx's overlay idiom) and its own raw
|
|
5
|
+
// pointerdown/move/up + pointer-capture drag loop (there's no shared drag
|
|
6
|
+
// primitive in the repo to reuse). The sheet's height is fixed to the tallest
|
|
7
|
+
// snap point; the *visible* portion is controlled by translating it down in
|
|
8
|
+
// pixels, snapped to one of `snapPoints` (fractions of `window.innerHeight`).
|
|
9
|
+
import { createEffect, createSignal, onCleanup, onMount, Show, type JSX } from 'solid-js'
|
|
10
|
+
|
|
11
|
+
import { cn } from '../lib/cn'
|
|
12
|
+
import { animate, motionReduced } from '../lib/motion'
|
|
13
|
+
import { Portal } from './Portal'
|
|
14
|
+
|
|
15
|
+
export interface SheetSnapProps {
|
|
16
|
+
open: boolean
|
|
17
|
+
onOpenChange: (open: boolean) => void
|
|
18
|
+
/** Fractions of viewport height, ascending, e.g. `[0.4, 0.9]`. @default [0.5, 0.9] */
|
|
19
|
+
snapPoints?: number[]
|
|
20
|
+
/** Index into `snapPoints` to open at. @default 0 */
|
|
21
|
+
defaultSnap?: number
|
|
22
|
+
children: JSX.Element
|
|
23
|
+
class?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_SNAPS = [0.5, 0.9]
|
|
27
|
+
const SPRING_STIFFNESS = 380
|
|
28
|
+
const SPRING_DAMPING = 38
|
|
29
|
+
/** Drag past the lowest snap point by this many px (with no help from velocity) dismisses. */
|
|
30
|
+
const DISMISS_DISTANCE_PX = 100
|
|
31
|
+
/** px/ms; a flick faster than this biases the resolved snap by one level, or dismisses near the bottom. */
|
|
32
|
+
const FLICK_VELOCITY = 0.5
|
|
33
|
+
|
|
34
|
+
const clamp = (n: number, min: number, max: number) => Math.min(Math.max(n, min), max)
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A draggable bottom sheet that rests at one of several height "snap points"
|
|
38
|
+
* (fractions of viewport height) instead of just open/closed. Drag the handle
|
|
39
|
+
* to resize with the finger; releasing snaps to the nearest point, biased by
|
|
40
|
+
* flick velocity (a fast swipe jumps a level), and a hard downward flick/drag
|
|
41
|
+
* past the lowest point dismisses. Falls back to an instant show/hide at
|
|
42
|
+
* `defaultSnap` — no drag physics — under reduced motion.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```tsx
|
|
46
|
+
* const [open, setOpen] = createSignal(false)
|
|
47
|
+
* <SheetSnap open={open()} onOpenChange={setOpen} snapPoints={[0.4, 0.9]}>
|
|
48
|
+
* <p>Sheet content…</p>
|
|
49
|
+
* </SheetSnap>
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function SheetSnap(props: SheetSnapProps): JSX.Element {
|
|
53
|
+
const snaps = () => (props.snapPoints?.length ? props.snapPoints : DEFAULT_SNAPS)
|
|
54
|
+
const defaultIndex = () => clamp(props.defaultSnap ?? 0, 0, snaps().length - 1)
|
|
55
|
+
|
|
56
|
+
const [mounted, setMounted] = createSignal(false)
|
|
57
|
+
const [translateY, setTranslateY] = createSignal(0)
|
|
58
|
+
|
|
59
|
+
let previouslyFocused: HTMLElement | null = null
|
|
60
|
+
let previousBodyOverflow = ''
|
|
61
|
+
|
|
62
|
+
// Plain (non-reactive) geometry — recomputed on open and on resize, read
|
|
63
|
+
// fresh inside the `translateY()`-dependent style expression below.
|
|
64
|
+
let maxHeightPx = 0
|
|
65
|
+
let translateYs: number[] = []
|
|
66
|
+
let snapIndex = 0
|
|
67
|
+
|
|
68
|
+
let controls: ReturnType<typeof animate> | undefined
|
|
69
|
+
let dragging = false
|
|
70
|
+
let dragStartClientY = 0
|
|
71
|
+
let dragStartTranslate = 0
|
|
72
|
+
let lastClientY = 0
|
|
73
|
+
let lastTime = 0
|
|
74
|
+
let velocity = 0 // px/ms, positive = moving down (toward closed)
|
|
75
|
+
|
|
76
|
+
const computeGeometry = () => {
|
|
77
|
+
const vh = window.innerHeight
|
|
78
|
+
const pixelHeights = snaps().map((f) => f * vh)
|
|
79
|
+
maxHeightPx = Math.max(...pixelHeights)
|
|
80
|
+
translateYs = pixelHeights.map((h) => maxHeightPx - h)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const lockScroll = () => {
|
|
84
|
+
previousBodyOverflow = document.body.style.overflow
|
|
85
|
+
document.body.style.overflow = 'hidden'
|
|
86
|
+
}
|
|
87
|
+
const unlockScroll = () => {
|
|
88
|
+
document.body.style.overflow = previousBodyOverflow
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const animateTo = (target: number, onDone?: () => void) => {
|
|
92
|
+
controls?.stop()
|
|
93
|
+
if (motionReduced()) {
|
|
94
|
+
setTranslateY(target)
|
|
95
|
+
onDone?.()
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
controls = animate(translateY(), target, {
|
|
99
|
+
type: 'spring',
|
|
100
|
+
stiffness: SPRING_STIFFNESS,
|
|
101
|
+
damping: SPRING_DAMPING,
|
|
102
|
+
onUpdate: (v: number) => setTranslateY(v),
|
|
103
|
+
})
|
|
104
|
+
controls.finished.then(() => onDone?.()).catch(() => {})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Mount/unmount + enter/exit animation, driven off the controlled `open` prop.
|
|
108
|
+
createEffect(() => {
|
|
109
|
+
if (props.open) {
|
|
110
|
+
if (mounted()) return
|
|
111
|
+
computeGeometry()
|
|
112
|
+
snapIndex = defaultIndex()
|
|
113
|
+
previouslyFocused = document.activeElement as HTMLElement | null
|
|
114
|
+
setTranslateY(motionReduced() ? translateYs[snapIndex] : maxHeightPx)
|
|
115
|
+
setMounted(true)
|
|
116
|
+
lockScroll()
|
|
117
|
+
} else if (mounted()) {
|
|
118
|
+
animateTo(maxHeightPx, () => {
|
|
119
|
+
setMounted(false)
|
|
120
|
+
unlockScroll()
|
|
121
|
+
previouslyFocused?.focus()
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const resolveDragEnd = () => {
|
|
127
|
+
const current = translateY()
|
|
128
|
+
const mostClosedY = translateYs[0]
|
|
129
|
+
|
|
130
|
+
const draggedPastLowest = current - mostClosedY
|
|
131
|
+
const fastDownwardNearBottom =
|
|
132
|
+
velocity > FLICK_VELOCITY && current >= mostClosedY - DISMISS_DISTANCE_PX / 2
|
|
133
|
+
if (draggedPastLowest > DISMISS_DISTANCE_PX || fastDownwardNearBottom) {
|
|
134
|
+
props.onOpenChange(false)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let nearest = 0
|
|
139
|
+
let bestDist = Infinity
|
|
140
|
+
translateYs.forEach((t, i) => {
|
|
141
|
+
const d = Math.abs(current - t)
|
|
142
|
+
if (d < bestDist) {
|
|
143
|
+
bestDist = d
|
|
144
|
+
nearest = i
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
let target = nearest
|
|
149
|
+
if (velocity > FLICK_VELOCITY) target = Math.max(nearest - 1, 0)
|
|
150
|
+
else if (velocity < -FLICK_VELOCITY) target = Math.min(nearest + 1, translateYs.length - 1)
|
|
151
|
+
|
|
152
|
+
snapIndex = target
|
|
153
|
+
animateTo(translateYs[target])
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const onHandlePointerDown = (e: PointerEvent) => {
|
|
157
|
+
if (motionReduced()) return
|
|
158
|
+
e.preventDefault()
|
|
159
|
+
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
160
|
+
controls?.stop()
|
|
161
|
+
dragging = true
|
|
162
|
+
dragStartClientY = e.clientY
|
|
163
|
+
dragStartTranslate = translateY()
|
|
164
|
+
lastClientY = e.clientY
|
|
165
|
+
lastTime = performance.now()
|
|
166
|
+
velocity = 0
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const onHandlePointerMove = (e: PointerEvent) => {
|
|
170
|
+
if (!dragging) return
|
|
171
|
+
const next = Math.max(dragStartTranslate + (e.clientY - dragStartClientY), 0)
|
|
172
|
+
setTranslateY(next)
|
|
173
|
+
|
|
174
|
+
const now = performance.now()
|
|
175
|
+
const dt = now - lastTime
|
|
176
|
+
if (dt > 0) velocity = (e.clientY - lastClientY) / dt
|
|
177
|
+
lastClientY = e.clientY
|
|
178
|
+
lastTime = now
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const onHandlePointerUp = (e: PointerEvent) => {
|
|
182
|
+
if (!dragging) return
|
|
183
|
+
dragging = false
|
|
184
|
+
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
|
185
|
+
resolveDragEnd()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
189
|
+
if (e.key === 'Escape' && props.open) props.onOpenChange(false)
|
|
190
|
+
}
|
|
191
|
+
const onResize = () => {
|
|
192
|
+
if (!mounted()) return
|
|
193
|
+
computeGeometry()
|
|
194
|
+
setTranslateY(translateYs[clamp(snapIndex, 0, translateYs.length - 1)])
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
onMount(() => {
|
|
198
|
+
document.addEventListener('keydown', onKeyDown)
|
|
199
|
+
window.addEventListener('resize', onResize)
|
|
200
|
+
})
|
|
201
|
+
onCleanup(() => {
|
|
202
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
203
|
+
window.removeEventListener('resize', onResize)
|
|
204
|
+
controls?.stop()
|
|
205
|
+
if (mounted()) unlockScroll()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<Show when={mounted()}>
|
|
210
|
+
<Portal>
|
|
211
|
+
<div
|
|
212
|
+
aria-hidden="true"
|
|
213
|
+
onClick={() => props.onOpenChange(false)}
|
|
214
|
+
class="fixed inset-0 z-[9998] bg-black/50 backdrop-blur-[2px]"
|
|
215
|
+
/>
|
|
216
|
+
<div
|
|
217
|
+
ref={(el) => {
|
|
218
|
+
requestAnimationFrame(() => {
|
|
219
|
+
el.focus()
|
|
220
|
+
animateTo(translateYs[snapIndex])
|
|
221
|
+
})
|
|
222
|
+
}}
|
|
223
|
+
role="dialog"
|
|
224
|
+
aria-modal="true"
|
|
225
|
+
aria-label="Bottom sheet"
|
|
226
|
+
tabindex={-1}
|
|
227
|
+
class={cn(
|
|
228
|
+
'fixed inset-x-0 bottom-0 z-[9999] flex flex-col overflow-hidden rounded-t-2xl border border-border bg-glass shadow-2xl outline-none',
|
|
229
|
+
props.class,
|
|
230
|
+
)}
|
|
231
|
+
style={{
|
|
232
|
+
height: `${maxHeightPx}px`,
|
|
233
|
+
transform: `translateY(${translateY()}px)`,
|
|
234
|
+
}}
|
|
235
|
+
>
|
|
236
|
+
<div
|
|
237
|
+
role="button"
|
|
238
|
+
tabindex={0}
|
|
239
|
+
aria-label="Drag handle"
|
|
240
|
+
onPointerDown={onHandlePointerDown}
|
|
241
|
+
onPointerMove={onHandlePointerMove}
|
|
242
|
+
onPointerUp={onHandlePointerUp}
|
|
243
|
+
onPointerCancel={onHandlePointerUp}
|
|
244
|
+
onKeyDown={(e) => {
|
|
245
|
+
if (e.key === 'ArrowUp') {
|
|
246
|
+
e.preventDefault()
|
|
247
|
+
snapIndex = Math.min(snapIndex + 1, translateYs.length - 1)
|
|
248
|
+
animateTo(translateYs[snapIndex])
|
|
249
|
+
} else if (e.key === 'ArrowDown') {
|
|
250
|
+
e.preventDefault()
|
|
251
|
+
snapIndex = Math.max(snapIndex - 1, 0)
|
|
252
|
+
animateTo(translateYs[snapIndex])
|
|
253
|
+
}
|
|
254
|
+
}}
|
|
255
|
+
class="flex shrink-0 touch-none items-center justify-center py-3 outline-none"
|
|
256
|
+
>
|
|
257
|
+
<div class="h-1.5 w-10 rounded-full bg-muted-foreground/40" />
|
|
258
|
+
</div>
|
|
259
|
+
<div class="flex-1 overflow-y-auto overscroll-contain px-4 pb-4">{props.children}</div>
|
|
260
|
+
</div>
|
|
261
|
+
</Portal>
|
|
262
|
+
</Show>
|
|
263
|
+
)
|
|
264
|
+
}
|