@a4ui/core 0.11.1 → 0.12.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 (126) hide show
  1. package/README.md +36 -26
  2. package/dist/full.css +3042 -0
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/package.json +8 -1
  6. package/src/charts/BarChart.tsx +55 -0
  7. package/src/charts/DonutChart.tsx +127 -0
  8. package/src/charts/Sparkline.tsx +101 -0
  9. package/src/charts/index.ts +5 -0
  10. package/src/commerce/CartLine.tsx +84 -0
  11. package/src/commerce/CartSummary.tsx +70 -0
  12. package/src/commerce/FilterGroup.tsx +70 -0
  13. package/src/commerce/PriceTag.tsx +62 -0
  14. package/src/commerce/ProductCard.tsx +121 -0
  15. package/src/commerce/ProductGrid.tsx +27 -0
  16. package/src/commerce/QuantityStepper.tsx +59 -0
  17. package/src/commerce/index.ts +10 -0
  18. package/src/elements.tsx +167 -0
  19. package/src/index.ts +166 -0
  20. package/src/layout/AppShell.tsx +102 -0
  21. package/src/layout/ChristmasBackground.tsx +177 -0
  22. package/src/layout/EffectsToggle.tsx +41 -0
  23. package/src/layout/NavGroup.tsx +45 -0
  24. package/src/layout/SnowScenery.tsx +76 -0
  25. package/src/layout/SpaceBackground.tsx +459 -0
  26. package/src/layout/ThemeToggle.tsx +40 -0
  27. package/src/layout/ThemedScenery.tsx +179 -0
  28. package/src/layout/sceneEffects.ts +49 -0
  29. package/src/lib/cn.ts +17 -0
  30. package/src/lib/effects.ts +71 -0
  31. package/src/lib/media.ts +27 -0
  32. package/src/lib/motion.ts +191 -0
  33. package/src/lib/theme.ts +113 -0
  34. package/src/lib/virtual.ts +33 -0
  35. package/src/styles/space.css +510 -0
  36. package/src/styles/tokens.css +213 -0
  37. package/src/themes/index.ts +102 -0
  38. package/src/themes/palettes.ts +368 -0
  39. package/src/ui/Accordion.tsx +65 -0
  40. package/src/ui/Affix.tsx +72 -0
  41. package/src/ui/Alert.tsx +55 -0
  42. package/src/ui/AlertDialog.tsx +58 -0
  43. package/src/ui/Anchor.tsx +100 -0
  44. package/src/ui/Avatar.tsx +34 -0
  45. package/src/ui/AvatarGroup.tsx +55 -0
  46. package/src/ui/BackToTop.tsx +51 -0
  47. package/src/ui/Badge.tsx +46 -0
  48. package/src/ui/BottomNavigation.tsx +65 -0
  49. package/src/ui/Breadcrumb.tsx +64 -0
  50. package/src/ui/Button.tsx +49 -0
  51. package/src/ui/Calendar.tsx +40 -0
  52. package/src/ui/CalendarHeatmap.tsx +180 -0
  53. package/src/ui/Card.tsx +84 -0
  54. package/src/ui/Carousel.tsx +121 -0
  55. package/src/ui/Cascader.tsx +158 -0
  56. package/src/ui/Checkbox.tsx +35 -0
  57. package/src/ui/Clock.tsx +221 -0
  58. package/src/ui/Collapse.tsx +48 -0
  59. package/src/ui/ColorPicker.tsx +89 -0
  60. package/src/ui/Combobox.tsx +67 -0
  61. package/src/ui/Command.tsx +158 -0
  62. package/src/ui/Comment.tsx +91 -0
  63. package/src/ui/ContextMenu.tsx +58 -0
  64. package/src/ui/Countdown.tsx +123 -0
  65. package/src/ui/DataGrid.tsx +177 -0
  66. package/src/ui/DateField.tsx +177 -0
  67. package/src/ui/DateRangePicker.tsx +204 -0
  68. package/src/ui/DateTimeField.tsx +68 -0
  69. package/src/ui/Descriptions.tsx +56 -0
  70. package/src/ui/Drawer.tsx +72 -0
  71. package/src/ui/Dropdown.tsx +71 -0
  72. package/src/ui/Dropzone.tsx +88 -0
  73. package/src/ui/Empty.tsx +53 -0
  74. package/src/ui/FileUpload.tsx +198 -0
  75. package/src/ui/FloatingActionButton.tsx +48 -0
  76. package/src/ui/Form.tsx +108 -0
  77. package/src/ui/Highlight.tsx +53 -0
  78. package/src/ui/HoverCard.tsx +42 -0
  79. package/src/ui/Image.tsx +66 -0
  80. package/src/ui/Input.tsx +38 -0
  81. package/src/ui/Kbd.tsx +25 -0
  82. package/src/ui/List.tsx +71 -0
  83. package/src/ui/Marquee.tsx +48 -0
  84. package/src/ui/Mentions.tsx +170 -0
  85. package/src/ui/Meter.tsx +57 -0
  86. package/src/ui/Modal.tsx +106 -0
  87. package/src/ui/MultiSelect.tsx +236 -0
  88. package/src/ui/NotificationCenter.tsx +103 -0
  89. package/src/ui/NumberInput.tsx +48 -0
  90. package/src/ui/PageHeader.tsx +60 -0
  91. package/src/ui/Pagination.tsx +62 -0
  92. package/src/ui/Popover.tsx +42 -0
  93. package/src/ui/Portal.tsx +34 -0
  94. package/src/ui/Progress.tsx +55 -0
  95. package/src/ui/RadioGroup.tsx +65 -0
  96. package/src/ui/Rating.tsx +98 -0
  97. package/src/ui/Result.tsx +75 -0
  98. package/src/ui/RingProgress.tsx +75 -0
  99. package/src/ui/SegmentedControl.tsx +61 -0
  100. package/src/ui/Select.tsx +55 -0
  101. package/src/ui/Separator.tsx +39 -0
  102. package/src/ui/Skeleton.tsx +23 -0
  103. package/src/ui/Slider.tsx +57 -0
  104. package/src/ui/Sortable.tsx +174 -0
  105. package/src/ui/SpeedDial.tsx +75 -0
  106. package/src/ui/Spinner.tsx +33 -0
  107. package/src/ui/Splitter.tsx +115 -0
  108. package/src/ui/Stat.tsx +74 -0
  109. package/src/ui/Stepper.tsx +134 -0
  110. package/src/ui/Switch.tsx +44 -0
  111. package/src/ui/Table.tsx +128 -0
  112. package/src/ui/Tabs.tsx +72 -0
  113. package/src/ui/TagInput.tsx +85 -0
  114. package/src/ui/Textarea.tsx +38 -0
  115. package/src/ui/TimeField.tsx +298 -0
  116. package/src/ui/Timeline.tsx +89 -0
  117. package/src/ui/Toast.tsx +68 -0
  118. package/src/ui/Toggle.tsx +41 -0
  119. package/src/ui/ToggleGroup.tsx +59 -0
  120. package/src/ui/Tooltip.tsx +42 -0
  121. package/src/ui/Tour.tsx +197 -0
  122. package/src/ui/Transfer.tsx +112 -0
  123. package/src/ui/Tree.tsx +100 -0
  124. package/src/ui/TreeSelect.tsx +151 -0
  125. package/src/ui/VirtualList.tsx +81 -0
  126. package/src/ui/internal/CalendarCore.tsx +276 -0
@@ -0,0 +1,48 @@
1
+ // Infinite horizontal scroller that loops its children seamlessly.
2
+ import type { JSX, ParentProps } from 'solid-js'
3
+
4
+ import { cn } from '../lib/cn'
5
+ import { motionReduced } from '../lib/motion'
6
+
7
+ interface MarqueeProps extends ParentProps {
8
+ /** Seconds for one full loop of the track. Lower is faster. Defaults to `20`. */
9
+ speed?: number
10
+ /** Pause the scroll while the pointer hovers the strip. Defaults to `true`. */
11
+ pauseOnHover?: boolean
12
+ class?: string
13
+ }
14
+
15
+ /**
16
+ * A horizontally scrolling strip that loops its children seamlessly. The
17
+ * children are rendered twice inside a flex track; when the first copy has
18
+ * scrolled fully out of view the second copy sits exactly where the first
19
+ * began, so the animation restarts with no visible seam. Honors reduced
20
+ * motion (the track sits still) and, by default, pauses on hover.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * <Marquee speed={30}>
25
+ * <span class="mx-4">A4ui</span>
26
+ * <span class="mx-4">SolidJS</span>
27
+ * <span class="mx-4">Tailwind</span>
28
+ * </Marquee>
29
+ * ```
30
+ */
31
+ export function Marquee(props: MarqueeProps): JSX.Element {
32
+ const pauseOnHover = () => props.pauseOnHover ?? true
33
+
34
+ return (
35
+ <div class={cn('overflow-hidden', props.class)}>
36
+ <style>{'@keyframes marquee{from{transform:translateX(0)}to{transform:translateX(-50%)}}'}</style>
37
+ <div
38
+ class={cn('flex w-max shrink-0', pauseOnHover() && 'hover:[animation-play-state:paused]')}
39
+ style={motionReduced() ? undefined : { animation: `marquee ${props.speed ?? 20}s linear infinite` }}
40
+ >
41
+ <div class="flex shrink-0">{props.children}</div>
42
+ <div class="flex shrink-0" aria-hidden="true">
43
+ {props.children}
44
+ </div>
45
+ </div>
46
+ </div>
47
+ )
48
+ }
@@ -0,0 +1,170 @@
1
+ // Multi-line text field with @mention autocomplete over a native <textarea>.
2
+ import type { JSX } from 'solid-js'
3
+ import { For, Show, createSignal } from 'solid-js'
4
+
5
+ import { cn } from '../lib/cn'
6
+
7
+ export interface MentionOption {
8
+ value: string
9
+ label: string
10
+ }
11
+
12
+ export interface MentionsProps {
13
+ value: string
14
+ /** Called with the new string value on every input (not the raw DOM event). */
15
+ onChange: (value: string) => void
16
+ /** Options offered while typing an `@mention`. */
17
+ options: MentionOption[]
18
+ placeholder?: string
19
+ class?: string
20
+ }
21
+
22
+ const TEXTAREA_BASE =
23
+ 'w-full min-h-[80px] resize-y rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground px-3 py-2 text-sm outline-none transition-colors a4-field disabled:cursor-not-allowed disabled:opacity-50'
24
+
25
+ /** Match an active `@query` immediately before the caret. */
26
+ const MENTION_RE = /@(\w*)$/
27
+
28
+ /**
29
+ * Controlled multi-line text field that shows an autocomplete dropdown while
30
+ * the user types an `@mention`. On each input, the text before the caret is
31
+ * checked for a trailing `@query`; matching {@link MentionOption}s (by `label`
32
+ * or `value`, case-insensitive) are offered in a panel. Selecting one replaces
33
+ * the `@query` with `@{label} `. Arrow keys move the highlight, Enter accepts,
34
+ * Escape dismisses.
35
+ *
36
+ * @example
37
+ * ```tsx
38
+ * const [body, setBody] = createSignal('')
39
+ * <Mentions
40
+ * value={body()}
41
+ * onChange={setBody}
42
+ * options={[
43
+ * { value: 'ada', label: 'Ada Lovelace' },
44
+ * { value: 'alan', label: 'Alan Turing' },
45
+ * ]}
46
+ * placeholder="Write a note, use @ to mention someone"
47
+ * />
48
+ * ```
49
+ */
50
+ export function Mentions(props: MentionsProps): JSX.Element {
51
+ let ref: HTMLTextAreaElement | undefined
52
+ const [open, setOpen] = createSignal(false)
53
+ const [query, setQuery] = createSignal('')
54
+ const [caret, setCaret] = createSignal(0)
55
+ const [highlighted, setHighlighted] = createSignal(0)
56
+
57
+ const filtered = (): MentionOption[] => {
58
+ const q = query().toLowerCase()
59
+ return props.options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
60
+ }
61
+
62
+ const close = () => {
63
+ setOpen(false)
64
+ setQuery('')
65
+ }
66
+
67
+ const sync = (el: HTMLTextAreaElement) => {
68
+ const pos = el.selectionStart ?? el.value.length
69
+ setCaret(pos)
70
+ const before = el.value.slice(0, pos)
71
+ const match = before.match(MENTION_RE)
72
+ if (match) {
73
+ setQuery(match[1])
74
+ setOpen(true)
75
+ setHighlighted(0)
76
+ } else {
77
+ close()
78
+ }
79
+ }
80
+
81
+ const select = (option: MentionOption) => {
82
+ const el = ref
83
+ const full = props.value
84
+ const pos = el ? (el.selectionStart ?? caret()) : caret()
85
+ const before = full.slice(0, pos)
86
+ const after = full.slice(pos)
87
+ const start = before.lastIndexOf('@')
88
+ if (start === -1) {
89
+ close()
90
+ return
91
+ }
92
+ const insert = `@${option.label} `
93
+ const next = full.slice(0, start) + insert + after
94
+ props.onChange(next)
95
+ close()
96
+ if (el) {
97
+ const nextCaret = start + insert.length
98
+ queueMicrotask(() => {
99
+ el.focus()
100
+ el.setSelectionRange(nextCaret, nextCaret)
101
+ setCaret(nextCaret)
102
+ })
103
+ }
104
+ }
105
+
106
+ const onKeyDown = (ev: KeyboardEvent) => {
107
+ if (!open()) return
108
+ const items = filtered()
109
+ if (items.length === 0) return
110
+ switch (ev.key) {
111
+ case 'ArrowDown':
112
+ ev.preventDefault()
113
+ setHighlighted((i) => (i + 1) % items.length)
114
+ break
115
+ case 'ArrowUp':
116
+ ev.preventDefault()
117
+ setHighlighted((i) => (i - 1 + items.length) % items.length)
118
+ break
119
+ case 'Enter':
120
+ ev.preventDefault()
121
+ select(items[Math.min(highlighted(), items.length - 1)])
122
+ break
123
+ case 'Escape':
124
+ ev.preventDefault()
125
+ close()
126
+ break
127
+ }
128
+ }
129
+
130
+ return (
131
+ <div class={cn('relative', props.class)}>
132
+ <textarea
133
+ ref={ref}
134
+ class={TEXTAREA_BASE}
135
+ value={props.value}
136
+ placeholder={props.placeholder}
137
+ onInput={(ev) => {
138
+ props.onChange(ev.currentTarget.value)
139
+ sync(ev.currentTarget)
140
+ }}
141
+ onClick={(ev) => sync(ev.currentTarget)}
142
+ onKeyUp={(ev) => sync(ev.currentTarget)}
143
+ onKeyDown={onKeyDown}
144
+ onBlur={() => close()}
145
+ />
146
+ <Show when={open() && filtered().length > 0}>
147
+ <div class="absolute z-50 mt-1 max-h-48 w-56 overflow-y-auto rounded-md border border-border bg-card p-1 shadow-md">
148
+ <For each={filtered()}>
149
+ {(option, i) => (
150
+ <button
151
+ type="button"
152
+ class={cn(
153
+ 'w-full rounded px-2 py-1.5 text-left text-sm hover:bg-muted',
154
+ i() === highlighted() && 'bg-muted',
155
+ )}
156
+ onMouseDown={(ev) => {
157
+ ev.preventDefault()
158
+ select(option)
159
+ }}
160
+ onMouseEnter={() => setHighlighted(i())}
161
+ >
162
+ {option.label}
163
+ </button>
164
+ )}
165
+ </For>
166
+ </div>
167
+ </Show>
168
+ </div>
169
+ )
170
+ }
@@ -0,0 +1,57 @@
1
+ // Static measurement gauge on Kobalte's Meter primitive (e.g. disk usage).
2
+ import { Meter as KMeter } from '@kobalte/core/meter'
3
+ import type { JSX } from 'solid-js'
4
+ import { Show } from 'solid-js'
5
+
6
+ import { cn } from '../lib/cn'
7
+
8
+ interface MeterProps {
9
+ value: number
10
+ /** @default 100 */
11
+ max?: number
12
+ /** Label shown above the track, paired with an auto-formatted value label. */
13
+ label?: string
14
+ class?: string
15
+ }
16
+
17
+ /**
18
+ * Static measurement gauge (e.g. disk/quota usage) on Kobalte's `Meter` primitive.
19
+ * Unlike a progress bar, a meter represents a fixed measurement against a range,
20
+ * not an in-flight operation.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * <Meter value={72} max={100} label="Storage used" />
25
+ * ```
26
+ */
27
+ export function Meter(props: MeterProps): JSX.Element {
28
+ // Kobalte's Meter.Fill has no intrinsic width — we set it ourselves from the
29
+ // value as a fraction of the range, clamped to 0–100%. (Without this the bar
30
+ // ignored `max`, so e.g. value 38 of max 50 read "76%" but filled only ~38%.)
31
+ const percent = () => {
32
+ const max = props.max ?? 100
33
+ if (max <= 0) return 0
34
+ return Math.max(0, Math.min(100, (props.value / max) * 100))
35
+ }
36
+ return (
37
+ <KMeter
38
+ value={props.value}
39
+ minValue={0}
40
+ maxValue={props.max ?? 100}
41
+ class={cn('flex flex-col gap-1.5', props.class)}
42
+ >
43
+ <Show when={props.label}>
44
+ <div class="flex items-center justify-between text-sm text-foreground">
45
+ <KMeter.Label>{props.label}</KMeter.Label>
46
+ <KMeter.ValueLabel class="text-muted-foreground" />
47
+ </div>
48
+ </Show>
49
+ <KMeter.Track class="h-2 overflow-hidden rounded-full bg-muted">
50
+ <KMeter.Fill
51
+ class="h-full rounded-full bg-primary transition-all duration-500"
52
+ style={{ width: `${percent()}%` }}
53
+ />
54
+ </KMeter.Track>
55
+ </KMeter>
56
+ )
57
+ }
@@ -0,0 +1,106 @@
1
+ // Dialog on Kobalte's Dialog primitive. Two shapes via `variant`:
2
+ // - 'drawer' (default): right-anchored slide-over — create/edit forms, detail
3
+ // panels (more vertical room, less jarring than a centered pop).
4
+ // - 'center': the classic centered modal — short confirmations (destructive
5
+ // yes/no) where a full-height drawer would be overkill.
6
+ // The .drawer-content / .modal-content classes drive the slide/scale keyframes
7
+ // (styles.css) off Kobalte's data-expanded/data-closed presence tracking.
8
+ import { Dialog } from '@kobalte/core/dialog'
9
+ import { X } from 'lucide-solid'
10
+ import type { JSX, ParentProps } from 'solid-js'
11
+ import { Show } from 'solid-js'
12
+
13
+ import { cn } from '../lib/cn'
14
+
15
+ interface ModalProps extends ParentProps {
16
+ open: boolean
17
+ onOpenChange: (open: boolean) => void
18
+ /** Header title; omitting it hides the whole header bar. */
19
+ title?: string
20
+ /**
21
+ * `'drawer'` (default): right-anchored slide-over, more room, less jarring —
22
+ * use for forms/detail panels. `'center'`: classic centered modal — use for
23
+ * short confirmations (e.g. destructive yes/no).
24
+ * @default 'drawer'
25
+ */
26
+ variant?: 'drawer' | 'center'
27
+ class?: string
28
+ /** Accessible label for the close button. @default 'Close' */
29
+ closeLabel?: string
30
+ }
31
+
32
+ /**
33
+ * Dialog on Kobalte's `Dialog` primitive (focus trap, portal, presence handled
34
+ * for you), rendered as either a slide-over drawer or a centered modal depending
35
+ * on `variant`.
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * const [open, setOpen] = createSignal(false)
40
+ * <Modal open={open()} onOpenChange={setOpen} variant="center" title="Delete item?">
41
+ * <p>This action cannot be undone.</p>
42
+ * </Modal>
43
+ * ```
44
+ */
45
+ export function Modal(props: ModalProps): JSX.Element {
46
+ const isCenter = () => props.variant === 'center'
47
+ return (
48
+ <Dialog open={props.open} onOpenChange={props.onOpenChange}>
49
+ <Dialog.Portal>
50
+ <Dialog.Overlay class="modal-overlay fixed inset-0 z-40 bg-background/80 backdrop-blur-sm" />
51
+ <Show
52
+ when={isCenter()}
53
+ fallback={
54
+ <div class="fixed inset-0 z-50 flex justify-end">
55
+ <Dialog.Content
56
+ class={cn(
57
+ 'drawer-content bg-glass flex h-full w-full max-w-[520px] flex-col overflow-y-auto border-l border-border text-card-foreground shadow-2xl',
58
+ props.class,
59
+ )}
60
+ >
61
+ <Show when={props.title}>
62
+ <div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/85 px-6 py-4 backdrop-blur">
63
+ <Dialog.Title class="text-lg font-semibold leading-none tracking-tight">
64
+ {props.title}
65
+ </Dialog.Title>
66
+ <Dialog.CloseButton
67
+ class="grid h-8 w-8 place-items-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
68
+ aria-label={props.closeLabel ?? 'Close'}
69
+ >
70
+ <X class="h-5 w-5" />
71
+ </Dialog.CloseButton>
72
+ </div>
73
+ </Show>
74
+ <div class="p-6 pt-4">{props.children}</div>
75
+ </Dialog.Content>
76
+ </div>
77
+ }
78
+ >
79
+ <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
80
+ <Dialog.Content
81
+ class={cn(
82
+ 'modal-content w-full max-w-lg rounded-xl border border-border bg-card text-card-foreground shadow-sm',
83
+ props.class,
84
+ )}
85
+ >
86
+ <Show when={props.title}>
87
+ <div class="flex items-center justify-between border-b border-border p-6 pb-4">
88
+ <Dialog.Title class="text-lg font-semibold leading-none tracking-tight">
89
+ {props.title}
90
+ </Dialog.Title>
91
+ <Dialog.CloseButton
92
+ class="text-muted-foreground hover:text-foreground"
93
+ aria-label={props.closeLabel ?? 'Close'}
94
+ >
95
+
96
+ </Dialog.CloseButton>
97
+ </div>
98
+ </Show>
99
+ <div class="p-6 pt-4">{props.children}</div>
100
+ </Dialog.Content>
101
+ </div>
102
+ </Show>
103
+ </Dialog.Portal>
104
+ </Dialog>
105
+ )
106
+ }
@@ -0,0 +1,236 @@
1
+ // Multi-select dropdown — searchable options with removable chips on the
2
+ // trigger. Popover mechanics (portaled, fixed-position, outside-click/Escape/
3
+ // scroll/resize close) are copied verbatim from DateField.tsx: no Kobalte
4
+ // primitive covers a chip-trigger multi-select, so this is hand-rolled in
5
+ // plain Solid + theme tokens.
6
+ import { Check, ChevronDown, X } from 'lucide-solid'
7
+ import type { JSX } from 'solid-js'
8
+ import { For, Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js'
9
+ import { Portal } from 'solid-js/web'
10
+
11
+ import { cn } from '../lib/cn'
12
+
13
+ /** One selectable option: a stable `value` and its display `label`. */
14
+ export interface MultiSelectOption {
15
+ value: string
16
+ label: string
17
+ }
18
+
19
+ export interface MultiSelectProps {
20
+ options: MultiSelectOption[]
21
+ value: string[]
22
+ onChange: (value: string[]) => void
23
+ placeholder?: string
24
+ /** Show a search box in the dropdown. Default `true`. */
25
+ searchable?: boolean
26
+ disabled?: boolean
27
+ class?: string
28
+ }
29
+
30
+ /**
31
+ * Multi-select dropdown: the trigger shows selected options as removable
32
+ * chips, and the portaled popover lists (optionally search-filtered) options
33
+ * to toggle. Picking an option keeps the popover open so several values can
34
+ * be chosen in a row; the popover closes on outside click, Escape, scroll, or
35
+ * resize.
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * const [tags, setTags] = createSignal<string[]>([])
40
+ * <MultiSelect
41
+ * options={[{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }]}
42
+ * value={tags()}
43
+ * onChange={setTags}
44
+ * placeholder="Select tags…"
45
+ * />
46
+ * ```
47
+ */
48
+ export function MultiSelect(props: MultiSelectProps): JSX.Element {
49
+ const [open, setOpen] = createSignal(false)
50
+ const [query, setQuery] = createSignal('')
51
+
52
+ let rootRef: HTMLDivElement | undefined
53
+ let btnRef: HTMLButtonElement | undefined
54
+ let popRef: HTMLDivElement | undefined
55
+ let searchRef: HTMLInputElement | undefined
56
+
57
+ // Portaled popovers escape the trigger's stacking context (e.g. a glass
58
+ // card's backdrop-filter), so they can't be `absolute`-positioned relative
59
+ // to it — place them with `fixed` at the trigger's measured viewport rect,
60
+ // same as DateField.
61
+ const [pos, setPos] = createSignal({ top: 0, left: 0, width: 224 })
62
+ const MIN_W = 224 // min-w-56
63
+
64
+ const place = () => {
65
+ if (!btnRef) return
66
+ const r = btnRef.getBoundingClientRect()
67
+ const width = Math.max(r.width, MIN_W)
68
+ const left = Math.max(8, Math.min(r.left, window.innerWidth - width - 8))
69
+ setPos({ top: r.bottom + 4, left, width })
70
+ }
71
+
72
+ const openPopover = () => {
73
+ if (props.disabled) return
74
+ place()
75
+ setOpen(true)
76
+ setQuery('')
77
+ }
78
+
79
+ const close = () => setOpen(false)
80
+
81
+ const onDocPointer = (ev: MouseEvent) => {
82
+ const t = ev.target as Node
83
+ // Ignore clicks on the trigger (rootRef) AND the portaled popover (popRef).
84
+ if (rootRef && !rootRef.contains(t) && popRef && !popRef.contains(t)) close()
85
+ }
86
+ const onKey = (ev: KeyboardEvent) => {
87
+ if (ev.key === 'Escape') close()
88
+ }
89
+
90
+ // Wire/tear-down the global listeners strictly around the open state. Scroll/
91
+ // resize close the popover (its fixed position would otherwise drift).
92
+ const toggleListeners = (isOpen: boolean) => {
93
+ if (isOpen) {
94
+ document.addEventListener('mousedown', onDocPointer)
95
+ document.addEventListener('keydown', onKey)
96
+ window.addEventListener('scroll', close, true)
97
+ window.addEventListener('resize', close)
98
+ } else {
99
+ document.removeEventListener('mousedown', onDocPointer)
100
+ document.removeEventListener('keydown', onKey)
101
+ window.removeEventListener('scroll', close, true)
102
+ window.removeEventListener('resize', close)
103
+ }
104
+ }
105
+ // Reactively attach when open flips; always clean up on unmount.
106
+ createEffect(() => toggleListeners(open()))
107
+ onCleanup(() => toggleListeners(false))
108
+
109
+ // Autofocus the search box once the popover mounts.
110
+ createEffect(() => {
111
+ if (open() && (props.searchable ?? true)) {
112
+ searchRef?.focus()
113
+ }
114
+ })
115
+
116
+ const selectedOptions = createMemo(() => props.options.filter((o) => props.value.includes(o.value)))
117
+
118
+ const filteredOptions = createMemo(() => {
119
+ const q = query().trim().toLowerCase()
120
+ if (!q) return props.options
121
+ return props.options.filter((o) => o.label.toLowerCase().includes(q))
122
+ })
123
+
124
+ const toggleValue = (value: string) => {
125
+ const next = props.value.includes(value)
126
+ ? props.value.filter((v) => v !== value)
127
+ : [...props.value, value]
128
+ props.onChange(next)
129
+ }
130
+
131
+ const removeValue = (ev: MouseEvent, value: string) => {
132
+ ev.stopPropagation()
133
+ props.onChange(props.value.filter((v) => v !== value))
134
+ }
135
+
136
+ return (
137
+ <div ref={rootRef} class={cn('relative', props.class)}>
138
+ <button
139
+ ref={btnRef}
140
+ type="button"
141
+ disabled={props.disabled}
142
+ aria-haspopup="listbox"
143
+ aria-expanded={open()}
144
+ onClick={() => (open() ? close() : openPopover())}
145
+ class="flex min-h-9 h-auto w-full flex-wrap items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-left text-sm text-foreground outline-none transition-colors a4-field disabled:cursor-not-allowed disabled:opacity-50"
146
+ >
147
+ <span class="flex flex-1 flex-wrap items-center gap-1">
148
+ <Show
149
+ when={selectedOptions().length > 0}
150
+ fallback={<span class="text-muted-foreground">{props.placeholder ?? 'Select…'}</span>}
151
+ >
152
+ <For each={selectedOptions()}>
153
+ {(opt) => (
154
+ <span class="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-xs text-foreground">
155
+ {opt.label}
156
+ <button
157
+ type="button"
158
+ aria-label={`Remove ${opt.label}`}
159
+ onClick={(ev) => removeValue(ev, opt.value)}
160
+ class="rounded-sm text-muted-foreground outline-none transition-colors hover:text-foreground focus:ring-2 focus:ring-ring/30"
161
+ >
162
+ <X class="h-3 w-3" />
163
+ </button>
164
+ </span>
165
+ )}
166
+ </For>
167
+ </Show>
168
+ </span>
169
+ <ChevronDown class="h-4 w-4 shrink-0 text-muted-foreground" />
170
+ </button>
171
+
172
+ <Show when={open()}>
173
+ <Portal>
174
+ <div
175
+ ref={popRef}
176
+ style={{
177
+ position: 'fixed',
178
+ top: `${pos().top}px`,
179
+ left: `${pos().left}px`,
180
+ width: `${pos().width}px`,
181
+ }}
182
+ class="z-50 min-w-56 rounded-lg border border-border bg-card p-1 text-card-foreground shadow-lg"
183
+ >
184
+ <Show when={props.searchable ?? true}>
185
+ <input
186
+ ref={searchRef}
187
+ type="text"
188
+ value={query()}
189
+ onInput={(ev) => setQuery(ev.currentTarget.value)}
190
+ placeholder="Search…"
191
+ class="mb-1 w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm text-foreground outline-none a4-field"
192
+ />
193
+ </Show>
194
+ <ul role="listbox" aria-multiselectable="true" class="max-h-60 overflow-y-auto">
195
+ <For
196
+ each={filteredOptions()}
197
+ fallback={<li class="px-2 py-1.5 text-sm text-muted-foreground">No options</li>}
198
+ >
199
+ {(opt) => {
200
+ const isSelected = () => props.value.includes(opt.value)
201
+ return (
202
+ <li role="none">
203
+ <button
204
+ type="button"
205
+ role="option"
206
+ aria-selected={isSelected()}
207
+ aria-pressed={isSelected()}
208
+ onClick={() => toggleValue(opt.value)}
209
+ class={cn(
210
+ 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-muted focus:bg-muted',
211
+ isSelected() && 'bg-primary/10',
212
+ )}
213
+ >
214
+ <span
215
+ class={cn(
216
+ 'flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border border-input',
217
+ isSelected() && 'border-primary bg-primary text-primary-foreground',
218
+ )}
219
+ >
220
+ <Show when={isSelected()}>
221
+ <Check class="h-3 w-3" />
222
+ </Show>
223
+ </span>
224
+ <span class="truncate">{opt.label}</span>
225
+ </button>
226
+ </li>
227
+ )
228
+ }}
229
+ </For>
230
+ </ul>
231
+ </div>
232
+ </Portal>
233
+ </Show>
234
+ </div>
235
+ )
236
+ }