@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.
Files changed (58) hide show
  1. package/dist/elements.css +299 -0
  2. package/dist/full.css +299 -0
  3. package/dist/index.d.ts +27 -1
  4. package/dist/index.js +9140 -5065
  5. package/dist/ui/ActivityFeed.d.ts +49 -0
  6. package/dist/ui/AudioWaveform.d.ts +22 -0
  7. package/dist/ui/AvailabilityPicker.d.ts +35 -0
  8. package/dist/ui/CodeEditor.d.ts +25 -0
  9. package/dist/ui/CouponField.d.ts +33 -0
  10. package/dist/ui/EmojiPicker.d.ts +18 -0
  11. package/dist/ui/EventScheduler.d.ts +41 -0
  12. package/dist/ui/FollowingPointer.d.ts +24 -0
  13. package/dist/ui/GanttChart.d.ts +37 -0
  14. package/dist/ui/InteractiveMap.d.ts +50 -0
  15. package/dist/ui/JsonViewer.d.ts +24 -0
  16. package/dist/ui/Kanban.d.ts +49 -0
  17. package/dist/ui/Lamp.d.ts +22 -0
  18. package/dist/ui/Lightbox.d.ts +41 -0
  19. package/dist/ui/LocationPicker.d.ts +25 -0
  20. package/dist/ui/MaskedInput.d.ts +26 -0
  21. package/dist/ui/OnboardingChecklist.d.ts +51 -0
  22. package/dist/ui/OtpInput.d.ts +29 -0
  23. package/dist/ui/PivotTable.d.ts +37 -0
  24. package/dist/ui/PresenceAvatars.d.ts +54 -0
  25. package/dist/ui/QueryBuilder.d.ts +56 -0
  26. package/dist/ui/SheetSnap.d.ts +28 -0
  27. package/dist/ui/SignaturePad.d.ts +19 -0
  28. package/dist/ui/SpreadsheetGrid.d.ts +27 -0
  29. package/dist/ui/TreeTable.d.ts +52 -0
  30. package/dist/ui/VideoPlayerShell.d.ts +19 -0
  31. package/package.json +1 -1
  32. package/src/index.ts +60 -1
  33. package/src/ui/ActivityFeed.tsx +178 -0
  34. package/src/ui/AudioWaveform.tsx +190 -0
  35. package/src/ui/AvailabilityPicker.tsx +163 -0
  36. package/src/ui/CodeEditor.tsx +277 -0
  37. package/src/ui/CouponField.tsx +120 -0
  38. package/src/ui/EmojiPicker.tsx +424 -0
  39. package/src/ui/EventScheduler.tsx +280 -0
  40. package/src/ui/FollowingPointer.tsx +112 -0
  41. package/src/ui/GanttChart.tsx +283 -0
  42. package/src/ui/InteractiveMap.tsx +349 -0
  43. package/src/ui/JsonViewer.tsx +189 -0
  44. package/src/ui/Kanban.tsx +250 -0
  45. package/src/ui/Lamp.tsx +88 -0
  46. package/src/ui/Lightbox.tsx +261 -0
  47. package/src/ui/LocationPicker.tsx +96 -0
  48. package/src/ui/MaskedInput.tsx +108 -0
  49. package/src/ui/OnboardingChecklist.tsx +163 -0
  50. package/src/ui/OtpInput.tsx +153 -0
  51. package/src/ui/PivotTable.tsx +0 -0
  52. package/src/ui/PresenceAvatars.tsx +142 -0
  53. package/src/ui/QueryBuilder.tsx +280 -0
  54. package/src/ui/SheetSnap.tsx +264 -0
  55. package/src/ui/SignaturePad.tsx +221 -0
  56. package/src/ui/SpreadsheetGrid.tsx +304 -0
  57. package/src/ui/TreeTable.tsx +172 -0
  58. 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
+ }