@a4ui/core 0.11.1 → 0.13.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 (151) hide show
  1. package/README.md +80 -37
  2. package/dist/elements.css +79 -9
  3. package/dist/full.css +3112 -0
  4. package/dist/index.d.ts +13 -1
  5. package/dist/index.js +3392 -2537
  6. package/dist/lib/flyToCart.d.ts +25 -0
  7. package/dist/ui/Curtain.d.ts +44 -0
  8. package/dist/ui/Expandable.d.ts +34 -0
  9. package/dist/ui/FillText.d.ts +18 -0
  10. package/dist/ui/HoldToConfirm.d.ts +24 -0
  11. package/dist/ui/LoadingDots.d.ts +20 -0
  12. package/dist/ui/MultiStateBadge.d.ts +23 -0
  13. package/dist/ui/NotificationStack.d.ts +32 -0
  14. package/dist/ui/NowPlaying.d.ts +21 -0
  15. package/dist/ui/Parallax.d.ts +21 -0
  16. package/dist/ui/ScrambleText.d.ts +22 -0
  17. package/dist/ui/TextReveal.d.ts +23 -0
  18. package/package.json +8 -1
  19. package/src/charts/BarChart.tsx +55 -0
  20. package/src/charts/DonutChart.tsx +127 -0
  21. package/src/charts/Sparkline.tsx +101 -0
  22. package/src/charts/index.ts +5 -0
  23. package/src/commerce/CartLine.tsx +84 -0
  24. package/src/commerce/CartSummary.tsx +70 -0
  25. package/src/commerce/FilterGroup.tsx +70 -0
  26. package/src/commerce/PriceTag.tsx +62 -0
  27. package/src/commerce/ProductCard.tsx +121 -0
  28. package/src/commerce/ProductGrid.tsx +27 -0
  29. package/src/commerce/QuantityStepper.tsx +59 -0
  30. package/src/commerce/index.ts +10 -0
  31. package/src/elements.tsx +167 -0
  32. package/src/index.ts +186 -0
  33. package/src/layout/AppShell.tsx +102 -0
  34. package/src/layout/ChristmasBackground.tsx +177 -0
  35. package/src/layout/EffectsToggle.tsx +41 -0
  36. package/src/layout/NavGroup.tsx +45 -0
  37. package/src/layout/SnowScenery.tsx +76 -0
  38. package/src/layout/SpaceBackground.tsx +459 -0
  39. package/src/layout/ThemeToggle.tsx +40 -0
  40. package/src/layout/ThemedScenery.tsx +179 -0
  41. package/src/layout/sceneEffects.ts +49 -0
  42. package/src/lib/cn.ts +17 -0
  43. package/src/lib/effects.ts +71 -0
  44. package/src/lib/flyToCart.ts +103 -0
  45. package/src/lib/media.ts +27 -0
  46. package/src/lib/motion.ts +191 -0
  47. package/src/lib/theme.ts +113 -0
  48. package/src/lib/virtual.ts +33 -0
  49. package/src/styles/space.css +510 -0
  50. package/src/styles/tokens.css +213 -0
  51. package/src/themes/index.ts +102 -0
  52. package/src/themes/palettes.ts +368 -0
  53. package/src/ui/Accordion.tsx +65 -0
  54. package/src/ui/Affix.tsx +72 -0
  55. package/src/ui/Alert.tsx +55 -0
  56. package/src/ui/AlertDialog.tsx +58 -0
  57. package/src/ui/Anchor.tsx +100 -0
  58. package/src/ui/Avatar.tsx +34 -0
  59. package/src/ui/AvatarGroup.tsx +55 -0
  60. package/src/ui/BackToTop.tsx +51 -0
  61. package/src/ui/Badge.tsx +46 -0
  62. package/src/ui/BottomNavigation.tsx +65 -0
  63. package/src/ui/Breadcrumb.tsx +64 -0
  64. package/src/ui/Button.tsx +49 -0
  65. package/src/ui/Calendar.tsx +40 -0
  66. package/src/ui/CalendarHeatmap.tsx +180 -0
  67. package/src/ui/Card.tsx +84 -0
  68. package/src/ui/Carousel.tsx +121 -0
  69. package/src/ui/Cascader.tsx +158 -0
  70. package/src/ui/Checkbox.tsx +35 -0
  71. package/src/ui/Clock.tsx +221 -0
  72. package/src/ui/Collapse.tsx +48 -0
  73. package/src/ui/ColorPicker.tsx +89 -0
  74. package/src/ui/Combobox.tsx +67 -0
  75. package/src/ui/Command.tsx +158 -0
  76. package/src/ui/Comment.tsx +91 -0
  77. package/src/ui/ContextMenu.tsx +58 -0
  78. package/src/ui/Countdown.tsx +123 -0
  79. package/src/ui/Curtain.tsx +348 -0
  80. package/src/ui/DataGrid.tsx +177 -0
  81. package/src/ui/DateField.tsx +177 -0
  82. package/src/ui/DateRangePicker.tsx +204 -0
  83. package/src/ui/DateTimeField.tsx +68 -0
  84. package/src/ui/Descriptions.tsx +56 -0
  85. package/src/ui/Drawer.tsx +72 -0
  86. package/src/ui/Dropdown.tsx +71 -0
  87. package/src/ui/Dropzone.tsx +88 -0
  88. package/src/ui/Empty.tsx +53 -0
  89. package/src/ui/Expandable.tsx +229 -0
  90. package/src/ui/FileUpload.tsx +198 -0
  91. package/src/ui/FillText.tsx +63 -0
  92. package/src/ui/FloatingActionButton.tsx +48 -0
  93. package/src/ui/Form.tsx +108 -0
  94. package/src/ui/Highlight.tsx +53 -0
  95. package/src/ui/HoldToConfirm.tsx +141 -0
  96. package/src/ui/HoverCard.tsx +42 -0
  97. package/src/ui/Image.tsx +66 -0
  98. package/src/ui/Input.tsx +38 -0
  99. package/src/ui/Kbd.tsx +25 -0
  100. package/src/ui/List.tsx +71 -0
  101. package/src/ui/LoadingDots.tsx +75 -0
  102. package/src/ui/Marquee.tsx +48 -0
  103. package/src/ui/Mentions.tsx +170 -0
  104. package/src/ui/Meter.tsx +57 -0
  105. package/src/ui/Modal.tsx +106 -0
  106. package/src/ui/MultiSelect.tsx +236 -0
  107. package/src/ui/MultiStateBadge.tsx +93 -0
  108. package/src/ui/NotificationCenter.tsx +103 -0
  109. package/src/ui/NotificationStack.tsx +104 -0
  110. package/src/ui/NowPlaying.tsx +100 -0
  111. package/src/ui/NumberInput.tsx +48 -0
  112. package/src/ui/PageHeader.tsx +60 -0
  113. package/src/ui/Pagination.tsx +62 -0
  114. package/src/ui/Parallax.tsx +51 -0
  115. package/src/ui/Popover.tsx +42 -0
  116. package/src/ui/Portal.tsx +34 -0
  117. package/src/ui/Progress.tsx +55 -0
  118. package/src/ui/RadioGroup.tsx +65 -0
  119. package/src/ui/Rating.tsx +98 -0
  120. package/src/ui/Result.tsx +75 -0
  121. package/src/ui/RingProgress.tsx +75 -0
  122. package/src/ui/ScrambleText.tsx +95 -0
  123. package/src/ui/SegmentedControl.tsx +61 -0
  124. package/src/ui/Select.tsx +55 -0
  125. package/src/ui/Separator.tsx +39 -0
  126. package/src/ui/Skeleton.tsx +23 -0
  127. package/src/ui/Slider.tsx +57 -0
  128. package/src/ui/Sortable.tsx +174 -0
  129. package/src/ui/SpeedDial.tsx +85 -0
  130. package/src/ui/Spinner.tsx +33 -0
  131. package/src/ui/Splitter.tsx +115 -0
  132. package/src/ui/Stat.tsx +74 -0
  133. package/src/ui/Stepper.tsx +134 -0
  134. package/src/ui/Switch.tsx +44 -0
  135. package/src/ui/Table.tsx +128 -0
  136. package/src/ui/Tabs.tsx +72 -0
  137. package/src/ui/TagInput.tsx +85 -0
  138. package/src/ui/TextReveal.tsx +93 -0
  139. package/src/ui/Textarea.tsx +38 -0
  140. package/src/ui/TimeField.tsx +298 -0
  141. package/src/ui/Timeline.tsx +89 -0
  142. package/src/ui/Toast.tsx +68 -0
  143. package/src/ui/Toggle.tsx +41 -0
  144. package/src/ui/ToggleGroup.tsx +59 -0
  145. package/src/ui/Tooltip.tsx +42 -0
  146. package/src/ui/Tour.tsx +197 -0
  147. package/src/ui/Transfer.tsx +112 -0
  148. package/src/ui/Tree.tsx +100 -0
  149. package/src/ui/TreeSelect.tsx +151 -0
  150. package/src/ui/VirtualList.tsx +81 -0
  151. package/src/ui/internal/CalendarCore.tsx +276 -0
@@ -0,0 +1,72 @@
1
+ // Right-anchored slide-over on Kobalte's Dialog primitive (focus trap +
2
+ // presence for free, like Modal). The drawer-content class drives the
3
+ // slide-in/out keyframes in styles.css off Kobalte's data-expanded/data-closed.
4
+ // Pass a `title` for the sticky glass header, or omit it and render your own
5
+ // inside `children`.
6
+ import { Dialog } from '@kobalte/core/dialog'
7
+ import { X } from 'lucide-solid'
8
+ import type { JSX, ParentProps } from 'solid-js'
9
+ import { Show } from 'solid-js'
10
+
11
+ import { cn } from '../lib/cn'
12
+
13
+ interface DrawerProps extends ParentProps {
14
+ open: boolean
15
+ onOpenChange: (open: boolean) => void
16
+ /** Sticky glass header title. Omit to render fully custom header content in `children`. */
17
+ title?: string
18
+ /** Small muted line under `title`; only shown when `title` is also set. */
19
+ subtitle?: string
20
+ class?: string
21
+ /** Accessible label for the close button. @default 'Close' */
22
+ closeLabel?: string
23
+ }
24
+
25
+ /**
26
+ * Right-anchored slide-over panel built on Kobalte's `Dialog` (focus trap, portal,
27
+ * and open/close presence handled for you). Use for create/edit forms or detail
28
+ * panels that need more room than a centered `Modal`.
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * const [open, setOpen] = createSignal(false)
33
+ * <Drawer open={open()} onOpenChange={setOpen} title="Edit invoice">
34
+ * <p>Drawer content…</p>
35
+ * </Drawer>
36
+ * ```
37
+ */
38
+ export function Drawer(props: DrawerProps): JSX.Element {
39
+ return (
40
+ <Dialog open={props.open} onOpenChange={props.onOpenChange}>
41
+ <Dialog.Portal>
42
+ <Dialog.Overlay class="modal-overlay fixed inset-0 z-40 bg-black/50 backdrop-blur-[2px]" />
43
+ <div class="fixed inset-0 z-50 flex justify-end">
44
+ <Dialog.Content
45
+ class={cn(
46
+ 'drawer-content flex h-full w-full max-w-[520px] flex-col overflow-y-auto border-l border-border bg-glass shadow-2xl',
47
+ props.class,
48
+ )}
49
+ >
50
+ <Show when={props.title}>
51
+ <div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/90 px-5 py-4 backdrop-blur">
52
+ <div>
53
+ <Dialog.Title class="text-[16px] font-bold leading-tight">{props.title}</Dialog.Title>
54
+ <Show when={props.subtitle}>
55
+ <p class="text-[12px] text-muted-foreground">{props.subtitle}</p>
56
+ </Show>
57
+ </div>
58
+ <Dialog.CloseButton
59
+ class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
60
+ aria-label={props.closeLabel ?? 'Close'}
61
+ >
62
+ <X class="h-5 w-5" />
63
+ </Dialog.CloseButton>
64
+ </div>
65
+ </Show>
66
+ {props.children}
67
+ </Dialog.Content>
68
+ </div>
69
+ </Dialog.Portal>
70
+ </Dialog>
71
+ )
72
+ }
@@ -0,0 +1,71 @@
1
+ // Accessible dropdown menu on Kobalte's DropdownMenu primitive.
2
+ import { DropdownMenu } from '@kobalte/core/dropdown-menu'
3
+ import type { JSX, ParentProps } from 'solid-js'
4
+ import { For } from 'solid-js'
5
+
6
+ import { cn } from '../lib/cn'
7
+
8
+ /** A single selectable row in a {@link Dropdown} menu. */
9
+ export interface DropdownItem {
10
+ label: string
11
+ onSelect: () => void
12
+ /** Styles the label in the destructive color (e.g. "Delete"). */
13
+ destructive?: boolean
14
+ disabled?: boolean
15
+ }
16
+
17
+ interface DropdownProps extends ParentProps {
18
+ items: DropdownItem[]
19
+ class?: string
20
+ /** Accessible name for the trigger. Pass this instead of nesting a `<button>`
21
+ inside — the Trigger IS the button, so its children must be non-interactive. */
22
+ label?: string
23
+ }
24
+
25
+ /**
26
+ * Accessible dropdown/context menu on Kobalte's `DropdownMenu` primitive.
27
+ * `props.children` is the trigger's (non-interactive) content — the Trigger
28
+ * itself renders as the button, so don't nest another `<button>` inside it.
29
+ * Unlike a Dialog, it does not lock page scroll while open.
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * <Dropdown label="Row actions" items={[
34
+ * { label: 'Edit', onSelect: () => edit(row) },
35
+ * { label: 'Delete', destructive: true, onSelect: () => remove(row) },
36
+ * ]}>
37
+ * <MoreVertical class="h-4 w-4" />
38
+ * </Dropdown>
39
+ * ```
40
+ */
41
+ export function Dropdown(props: DropdownProps): JSX.Element {
42
+ return (
43
+ // preventScroll defaults to true in Kobalte's menu — that locks page scroll
44
+ // (body overflow:hidden + scrollbar-width compensation) while the menu is
45
+ // open, which is wrong for a small contextual menu (it closes on scroll
46
+ // anyway). Dialogs (Modal/Drawer) keep the lock; a dropdown shouldn't.
47
+ <DropdownMenu preventScroll={false}>
48
+ <DropdownMenu.Trigger class={cn('inline-flex', props.class)} aria-label={props.label}>
49
+ {props.children}
50
+ </DropdownMenu.Trigger>
51
+ <DropdownMenu.Portal>
52
+ <DropdownMenu.Content class="z-50 min-w-[10rem] overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-sm">
53
+ <For each={props.items}>
54
+ {(item) => (
55
+ <DropdownMenu.Item
56
+ class={cn(
57
+ 'cursor-pointer select-none rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
58
+ item.destructive && 'text-destructive',
59
+ )}
60
+ disabled={item.disabled}
61
+ onSelect={item.onSelect}
62
+ >
63
+ {item.label}
64
+ </DropdownMenu.Item>
65
+ )}
66
+ </For>
67
+ </DropdownMenu.Content>
68
+ </DropdownMenu.Portal>
69
+ </DropdownMenu>
70
+ )
71
+ }
@@ -0,0 +1,88 @@
1
+ // Drag-and-drop (or click-to-browse) file dropzone. Purely presentational: it
2
+ // collects File[] and hands them to `onFiles` — the caller owns upload/mutation.
3
+ import { UploadCloud } from 'lucide-solid'
4
+ import { type JSX, Show, createSignal } from 'solid-js'
5
+
6
+ interface DropzoneProps {
7
+ /** Called with the dropped or browsed files; the caller owns upload/validation. */
8
+ onFiles: (files: File[]) => void
9
+ /** `accept` attribute for the hidden input, e.g. ".xml,.zip". */
10
+ accept?: string
11
+ multiple?: boolean
12
+ disabled?: boolean
13
+ /** Optional hint line under the primary label. */
14
+ hint?: string
15
+ label?: string
16
+ }
17
+
18
+ /**
19
+ * Presentational drag-and-drop (or click-to-browse) file dropzone. Not a Kobalte
20
+ * wrapper — just a styled `role="button"` region plus a hidden `<input type="file">`.
21
+ * It only collects `File[]` via `onFiles`; it does not upload or store anything.
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * <Dropzone accept=".xml,.zip" multiple onFiles={(files) => upload(files)} />
26
+ * ```
27
+ */
28
+ export function Dropzone(props: DropzoneProps): JSX.Element {
29
+ const [dragOver, setDragOver] = createSignal(false)
30
+ let input: HTMLInputElement | undefined
31
+
32
+ const emit = (list: FileList | null | undefined) => {
33
+ if (props.disabled || !list) return
34
+ const files = Array.from(list)
35
+ if (files.length) props.onFiles(files)
36
+ }
37
+
38
+ return (
39
+ <div
40
+ role="button"
41
+ tabindex={props.disabled ? -1 : 0}
42
+ aria-disabled={props.disabled}
43
+ onClick={() => !props.disabled && input?.click()}
44
+ onKeyDown={(e) => {
45
+ if (props.disabled) return
46
+ if (e.key === 'Enter' || e.key === ' ') {
47
+ e.preventDefault()
48
+ input?.click()
49
+ }
50
+ }}
51
+ onDragOver={(e) => {
52
+ if (props.disabled) return
53
+ e.preventDefault()
54
+ setDragOver(true)
55
+ }}
56
+ onDragLeave={() => setDragOver(false)}
57
+ onDrop={(e) => {
58
+ e.preventDefault()
59
+ setDragOver(false)
60
+ emit(e.dataTransfer?.files)
61
+ }}
62
+ class="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-10 text-center transition"
63
+ classList={{
64
+ 'border-ring bg-primary/5': dragOver(),
65
+ 'border-border bg-glass hover:border-ring/60': !dragOver() && !props.disabled,
66
+ 'cursor-not-allowed opacity-60': props.disabled,
67
+ }}
68
+ >
69
+ <UploadCloud class="h-8 w-8 text-muted-foreground" />
70
+ <p class="text-sm font-medium text-foreground">{props.label ?? 'Drag files here or click to browse'}</p>
71
+ <Show when={props.hint}>
72
+ <p class="text-xs text-muted-foreground">{props.hint}</p>
73
+ </Show>
74
+ <input
75
+ ref={input}
76
+ type="file"
77
+ class="hidden"
78
+ accept={props.accept}
79
+ multiple={props.multiple}
80
+ disabled={props.disabled}
81
+ onChange={(e) => {
82
+ emit(e.currentTarget.files)
83
+ e.currentTarget.value = '' // allow re-selecting the same file
84
+ }}
85
+ />
86
+ </div>
87
+ )
88
+ }
@@ -0,0 +1,53 @@
1
+ // Empty-state placeholder: a centered vertical stack for "nothing here yet"
2
+ // surfaces (empty lists, no search results, first-run views). The icon sits in a
3
+ // muted circle; everything uses semantic tokens so it stays theme-agnostic in
4
+ // both dark and light themes.
5
+ import { Inbox } from 'lucide-solid'
6
+ import { Show, type JSX } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+
10
+ export interface EmptyProps {
11
+ /** Bold headline describing the empty state. */
12
+ title: string
13
+ /** Optional supporting line under the title. */
14
+ description?: string
15
+ /** Optional custom icon; falls back to a lucide-solid `Inbox`. */
16
+ icon?: JSX.Element
17
+ /** Optional action slot (e.g. a Button) rendered below the text. */
18
+ action?: JSX.Element
19
+ class?: string
20
+ }
21
+
22
+ /**
23
+ * Empty-state placeholder — a centered icon, title, optional description, and an
24
+ * optional action slot — for lists, search results, or first-run views that have
25
+ * nothing to show yet. Falls back to an `Inbox` icon when none is provided.
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * <Empty
30
+ * title="No projects yet"
31
+ * description="Create your first project to get started."
32
+ * action={<Button>New project</Button>}
33
+ * />
34
+ * ```
35
+ */
36
+ export function Empty(props: EmptyProps): JSX.Element {
37
+ return (
38
+ <div class={cn('flex flex-col items-center py-12 text-center', props.class)}>
39
+ <div class="grid h-12 w-12 place-items-center rounded-full bg-muted text-muted-foreground">
40
+ <Show when={props.icon} fallback={<Inbox class="h-6 w-6" />}>
41
+ {props.icon}
42
+ </Show>
43
+ </div>
44
+ <p class="mt-4 font-semibold text-foreground">{props.title}</p>
45
+ <Show when={props.description}>
46
+ <p class="mt-1 max-w-sm text-sm text-muted-foreground">{props.description}</p>
47
+ </Show>
48
+ <Show when={props.action}>
49
+ <div class="mt-4">{props.action}</div>
50
+ </Show>
51
+ </div>
52
+ )
53
+ }
@@ -0,0 +1,229 @@
1
+ // Shared-element transition: a compact `trigger` (a card) morphs into an
2
+ // expanded overlay panel and back, using a FLIP animation. We animate the
3
+ // panel's position and SIZE (top/left/width/height) rather than a transform
4
+ // scale, so its content never stretches. Covers the motion.dev "family dialog"
5
+ // (size="dialog") and "app store layout" (size="full") patterns with one API.
6
+ import { createSignal, Show, onCleanup, onMount, type JSX } from 'solid-js'
7
+ import { Portal } from 'solid-js/web'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { animate, motionReduced } from '../lib/motion'
11
+
12
+ type Rect = { top: number; left: number; width: number; height: number }
13
+
14
+ export interface ExpandableProps {
15
+ /** Collapsed content — the card users click to expand. */
16
+ trigger: JSX.Element
17
+ /** Expanded panel content. */
18
+ children: JSX.Element
19
+ /** `dialog` = centered panel capped at `maxWidth`; `full` = near-fullscreen. @default 'dialog' */
20
+ size?: 'dialog' | 'full'
21
+ /** Max width (px) of the expanded panel when `size='dialog'`. @default 640 */
22
+ maxWidth?: number
23
+ /** Notified when the panel opens/closes. */
24
+ onOpenChange?: (open: boolean) => void
25
+ /** Class for the inline trigger wrapper. */
26
+ class?: string
27
+ /** Class for the expanded panel surface. */
28
+ panelClass?: string
29
+ }
30
+
31
+ const PAD = 24
32
+
33
+ /**
34
+ * Expands a card into an overlay panel with a shared-element (FLIP) transition —
35
+ * the card appears to grow into the dialog and shrink back on close. Animates
36
+ * position + size (never scale) so content stays crisp. Falls back to a plain
37
+ * show/hide under reduced motion. Close with the ✕, a backdrop click, or Escape.
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * <Expandable
42
+ * trigger={<Card class="cursor-pointer">Tap to expand</Card>}
43
+ * size="dialog"
44
+ * >
45
+ * <div class="p-6">Full details here…</div>
46
+ * </Expandable>
47
+ * ```
48
+ */
49
+ export function Expandable(props: ExpandableProps): JSX.Element {
50
+ const [open, setOpen] = createSignal(false)
51
+ let triggerEl: HTMLDivElement | undefined
52
+ let panelEl: HTMLDivElement | undefined
53
+ let backdropEl: HTMLDivElement | undefined
54
+ let originRect: Rect | undefined
55
+ let controls: ReturnType<typeof animate> | undefined
56
+
57
+ const rectOf = (el: Element): Rect => {
58
+ const r = el.getBoundingClientRect()
59
+ return { top: r.top, left: r.left, width: r.width, height: r.height }
60
+ }
61
+
62
+ // The panel's resting position/size, computed (not scaled) so text stays sharp.
63
+ const finalRect = (el: HTMLDivElement): Rect => {
64
+ const vw = window.innerWidth
65
+ const vh = window.innerHeight
66
+ if (props.size === 'full') {
67
+ const width = vw - PAD * 2
68
+ const height = vh - PAD * 2
69
+ return { left: PAD, top: PAD, width, height }
70
+ }
71
+ const width = Math.min(props.maxWidth ?? 640, vw - PAD * 2)
72
+ // Measure the natural height at the target width.
73
+ el.style.width = `${width}px`
74
+ el.style.height = 'auto'
75
+ el.style.maxHeight = `${vh - PAD * 2}px`
76
+ const natH = el.getBoundingClientRect().height
77
+ const height = Math.min(natH, vh - PAD * 2)
78
+ return { left: (vw - width) / 2, top: (vh - height) / 2, width, height }
79
+ }
80
+
81
+ const place = (el: HTMLDivElement, r: Rect) => {
82
+ el.style.top = `${r.top}px`
83
+ el.style.left = `${r.left}px`
84
+ el.style.width = `${r.width}px`
85
+ el.style.height = `${r.height}px`
86
+ }
87
+
88
+ const doOpen = () => {
89
+ if (open() || !triggerEl) return
90
+ originRect = rectOf(triggerEl)
91
+ setOpen(true)
92
+ props.onOpenChange?.(true)
93
+ }
94
+
95
+ // Runs once the portalled panel is in the DOM: FLIP from the card to the panel.
96
+ const runOpenFlip = () => {
97
+ const el = panelEl
98
+ if (!el) return
99
+ const final = finalRect(el)
100
+ if (motionReduced() || !originRect) {
101
+ place(el, final)
102
+ el.style.overflow = 'auto'
103
+ el.style.opacity = '1'
104
+ return
105
+ }
106
+ place(el, originRect) // first
107
+ el.style.opacity = '1'
108
+ void el.offsetWidth // force reflow
109
+ controls?.stop()
110
+ controls = animate(
111
+ el,
112
+ {
113
+ top: [originRect.top, final.top],
114
+ left: [originRect.left, final.left],
115
+ width: [originRect.width, final.width],
116
+ height: [originRect.height, final.height],
117
+ },
118
+ { type: 'spring', stiffness: 280, damping: 32 },
119
+ )
120
+ if (backdropEl) animate(backdropEl, { opacity: [0, 1] }, { duration: 0.25, ease: 'easeOut' })
121
+ controls.finished
122
+ .then(() => {
123
+ el.style.overflow = 'auto'
124
+ })
125
+ .catch(() => {})
126
+ }
127
+
128
+ const doClose = () => {
129
+ if (!open()) return
130
+ const el = panelEl
131
+ const back = triggerEl ? rectOf(triggerEl) : originRect
132
+ if (!el || !back || motionReduced()) {
133
+ finishClose()
134
+ return
135
+ }
136
+ controls?.stop()
137
+ el.style.overflow = 'hidden'
138
+ place(el, rectOf(el)) // pin current
139
+ void el.offsetWidth
140
+ controls = animate(
141
+ el,
142
+ { top: back.top, left: back.left, width: back.width, height: back.height, opacity: [1, 0.35] },
143
+ { duration: 0.3, ease: 'easeInOut' },
144
+ )
145
+ if (backdropEl) animate(backdropEl, { opacity: [1, 0] }, { duration: 0.25, ease: 'easeIn' })
146
+ controls.finished.then(finishClose).catch(finishClose)
147
+ }
148
+
149
+ const finishClose = () => {
150
+ setOpen(false)
151
+ props.onOpenChange?.(false)
152
+ }
153
+
154
+ const onKeyDown = (e: KeyboardEvent) => {
155
+ if (e.key === 'Escape') doClose()
156
+ }
157
+
158
+ onMount(() => document.addEventListener('keydown', onKeyDown))
159
+ onCleanup(() => {
160
+ document.removeEventListener('keydown', onKeyDown)
161
+ controls?.stop()
162
+ })
163
+
164
+ return (
165
+ <>
166
+ <div
167
+ ref={triggerEl}
168
+ role="button"
169
+ tabindex={0}
170
+ class={cn('cursor-pointer', props.class)}
171
+ onClick={doOpen}
172
+ onKeyDown={(e) => {
173
+ if (e.key === 'Enter' || e.key === ' ') {
174
+ e.preventDefault()
175
+ doOpen()
176
+ }
177
+ }}
178
+ >
179
+ {props.trigger}
180
+ </div>
181
+
182
+ <Show when={open()}>
183
+ <Portal>
184
+ <div
185
+ ref={backdropEl}
186
+ aria-hidden="true"
187
+ onClick={doClose}
188
+ class="fixed inset-0 z-[9998] bg-black/50 backdrop-blur-sm"
189
+ style={{ opacity: motionReduced() ? '1' : '0' }}
190
+ />
191
+ <div
192
+ ref={(el) => {
193
+ panelEl = el
194
+ // Measure/FLIP after layout settles.
195
+ requestAnimationFrame(runOpenFlip)
196
+ }}
197
+ role="dialog"
198
+ aria-modal="true"
199
+ class={cn(
200
+ 'card fixed z-[9999] overflow-hidden rounded-2xl border border-border bg-card shadow-2xl',
201
+ props.panelClass,
202
+ )}
203
+ style={{ opacity: '0', margin: '0' }}
204
+ >
205
+ <button
206
+ type="button"
207
+ aria-label="Close"
208
+ onClick={doClose}
209
+ class="absolute right-3 top-3 z-10 grid h-8 w-8 place-items-center rounded-full bg-muted/80 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
210
+ >
211
+ <svg
212
+ viewBox="0 0 24 24"
213
+ class="h-4 w-4"
214
+ fill="none"
215
+ stroke="currentColor"
216
+ stroke-width="2.5"
217
+ stroke-linecap="round"
218
+ aria-hidden="true"
219
+ >
220
+ <path d="M6 6l12 12M18 6L6 18" />
221
+ </svg>
222
+ </button>
223
+ {props.children}
224
+ </div>
225
+ </Portal>
226
+ </Show>
227
+ </>
228
+ )
229
+ }