@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,66 @@
1
+ // Content image with an optional click-to-zoom lightbox, built on Kobalte's
2
+ // Dialog primitive. The thumbnail lazy-loads; when `preview` is on it becomes a
3
+ // button that opens a dimmed, backdrop-blurred overlay with an enlarged copy of
4
+ // the image and a corner close button (focus trap/portal handled by Kobalte).
5
+ import { Dialog } from '@kobalte/core/dialog'
6
+ import { X } from 'lucide-solid'
7
+ import type { JSX } from 'solid-js'
8
+ import { createSignal, Show } from 'solid-js'
9
+
10
+ import { cn } from '../lib/cn'
11
+
12
+ export interface ImageProps {
13
+ src: string
14
+ alt: string
15
+ class?: string
16
+ /** When true, clicking the image opens a zoomable lightbox. @default true */
17
+ preview?: boolean
18
+ }
19
+
20
+ /**
21
+ * Lazy-loaded content image. Unless `preview` is disabled, the thumbnail is a
22
+ * zoom-in button that opens a centered lightbox (dimmed overlay + close button)
23
+ * on Kobalte's `Dialog` primitive.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * <Image src="/photo.jpg" alt="A scenic view" />
28
+ * <Image src="/logo.svg" alt="Logo" preview={false} />
29
+ * ```
30
+ */
31
+ export function Image(props: ImageProps): JSX.Element {
32
+ const [open, setOpen] = createSignal(false)
33
+ const preview = () => props.preview !== false
34
+
35
+ const img = (
36
+ <img src={props.src} alt={props.alt} loading="lazy" class={cn('rounded-lg object-cover', props.class)} />
37
+ )
38
+
39
+ return (
40
+ <Show when={preview()} fallback={img}>
41
+ <button type="button" class="cursor-zoom-in" aria-label={props.alt} onClick={() => setOpen(true)}>
42
+ {img}
43
+ </button>
44
+ <Dialog open={open()} onOpenChange={setOpen}>
45
+ <Dialog.Portal>
46
+ <Dialog.Overlay class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm" />
47
+ <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
48
+ <Dialog.Content role="dialog" class="relative">
49
+ <img
50
+ src={props.src}
51
+ alt={props.alt}
52
+ class="max-h-[85vh] max-w-[90vw] rounded-lg object-contain"
53
+ />
54
+ <Dialog.CloseButton
55
+ class="absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg bg-background/70 text-foreground transition hover:bg-muted"
56
+ aria-label="Close"
57
+ >
58
+ <X class="h-5 w-5" />
59
+ </Dialog.CloseButton>
60
+ </Dialog.Content>
61
+ </div>
62
+ </Dialog.Portal>
63
+ </Dialog>
64
+ </Show>
65
+ )
66
+ }
@@ -0,0 +1,38 @@
1
+ // `disabled` is wired onto the <input> element (not just styled).
2
+ import type { JSX } from 'solid-js'
3
+ import { splitProps } from 'solid-js'
4
+
5
+ import { cn } from '../lib/cn'
6
+
7
+ const INPUT_BASE =
8
+ 'w-full 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'
9
+
10
+ interface InputProps extends Omit<JSX.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onInput'> {
11
+ value: string
12
+ /** Called with the raw string value on every input event (controlled). */
13
+ onInput: (value: string) => void
14
+ class?: string
15
+ }
16
+
17
+ /**
18
+ * Themed text input with a value/onInput controlled-string API (instead of raw
19
+ * DOM events). All other native `<input>` attributes pass through, so `type`,
20
+ * `disabled`, `placeholder`, etc. work as usual.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * const [name, setName] = createSignal('')
25
+ * <Input value={name()} onInput={setName} placeholder="Full name" />
26
+ * ```
27
+ */
28
+ export function Input(props: InputProps): JSX.Element {
29
+ const [local, rest] = splitProps(props, ['value', 'onInput', 'class'])
30
+ return (
31
+ <input
32
+ class={cn(INPUT_BASE, local.class)}
33
+ value={local.value}
34
+ onInput={(ev) => local.onInput(ev.currentTarget.value)}
35
+ {...rest}
36
+ />
37
+ )
38
+ }
package/src/ui/Kbd.tsx ADDED
@@ -0,0 +1,25 @@
1
+ // Kbd — keyboard key indicator. Renders a single keycap for documenting
2
+ // shortcuts; compose several side by side for chords (e.g. ⌘ + K). Purely
3
+ // presentational and theme-agnostic — colors come from semantic tokens.
4
+ import type { JSX, ParentProps } from 'solid-js'
5
+
6
+ import { cn } from '../lib/cn'
7
+
8
+ interface KbdProps extends ParentProps {
9
+ class?: string
10
+ }
11
+
12
+ const KBD_BASE =
13
+ 'inline-flex min-w-[1.5rem] items-center justify-center rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground shadow-sm'
14
+
15
+ /**
16
+ * Keycap-styled indicator for a keyboard key or shortcut token.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * <Kbd>⌘</Kbd><Kbd>K</Kbd>
21
+ * ```
22
+ */
23
+ export function Kbd(props: KbdProps): JSX.Element {
24
+ return <kbd class={cn(KBD_BASE, props.class)}>{props.children}</kbd>
25
+ }
@@ -0,0 +1,71 @@
1
+ // Vertical list primitive (plain HTML + Tailwind, no headless dep). Renders a
2
+ // semantic <ul> of rows, each with an optional leading avatar, a title +
3
+ // optional description column, and an optional trailing meta/actions slot.
4
+ import { For, Show, type JSX } from 'solid-js'
5
+
6
+ import { cn } from '../lib/cn'
7
+
8
+ /** A single row in a {@link List}. */
9
+ export interface ListItem {
10
+ title: string
11
+ description?: string
12
+ avatar?: JSX.Element
13
+ meta?: JSX.Element
14
+ actions?: JSX.Element
15
+ }
16
+
17
+ interface ListProps {
18
+ items: ListItem[]
19
+ class?: string
20
+ }
21
+
22
+ export type { ListProps }
23
+
24
+ /**
25
+ * Vertical list of rows inside a rounded, bordered container with divider
26
+ * lines between items. Each item shows an optional leading `avatar`, a
27
+ * `title` with optional `description`, and an optional trailing `meta` /
28
+ * `actions` slot.
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * <List
33
+ * items={[
34
+ * {
35
+ * title: 'Alfredo Rivera',
36
+ * description: 'Owner',
37
+ * avatar: <Avatar name="Alfredo" />,
38
+ * meta: <span>2h ago</span>,
39
+ * actions: <Button size="sm">Edit</Button>,
40
+ * },
41
+ * ]}
42
+ * />
43
+ * ```
44
+ */
45
+ export function List(props: ListProps): JSX.Element {
46
+ return (
47
+ <ul class={cn('divide-y divide-border rounded-lg border border-border', props.class)}>
48
+ <For each={props.items}>
49
+ {(item) => (
50
+ <li class="flex items-center gap-3 px-4 py-3">
51
+ <Show when={item.avatar}>
52
+ <div class="shrink-0">{item.avatar}</div>
53
+ </Show>
54
+ <div class="min-w-0 flex-1">
55
+ <p class="text-sm font-medium text-foreground">{item.title}</p>
56
+ <Show when={item.description}>
57
+ <p class="text-xs text-muted-foreground">{item.description}</p>
58
+ </Show>
59
+ </div>
60
+ <Show when={item.meta}>
61
+ <div class="text-xs text-muted-foreground">{item.meta}</div>
62
+ </Show>
63
+ <Show when={item.actions}>
64
+ <div class="shrink-0">{item.actions}</div>
65
+ </Show>
66
+ </li>
67
+ )}
68
+ </For>
69
+ </ul>
70
+ )
71
+ }
@@ -0,0 +1,75 @@
1
+ // Three dots bouncing in a staggered wave — a lightweight indeterminate
2
+ // loading indicator. The bounce is pure CSS (keyframes injected once into the
3
+ // document head) so there's no per-frame JS cost; reduced motion swaps the
4
+ // bounce for a gentle opacity pulse.
5
+ import type { JSX } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+ import { motionReduced } from '../lib/motion'
9
+
10
+ export interface LoadingDotsProps {
11
+ /** Dot diameter in px. @default 8 */
12
+ size?: number
13
+ /** Optional accessible label. @default 'Loading' */
14
+ label?: string
15
+ class?: string
16
+ }
17
+
18
+ const STYLE_ID = 'a4ui-loading-dots-style'
19
+
20
+ function ensureStyleInjected(): void {
21
+ if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return
22
+ const style = document.createElement('style')
23
+ style.id = STYLE_ID
24
+ style.textContent = `
25
+ @keyframes a4-dot-bounce {
26
+ 0%, 100% { transform: translateY(0); }
27
+ 50% { transform: translateY(-60%); }
28
+ }
29
+ @keyframes a4-dot-pulse {
30
+ 0%, 100% { opacity: 1; }
31
+ 50% { opacity: 0.35; }
32
+ }
33
+ `
34
+ document.head.appendChild(style)
35
+ }
36
+
37
+ /**
38
+ * Three dots bouncing in a staggered wave — an inline, indeterminate loading
39
+ * indicator. Dots use `bg-current`, so color/size follow the ancestor's text
40
+ * color; diameter is set via `size`. Falls back to a gentle opacity pulse
41
+ * under reduced motion.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * <LoadingDots label="Loading results" class="text-primary" />
46
+ * ```
47
+ */
48
+ export function LoadingDots(props: LoadingDotsProps): JSX.Element {
49
+ ensureStyleInjected()
50
+
51
+ const size = () => props.size ?? 8
52
+ const animationName = () => (motionReduced() ? 'a4-dot-pulse' : 'a4-dot-bounce')
53
+
54
+ const dotStyle = (delayMs: number): JSX.CSSProperties => ({
55
+ width: `${size()}px`,
56
+ height: `${size()}px`,
57
+ 'animation-name': animationName(),
58
+ 'animation-duration': '0.6s',
59
+ 'animation-iteration-count': 'infinite',
60
+ 'animation-timing-function': 'ease-in-out',
61
+ 'animation-delay': `${delayMs}ms`,
62
+ })
63
+
64
+ return (
65
+ <span
66
+ role="status"
67
+ aria-label={props.label ?? 'Loading'}
68
+ class={cn('inline-flex items-center gap-1 text-current', props.class)}
69
+ >
70
+ <span class="rounded-full bg-current" style={dotStyle(0)} />
71
+ <span class="rounded-full bg-current" style={dotStyle(120)} />
72
+ <span class="rounded-full bg-current" style={dotStyle(240)} />
73
+ </span>
74
+ )
75
+ }
@@ -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
+ }