@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,95 @@
1
+ // Text that "decodes" into place: characters scramble through random glyphs
2
+ // before locking, left to right, into the final string — motion.dev's
3
+ // scramble-text effect, reimplemented here with a plain setInterval (locking
4
+ // one more character's worth of the string on each tick) rather than the
5
+ // `motion` package, since the effect is character substitution, not a tween.
6
+ import { createSignal, onCleanup, onMount, type JSX } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+ import { motionReduced } from '../lib/motion'
10
+
11
+ export interface ScrambleTextProps {
12
+ text: string
13
+ /** When to run: on mount, or on hover. @default 'mount' */
14
+ trigger?: 'mount' | 'hover'
15
+ /** Total scramble duration in ms. @default 800 */
16
+ duration?: number
17
+ class?: string
18
+ }
19
+
20
+ const GLYPHS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*<>-_'
21
+ const TICK_MS = 30
22
+
23
+ function randomGlyph(): string {
24
+ return GLYPHS[Math.floor(Math.random() * GLYPHS.length)] as string
25
+ }
26
+
27
+ /**
28
+ * Text that scrambles through random glyphs before "decoding" into the final
29
+ * string, left to right, over `duration` ms. Runs once on mount by default;
30
+ * pass `trigger="hover"` to (re)run it — cleanly restarting if it's still
31
+ * mid-scramble — on pointer hover instead. Renders the final text as-is, with
32
+ * no scramble, when the user prefers reduced motion.
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * <ScrambleText text="ACCESS GRANTED" trigger="hover" duration={600} />
37
+ * ```
38
+ */
39
+ export function ScrambleText(props: ScrambleTextProps): JSX.Element {
40
+ // eslint-disable-next-line solid/reactivity -- one-time seed; scramble() re-reads props.text on each run
41
+ const [display, setDisplay] = createSignal(props.text)
42
+ let intervalId: ReturnType<typeof setInterval> | undefined
43
+
44
+ const stop = (): void => {
45
+ if (intervalId === undefined) return
46
+ clearInterval(intervalId)
47
+ intervalId = undefined
48
+ }
49
+
50
+ const scramble = (): void => {
51
+ const text = props.text
52
+ if (motionReduced()) {
53
+ setDisplay(text)
54
+ return
55
+ }
56
+
57
+ // Restart cleanly if a previous scramble (from a prior trigger) is still running.
58
+ stop()
59
+ const totalTicks = Math.max(1, Math.round((props.duration ?? 800) / TICK_MS))
60
+ let tick = 0
61
+
62
+ intervalId = setInterval(() => {
63
+ tick += 1
64
+
65
+ if (tick >= totalTicks) {
66
+ setDisplay(text)
67
+ stop()
68
+ return
69
+ }
70
+
71
+ const lockedCount = Math.floor((tick / totalTicks) * text.length)
72
+ setDisplay(
73
+ text
74
+ .split('')
75
+ .map((char, i) => (char === ' ' || i < lockedCount ? char : randomGlyph()))
76
+ .join(''),
77
+ )
78
+ }, TICK_MS)
79
+ }
80
+
81
+ onMount(() => {
82
+ if (props.trigger !== 'hover') scramble()
83
+ })
84
+ onCleanup(stop)
85
+
86
+ const handleMouseEnter = (): void => {
87
+ if (props.trigger === 'hover') scramble()
88
+ }
89
+
90
+ return (
91
+ <span class={cn('font-mono', props.class)} onMouseEnter={handleMouseEnter}>
92
+ {display()}
93
+ </span>
94
+ )
95
+ }
@@ -0,0 +1,61 @@
1
+ // Single-choice segmented control on Kobalte's SegmentedControl primitive,
2
+ // with an animated indicator sliding under the selected item.
3
+ import { SegmentedControl as KSegmentedControl } from '@kobalte/core/segmented-control'
4
+ import type { JSX } from 'solid-js'
5
+ import { For } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+
9
+ /** A single selectable choice within a {@link SegmentedControl}. */
10
+ export interface SegmentedOption {
11
+ value: string
12
+ label: string
13
+ }
14
+
15
+ interface SegmentedControlProps {
16
+ value: string
17
+ onChange: (value: string) => void
18
+ options: SegmentedOption[]
19
+ class?: string
20
+ }
21
+
22
+ /**
23
+ * Single-choice segmented control with an animated indicator that slides
24
+ * under the selected item, built on Kobalte's `SegmentedControl` primitive.
25
+ * Use for a small, always-visible set of mutually exclusive options (e.g.
26
+ * view toggles) as a more compact alternative to {@link RadioGroup}.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * <SegmentedControl
31
+ * value={view()}
32
+ * onChange={setView}
33
+ * options={[
34
+ * { value: 'list', label: 'List' },
35
+ * { value: 'grid', label: 'Grid' },
36
+ * ]}
37
+ * />
38
+ * ```
39
+ */
40
+ export function SegmentedControl(props: SegmentedControlProps): JSX.Element {
41
+ return (
42
+ <KSegmentedControl
43
+ value={props.value}
44
+ onChange={props.onChange}
45
+ class={cn('relative inline-flex rounded-md border border-border bg-card p-1', props.class)}
46
+ >
47
+ <KSegmentedControl.Indicator class="absolute rounded-sm bg-primary shadow-sm transition-all" />
48
+ <For each={props.options}>
49
+ {(option) => (
50
+ <KSegmentedControl.Item
51
+ value={option.value}
52
+ class="relative z-10 flex cursor-pointer items-center px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors data-[selected]:text-primary-foreground"
53
+ >
54
+ <KSegmentedControl.ItemInput />
55
+ <KSegmentedControl.ItemLabel>{option.label}</KSegmentedControl.ItemLabel>
56
+ </KSegmentedControl.Item>
57
+ )}
58
+ </For>
59
+ </KSegmentedControl>
60
+ )
61
+ }
@@ -0,0 +1,55 @@
1
+ // Controlled native <select>.
2
+ import type { JSX, ParentProps } from 'solid-js'
3
+ import { createEffect, splitProps } from 'solid-js'
4
+
5
+ import { cn } from '../lib/cn'
6
+
7
+ const SELECT_BASE =
8
+ 'w-full rounded-md border border-input bg-background text-foreground px-3 py-2 text-sm outline-none transition-colors a4-field'
9
+
10
+ interface SelectProps
11
+ extends ParentProps, Omit<JSX.SelectHTMLAttributes<HTMLSelectElement>, 'value' | 'onChange'> {
12
+ value: string
13
+ onChange: (value: string) => void
14
+ class?: string
15
+ }
16
+
17
+ /**
18
+ * Controlled native `<select>`, styled to match the design system. Pass
19
+ * `<option>` elements as children. Not a Kobalte primitive — for a
20
+ * non-native, fully custom dropdown consider building on Kobalte's `Select`
21
+ * directly.
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * <Select value={country()} onChange={setCountry}>
26
+ * <option value="mx">Mexico</option>
27
+ * <option value="us">United States</option>
28
+ * </Select>
29
+ * ```
30
+ */
31
+ export function Select(props: SelectProps): JSX.Element {
32
+ const [local, rest] = splitProps(props, ['value', 'onChange', 'class', 'children'])
33
+ let el!: HTMLSelectElement
34
+ // A native <select> silently drops its value when the matching <option> isn't
35
+ // in the DOM yet — e.g. options loaded async, or the value set (edit form)
36
+ // before the options mount. The declarative `value` below covers the common
37
+ // case; this effect re-applies it whenever EITHER the value or the option set
38
+ // changes, so edit forms reliably pre-select.
39
+ createEffect(() => {
40
+ const v = local.value ?? ''
41
+ void local.children // track option changes
42
+ if (el && el.value !== v) el.value = v
43
+ })
44
+ return (
45
+ <select
46
+ ref={el}
47
+ class={cn(SELECT_BASE, local.class)}
48
+ value={local.value}
49
+ onChange={(ev) => local.onChange(ev.currentTarget.value)}
50
+ {...rest}
51
+ >
52
+ {local.children}
53
+ </select>
54
+ )
55
+ }
@@ -0,0 +1,39 @@
1
+ // Visual/semantic divider on Kobalte's Separator primitive.
2
+ import { Separator as KSeparator } from '@kobalte/core/separator'
3
+ import type { JSX } from 'solid-js'
4
+
5
+ import { cn } from '../lib/cn'
6
+
7
+ interface SeparatorProps {
8
+ /** Default: `'horizontal'`. */
9
+ orientation?: 'horizontal' | 'vertical'
10
+ class?: string
11
+ }
12
+
13
+ /**
14
+ * Visual/semantic divider, built on Kobalte's `Separator` primitive (renders
15
+ * with the correct ARIA role for the given orientation). Use to separate
16
+ * groups of content, e.g. inside a menu or between toolbar sections.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * <Separator orientation="vertical" />
21
+ * ```
22
+ */
23
+ export function Separator(props: SeparatorProps): JSX.Element {
24
+ const orientation = () => props.orientation ?? 'horizontal'
25
+ // Kobalte's Separator renders an <hr>, which carries default block margins and
26
+ // a zero height that defeat `self-stretch` on the vertical variant — reset both
27
+ // (border-0 m-0) and give vertical a fallback min-height so it shows even when
28
+ // its flex parent doesn't stretch it.
29
+ return (
30
+ <KSeparator
31
+ orientation={orientation()}
32
+ class={cn(
33
+ 'shrink-0 border-0 bg-border m-0',
34
+ orientation() === 'vertical' ? 'w-px min-h-4 h-full self-stretch' : 'h-px w-full',
35
+ props.class,
36
+ )}
37
+ />
38
+ )
39
+ }
@@ -0,0 +1,23 @@
1
+ // Pulsing placeholder for loading content — plain div, no primitive.
2
+ import type { JSX } from 'solid-js'
3
+
4
+ import { cn } from '../lib/cn'
5
+
6
+ interface SkeletonProps {
7
+ /** Size/shape the placeholder to match the content it stands in for, e.g. `"h-4 w-32 rounded-full"`. */
8
+ class?: string
9
+ }
10
+
11
+ /**
12
+ * Pulsing placeholder block for content that is still loading. Plain `div`,
13
+ * no Kobalte primitive — size it via `class` to match the shape of the real
14
+ * content (text line, avatar, card, etc.).
15
+ *
16
+ * @example
17
+ * ```tsx
18
+ * <Skeleton class="h-4 w-48" />
19
+ * ```
20
+ */
21
+ export function Skeleton(props: SkeletonProps): JSX.Element {
22
+ return <div class={cn('animate-pulse rounded-md bg-muted', props.class)} />
23
+ }
@@ -0,0 +1,57 @@
1
+ // Single-value range slider on Kobalte's Slider primitive. Kobalte works in
2
+ // arrays (multi-thumb); we wrap/unwrap the single value for a simpler API.
3
+ import { Slider as KSlider } from '@kobalte/core/slider'
4
+ import type { JSX } from 'solid-js'
5
+ import { Show } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+
9
+ interface SliderProps {
10
+ value: number
11
+ onChange: (value: number) => void
12
+ /** Default: 0. */
13
+ min?: number
14
+ /** Default: 100. */
15
+ max?: number
16
+ /** Increment step. Default: 1. */
17
+ step?: number
18
+ /** Label shown above the track, paired with an auto-generated value label. */
19
+ label?: string
20
+ class?: string
21
+ }
22
+
23
+ /**
24
+ * Single-value range slider, built on Kobalte's `Slider` primitive (which
25
+ * natively supports multiple thumbs). This wrapper accepts and emits a plain
26
+ * `number` instead of an array, for a simpler single-thumb API.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * <Slider label="Volume" value={volume()} onChange={setVolume} min={0} max={100} />
31
+ * ```
32
+ */
33
+ export function Slider(props: SliderProps): JSX.Element {
34
+ return (
35
+ <KSlider
36
+ value={[props.value]}
37
+ onChange={(v) => props.onChange(v[0])}
38
+ minValue={props.min ?? 0}
39
+ maxValue={props.max ?? 100}
40
+ step={props.step ?? 1}
41
+ class={cn('flex flex-col gap-2', props.class)}
42
+ >
43
+ <Show when={props.label}>
44
+ <div class="flex items-center justify-between text-sm text-foreground">
45
+ <KSlider.Label>{props.label}</KSlider.Label>
46
+ <KSlider.ValueLabel class="text-muted-foreground" />
47
+ </div>
48
+ </Show>
49
+ <KSlider.Track class="relative h-1.5 w-full rounded-full bg-muted">
50
+ <KSlider.Fill class="absolute h-full rounded-full bg-primary" />
51
+ <KSlider.Thumb class="-top-1.5 block h-4 w-4 rounded-full bg-primary outline-none focus:ring-2 focus:ring-ring/40">
52
+ <KSlider.Input />
53
+ </KSlider.Thumb>
54
+ </KSlider.Track>
55
+ </KSlider>
56
+ )
57
+ }
@@ -0,0 +1,174 @@
1
+ // Generic drag-to-reorder list. Dragging is pointer-events based (mouse +
2
+ // touch, no HTML5 drag/drop, no external lib): a grip handle starts the drag,
3
+ // a floating clone follows the pointer through a Portal, and the grabbed
4
+ // row's slot collapses into a dashed placeholder until it's dropped.
5
+ import { createEffect, createSignal, For, Show, type JSX } from 'solid-js'
6
+ import { Portal } from 'solid-js/web'
7
+ import { GripVertical } from 'lucide-solid'
8
+
9
+ import { cn } from '../lib/cn'
10
+
11
+ export interface SortableProps<T> {
12
+ /** The items to render, in current order. */
13
+ items: T[]
14
+ /** Stable unique key for an item (used for drag tracking + list keying). */
15
+ itemKey: (item: T) => string
16
+ /** Render an item's content (the grip handle is added automatically to its left). */
17
+ children: (item: T) => JSX.Element
18
+ /** Called with the new order after a drop. */
19
+ onReorder: (items: T[]) => void
20
+ /** Disable dragging. */
21
+ disabled?: boolean
22
+ class?: string
23
+ }
24
+
25
+ interface Drag {
26
+ key: string
27
+ y: number
28
+ offset: number
29
+ height: number
30
+ width: number
31
+ left: number
32
+ }
33
+
34
+ /**
35
+ * Vertical list whose rows reorder by dragging a grip handle. Pointer-events
36
+ * driven (works with mouse and touch): the grabbed row leaves behind a dashed
37
+ * placeholder while a floating clone follows the pointer, and other rows shift
38
+ * live as the pointer crosses their midpoint. `onReorder` fires once on drop
39
+ * with the final order.
40
+ *
41
+ * @example
42
+ * ```tsx
43
+ * <Sortable
44
+ * items={fields()}
45
+ * itemKey={(f) => f.id}
46
+ * onReorder={setFields}
47
+ * >
48
+ * {(field) => <span>{field.label}</span>}
49
+ * </Sortable>
50
+ * ```
51
+ */
52
+ export function Sortable<T>(props: SortableProps<T>): JSX.Element {
53
+ // eslint-disable-next-line solid/reactivity -- seed once; a createEffect below keeps it in sync
54
+ const [local, setLocal] = createSignal<T[]>(props.items)
55
+ const [drag, setDrag] = createSignal<Drag | null>(null)
56
+ let listRef: HTMLDivElement | undefined
57
+
58
+ createEffect(() => {
59
+ const next = props.items
60
+ if (!drag()) setLocal(next)
61
+ })
62
+
63
+ const onDragMove = (e: PointerEvent) => {
64
+ const d = drag()
65
+ if (!d || !listRef) return
66
+ setDrag({ ...d, y: e.clientY })
67
+ const rows = Array.from(listRef.querySelectorAll<HTMLElement>('[data-sortable-item]'))
68
+ let target = rows.length - 1
69
+ for (let i = 0; i < rows.length; i++) {
70
+ const r = rows[i].getBoundingClientRect()
71
+ if (e.clientY < r.top + r.height / 2) {
72
+ target = i
73
+ break
74
+ }
75
+ }
76
+ const cur = local()
77
+ const from = cur.findIndex((it) => props.itemKey(it) === d.key)
78
+ if (from === -1 || from === target) return
79
+ const next = cur.slice()
80
+ const [moved] = next.splice(from, 1)
81
+ next.splice(target, 0, moved)
82
+ setLocal(next)
83
+ }
84
+
85
+ const onDragStart = (e: PointerEvent, key: string) => {
86
+ if (props.disabled) return
87
+ e.preventDefault()
88
+ const row = (e.currentTarget as HTMLElement).closest('[data-sortable-item]') as HTMLElement | null
89
+ if (!row) return
90
+ const r = row.getBoundingClientRect()
91
+ setDrag({ key, y: e.clientY, offset: e.clientY - r.top, height: r.height, width: r.width, left: r.left })
92
+
93
+ const move = (ev: PointerEvent) => onDragMove(ev)
94
+ const up = () => {
95
+ window.removeEventListener('pointermove', move)
96
+ window.removeEventListener('pointerup', up)
97
+ const order = local()
98
+ const had = drag() !== null
99
+ setDrag(null)
100
+ if (had) props.onReorder(order)
101
+ }
102
+ window.addEventListener('pointermove', move)
103
+ window.addEventListener('pointerup', up)
104
+ }
105
+
106
+ const draggedItem = () => {
107
+ const d = drag()
108
+ if (!d) return undefined
109
+ return local().find((it) => props.itemKey(it) === d.key)
110
+ }
111
+
112
+ return (
113
+ <div ref={listRef} class={cn('flex flex-col gap-2', drag() && 'select-none', props.class)}>
114
+ <For each={local()} fallback={null}>
115
+ {(item) => {
116
+ const key = props.itemKey(item)
117
+ const isDragged = () => drag()?.key === key
118
+ return (
119
+ <Show
120
+ when={!isDragged()}
121
+ fallback={
122
+ <div
123
+ data-sortable-item
124
+ class="rounded-lg border-2 border-dashed border-primary/40 bg-primary/5"
125
+ style={{ height: `${drag()?.height ?? 0}px` }}
126
+ />
127
+ }
128
+ >
129
+ <div
130
+ data-sortable-item
131
+ class="flex items-center gap-2 rounded-lg border border-border bg-card p-2"
132
+ >
133
+ <button
134
+ type="button"
135
+ onPointerDown={(e) => onDragStart(e, key)}
136
+ style={{ 'touch-action': 'none' }}
137
+ class="cursor-grab active:cursor-grabbing text-muted-foreground shrink-0"
138
+ aria-label="Drag to reorder"
139
+ disabled={props.disabled}
140
+ >
141
+ <GripVertical class="h-4 w-4" />
142
+ </button>
143
+ <div class="min-w-0 flex-1">{props.children(item)}</div>
144
+ </div>
145
+ </Show>
146
+ )
147
+ }}
148
+ </For>
149
+ <Show when={drag()}>
150
+ {(d) => (
151
+ <Portal>
152
+ <div
153
+ class="fixed shadow-2xl flex items-center gap-2 rounded-lg border border-border bg-card p-2"
154
+ style={{
155
+ left: `${d().left}px`,
156
+ top: `${d().y - d().offset}px`,
157
+ width: `${d().width}px`,
158
+ 'pointer-events': 'none',
159
+ 'z-index': 70,
160
+ }}
161
+ >
162
+ <span class="text-muted-foreground shrink-0">
163
+ <GripVertical class="h-4 w-4" />
164
+ </span>
165
+ <div class="min-w-0 flex-1">
166
+ <Show when={draggedItem()}>{(item) => props.children(item())}</Show>
167
+ </div>
168
+ </div>
169
+ </Portal>
170
+ )}
171
+ </Show>
172
+ </div>
173
+ )
174
+ }
@@ -0,0 +1,85 @@
1
+ // Floating action button that fans a stack of actions upward when open.
2
+ import { Plus } from 'lucide-solid'
3
+ import type { JSX } from 'solid-js'
4
+ import { createSignal, For, Show } from 'solid-js'
5
+
6
+ import { cn } from '../lib/cn'
7
+ import { animate, motionReduced } from '../lib/motion'
8
+
9
+ /** A single action in a {@link SpeedDial} — an icon button with an accessible label. */
10
+ export interface SpeedDialAction {
11
+ icon: JSX.Element
12
+ label: string
13
+ onClick: () => void
14
+ }
15
+
16
+ export interface SpeedDialProps {
17
+ actions: SpeedDialAction[]
18
+ /** Icon for the main FAB. Defaults to a `Plus` that rotates to an ✕ when open. */
19
+ icon?: JSX.Element
20
+ class?: string
21
+ }
22
+
23
+ /**
24
+ * A fixed floating action button (FAB) pinned to the bottom-right. Clicking the
25
+ * main circular button toggles an open state; while open, `actions` fan out
26
+ * upward as a vertical stack of smaller circular buttons. Selecting an action
27
+ * runs its `onClick` and closes the dial. Theme-agnostic — colors come from
28
+ * semantic tokens only.
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * <SpeedDial actions={[
33
+ * { icon: <Share class="h-5 w-5" />, label: 'Share', onClick: () => share() },
34
+ * { icon: <Copy class="h-5 w-5" />, label: 'Copy', onClick: () => copy() },
35
+ * ]} />
36
+ * ```
37
+ */
38
+ export function SpeedDial(props: SpeedDialProps): JSX.Element {
39
+ const [open, setOpen] = createSignal(false)
40
+
41
+ return (
42
+ <div class={cn('fixed bottom-6 right-6 z-40 flex flex-col items-center gap-3', props.class)}>
43
+ <Show when={open()}>
44
+ <div class="flex flex-col items-center gap-3">
45
+ <For each={props.actions}>
46
+ {(action, i) => (
47
+ <button
48
+ ref={(el) => {
49
+ // Fan out with a staggered spring as the dial opens.
50
+ if (!motionReduced())
51
+ animate(
52
+ el,
53
+ { opacity: [0, 1], y: [12, 0], scale: [0.8, 1] },
54
+ { type: 'spring', stiffness: 500, damping: 24, delay: i() * 0.05 },
55
+ )
56
+ }}
57
+ type="button"
58
+ aria-label={action.label}
59
+ title={action.label}
60
+ onClick={() => {
61
+ action.onClick()
62
+ setOpen(false)
63
+ }}
64
+ class="flex h-11 w-11 items-center justify-center rounded-full border border-border bg-card text-foreground shadow hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring"
65
+ >
66
+ {action.icon}
67
+ </button>
68
+ )}
69
+ </For>
70
+ </div>
71
+ </Show>
72
+ <button
73
+ type="button"
74
+ aria-label={open() ? 'Close actions' : 'Open actions'}
75
+ aria-expanded={open()}
76
+ onClick={() => setOpen((v) => !v)}
77
+ class="flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform duration-150 active:scale-[0.97] focus:outline-none focus:ring-2 focus:ring-ring"
78
+ >
79
+ <span class={cn('inline-flex transition-transform duration-150', open() && 'rotate-45')}>
80
+ {props.icon ?? <Plus class="h-6 w-6" />}
81
+ </span>
82
+ </button>
83
+ </div>
84
+ )
85
+ }
@@ -0,0 +1,33 @@
1
+ import type { JSX } from 'solid-js'
2
+
3
+ import { cn } from '../lib/cn'
4
+
5
+ interface SpinnerProps {
6
+ class?: string
7
+ /** Accessible label announced to screen readers via `aria-label`. Default: `'Loading'`. */
8
+ label?: string
9
+ }
10
+
11
+ /**
12
+ * Small spinning loading indicator (inline SVG, no primitive). Use for
13
+ * indeterminate loading states such as button/inline busy states.
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * <Spinner label="Saving" />
18
+ * ```
19
+ */
20
+ export function Spinner(props: SpinnerProps): JSX.Element {
21
+ return (
22
+ <svg
23
+ class={cn('h-5 w-5 animate-spin text-muted-foreground', props.class)}
24
+ viewBox="0 0 24 24"
25
+ fill="none"
26
+ role="status"
27
+ aria-label={props.label ?? 'Loading'}
28
+ >
29
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
30
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
31
+ </svg>
32
+ )
33
+ }