@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,115 @@
1
+ // Resizable split panes with a draggable, keyboard-operable divider.
2
+ import type { JSX } from 'solid-js'
3
+ import { createSignal } from 'solid-js'
4
+ import { cn } from '../lib/cn'
5
+
6
+ export interface SplitterProps {
7
+ /** Content of the first pane (left when horizontal, top when vertical). */
8
+ start: JSX.Element
9
+ /** Content of the second pane (right when horizontal, bottom when vertical). */
10
+ end: JSX.Element
11
+ /** Layout axis. `'horizontal'` (default) splits side-by-side with a vertical divider; `'vertical'` stacks the panes with a horizontal divider. */
12
+ orientation?: 'horizontal' | 'vertical'
13
+ /** Initial size of the first pane, as a percentage of the container. Defaults to `50`. */
14
+ initial?: number
15
+ /** Minimum size of the first pane, as a percentage. Defaults to `15`. */
16
+ min?: number
17
+ /** Maximum size of the first pane, as a percentage. Defaults to `85`. */
18
+ max?: number
19
+ class?: string
20
+ }
21
+
22
+ /**
23
+ * Two panes separated by a draggable divider that resizes them. Drag the
24
+ * divider with a pointer or focus it and use the Arrow keys to nudge the split.
25
+ * The first pane's size is tracked as a percentage and clamped to `[min, max]`.
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * <Splitter
30
+ * orientation="horizontal"
31
+ * initial={40}
32
+ * start={<div class="p-4">Sidebar</div>}
33
+ * end={<div class="p-4">Main content</div>}
34
+ * />
35
+ * ```
36
+ */
37
+ export function Splitter(props: SplitterProps): JSX.Element {
38
+ const orientation = () => props.orientation ?? 'horizontal'
39
+ const min = () => props.min ?? 15
40
+ const max = () => props.max ?? 85
41
+
42
+ const clamp = (value: number) => Math.min(max(), Math.max(min(), value))
43
+
44
+ const [size, setSize] = createSignal(clamp(props.initial ?? 50))
45
+
46
+ let container: HTMLDivElement | undefined
47
+
48
+ const updateFromPointer = (event: PointerEvent) => {
49
+ if (!container) return
50
+ const rect = container.getBoundingClientRect()
51
+ const pct =
52
+ orientation() === 'horizontal'
53
+ ? ((event.clientX - rect.left) / rect.width) * 100
54
+ : ((event.clientY - rect.top) / rect.height) * 100
55
+ setSize(clamp(pct))
56
+ }
57
+
58
+ const onPointerDown = (event: PointerEvent) => {
59
+ const divider = event.currentTarget as HTMLElement
60
+ divider.setPointerCapture(event.pointerId)
61
+ divider.addEventListener('pointermove', updateFromPointer)
62
+ const release = () => {
63
+ divider.removeEventListener('pointermove', updateFromPointer)
64
+ divider.removeEventListener('pointerup', release)
65
+ divider.removeEventListener('lostpointercapture', release)
66
+ }
67
+ divider.addEventListener('pointerup', release)
68
+ divider.addEventListener('lostpointercapture', release)
69
+ event.preventDefault()
70
+ }
71
+
72
+ const onKeyDown = (event: KeyboardEvent) => {
73
+ const step = 3
74
+ let delta = 0
75
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') delta = -step
76
+ else if (event.key === 'ArrowRight' || event.key === 'ArrowDown') delta = step
77
+ else if (event.key === 'Home') return setSize(min())
78
+ else if (event.key === 'End') return setSize(max())
79
+ else return
80
+ event.preventDefault()
81
+ setSize((s) => clamp(s + delta))
82
+ }
83
+
84
+ return (
85
+ <div
86
+ ref={container}
87
+ class={cn(
88
+ 'flex overflow-hidden',
89
+ orientation() === 'horizontal' ? 'h-64 flex-row' : 'flex-col',
90
+ props.class,
91
+ )}
92
+ >
93
+ <div class="overflow-auto" style={{ 'flex-basis': `${size()}%` }}>
94
+ {props.start}
95
+ </div>
96
+ <div
97
+ role="separator"
98
+ aria-orientation={orientation()}
99
+ aria-valuenow={Math.round(size())}
100
+ aria-valuemin={min()}
101
+ aria-valuemax={max()}
102
+ tabindex={0}
103
+ onPointerDown={onPointerDown}
104
+ onKeyDown={onKeyDown}
105
+ class={cn(
106
+ 'shrink-0 bg-border transition-colors hover:bg-primary/50 focus-visible:bg-primary/50 focus-visible:outline-none',
107
+ orientation() === 'horizontal' ? 'w-1 cursor-col-resize' : 'h-1 cursor-row-resize',
108
+ )}
109
+ />
110
+ <div class="flex-1 overflow-auto" style={{ 'flex-basis': `${100 - size()}%` }}>
111
+ {props.end}
112
+ </div>
113
+ </div>
114
+ )
115
+ }
@@ -0,0 +1,74 @@
1
+ // KPI / stat card with two motion touches: a Motion (motion.dev) fade-up
2
+ // entrance (staggered via `delay`) and a createCountUp tween on the numeric
3
+ // value — both gated on reduced motion. This is the design system's showcase of
4
+ // the motion layer (Motion's `animate` + the count-up helper) in a real component.
5
+ import { Show, onMount, type JSX } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+ import { animateIn, createCountUp } from '../lib/motion'
9
+ import { Card, CardContent } from './Card'
10
+
11
+ /** Color scheme applied to a {@link Stat}'s icon badge. */
12
+ export type StatTone = 'primary' | 'success' | 'danger' | 'neutral'
13
+
14
+ const TONE_CLASSES: Record<StatTone, string> = {
15
+ primary: 'bg-primary/15 text-primary',
16
+ success: 'bg-emerald-500/15 text-emerald-600',
17
+ danger: 'bg-rose-500/15 text-rose-600',
18
+ neutral: 'bg-muted text-muted-foreground',
19
+ }
20
+
21
+ interface StatProps {
22
+ label: string
23
+ /** Numeric value — count-up tweens toward it on mount and on change. */
24
+ value: number
25
+ /** Format the (animating) number for display. Default: rounded integer. */
26
+ format?: (n: number) => string
27
+ icon?: JSX.Element
28
+ tone?: StatTone
29
+ /** Entrance stagger delay in seconds (for a row of stats). */
30
+ delay?: number
31
+ class?: string
32
+ }
33
+
34
+ /**
35
+ * KPI / stat card showing a label, an animated count-up numeric value, and
36
+ * an optional tone-colored icon badge. Combines a `solid-motionone` fade-up
37
+ * entrance (use `delay` to stagger a row of stats) with the `createCountUp`
38
+ * tween — both automatically disabled under `prefers-reduced-motion`.
39
+ *
40
+ * @example
41
+ * ```tsx
42
+ * <Stat label="Active users" value={activeUsers()} tone="success" icon={<UsersIcon />} />
43
+ * ```
44
+ */
45
+ export function Stat(props: StatProps): JSX.Element {
46
+ const count = createCountUp(() => props.value)
47
+ const display = () => (props.format ?? ((n) => String(Math.round(n))))(count())
48
+ let ref: HTMLDivElement | undefined
49
+ onMount(() => {
50
+ if (ref) animateIn(ref, { delay: props.delay ?? 0 })
51
+ })
52
+ return (
53
+ <div ref={ref}>
54
+ <Card glass glow class={props.class}>
55
+ <CardContent class="flex items-center gap-4 p-5">
56
+ <Show when={props.icon}>
57
+ <div
58
+ class={cn(
59
+ 'flex h-11 w-11 shrink-0 items-center justify-center rounded-lg',
60
+ TONE_CLASSES[props.tone ?? 'neutral'],
61
+ )}
62
+ >
63
+ {props.icon}
64
+ </div>
65
+ </Show>
66
+ <div class="min-w-0">
67
+ <p class="truncate text-sm text-muted-foreground">{props.label}</p>
68
+ <p class="truncate text-xl font-semibold tabular-nums text-foreground">{display()}</p>
69
+ </div>
70
+ </CardContent>
71
+ </Card>
72
+ </div>
73
+ )
74
+ }
@@ -0,0 +1,134 @@
1
+ // Multi-step progress indicator: a row (or column) of numbered indicators
2
+ // connected by lines, tracking completed / active / upcoming states. Purely
3
+ // presentational and theme-agnostic — all colors come from semantic tokens.
4
+ import { Check } from 'lucide-solid'
5
+ import type { JSX } from 'solid-js'
6
+ import { For, Show } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+
10
+ /** A single step rendered by {@link Stepper}. */
11
+ export interface StepItem {
12
+ /** Text shown beside the step indicator. */
13
+ label: string
14
+ /** Optional secondary line shown under the label. */
15
+ description?: string
16
+ }
17
+
18
+ export interface StepperProps {
19
+ /** Ordered list of steps to display. */
20
+ steps: StepItem[]
21
+ /** Index of the currently active step; steps before it are completed. */
22
+ active: number
23
+ /** When provided, indicators become clickable buttons reporting their index. */
24
+ onStepClick?: (index: number) => void
25
+ /** Layout direction. Defaults to `'horizontal'`. */
26
+ orientation?: 'horizontal' | 'vertical'
27
+ class?: string
28
+ }
29
+
30
+ /**
31
+ * Multi-step progress indicator. Steps before `active` render as completed
32
+ * (with a check), the `active` step is highlighted with a ring, and later
33
+ * steps are dimmed. Pass `onStepClick` to let users jump between steps.
34
+ *
35
+ * @example
36
+ * ```tsx
37
+ * <Stepper
38
+ * active={1}
39
+ * steps={[
40
+ * { label: 'Account', description: 'Your details' },
41
+ * { label: 'Shipping' },
42
+ * { label: 'Payment' },
43
+ * ]}
44
+ * onStepClick={setActive}
45
+ * />
46
+ * ```
47
+ */
48
+ export function Stepper(props: StepperProps): JSX.Element {
49
+ const orientation = () => props.orientation ?? 'horizontal'
50
+
51
+ return (
52
+ <ol
53
+ aria-label="Progress"
54
+ class={cn('flex', orientation() === 'vertical' ? 'flex-col' : 'items-start', props.class)}
55
+ >
56
+ <For each={props.steps}>
57
+ {(step, index) => {
58
+ const isCompleted = () => index() < props.active
59
+ const isActive = () => index() === props.active
60
+ const isFirst = () => index() === 0
61
+ const isVertical = () => orientation() === 'vertical'
62
+ // The connector sits before this step and is "filled" when we've
63
+ // reached (or passed) it — i.e. for completed and active steps.
64
+ const connectorFilled = () => index() <= props.active
65
+
66
+ const indicatorContent = () => (
67
+ <Show when={isCompleted()} fallback={<span>{index() + 1}</span>}>
68
+ <Check class="h-4 w-4" aria-hidden="true" />
69
+ </Show>
70
+ )
71
+
72
+ const indicatorClass = () =>
73
+ cn(
74
+ 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-medium transition-colors',
75
+ isCompleted() || isActive()
76
+ ? 'bg-primary text-primary-foreground'
77
+ : 'bg-muted text-muted-foreground',
78
+ isActive() && 'ring-2 ring-primary/40',
79
+ )
80
+
81
+ return (
82
+ <li
83
+ aria-current={isActive() ? 'step' : undefined}
84
+ class={cn('flex', isVertical() ? 'flex-col' : 'flex-1 items-start last:flex-none')}
85
+ >
86
+ <div class={cn('flex', isVertical() ? 'flex-row' : 'w-full items-center')}>
87
+ {/* Connector line before this step (skipped for the first). */}
88
+ <Show when={!isFirst()}>
89
+ <span
90
+ aria-hidden="true"
91
+ class={cn(
92
+ connectorFilled() ? 'bg-primary' : 'bg-border',
93
+ isVertical() ? 'ml-4 -mt-1 mb-1 w-0.5 flex-1' : 'mx-2 h-0.5 flex-1',
94
+ )}
95
+ />
96
+ </Show>
97
+
98
+ <div class={cn('flex gap-3', isVertical() ? 'items-start' : 'flex-col items-center')}>
99
+ <Show
100
+ when={props.onStepClick}
101
+ fallback={<div class={indicatorClass()}>{indicatorContent()}</div>}
102
+ >
103
+ <button
104
+ type="button"
105
+ aria-label={step.label}
106
+ class={indicatorClass()}
107
+ onClick={() => props.onStepClick?.(index())}
108
+ >
109
+ {indicatorContent()}
110
+ </button>
111
+ </Show>
112
+
113
+ <div class={cn(isVertical() ? 'pt-1' : 'text-center')}>
114
+ <p
115
+ class={cn(
116
+ 'text-sm font-medium',
117
+ isActive() || isCompleted() ? 'text-foreground' : 'text-muted-foreground',
118
+ )}
119
+ >
120
+ {step.label}
121
+ </p>
122
+ <Show when={step.description}>
123
+ <p class="text-xs text-muted-foreground">{step.description}</p>
124
+ </Show>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </li>
129
+ )
130
+ }}
131
+ </For>
132
+ </ol>
133
+ )
134
+ }
@@ -0,0 +1,44 @@
1
+ // Accessible on/off toggle on Kobalte's Switch primitive.
2
+ import { Switch as KSwitch } from '@kobalte/core/switch'
3
+ import type { JSX } from 'solid-js'
4
+ import { Show } from 'solid-js'
5
+
6
+ import { cn } from '../lib/cn'
7
+
8
+ interface SwitchProps {
9
+ /** Whether the switch is on. Controlled — pair with `onChange`. */
10
+ checked: boolean
11
+ onChange: (checked: boolean) => void
12
+ /** Optional visible label rendered next to the control. */
13
+ label?: string
14
+ disabled?: boolean
15
+ class?: string
16
+ }
17
+
18
+ /**
19
+ * Accessible on/off switch, built on Kobalte's Switch primitive.
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * const [enabled, setEnabled] = createSignal(false)
24
+ * <Switch checked={enabled()} onChange={setEnabled} label="Enable notifications" />
25
+ * ```
26
+ */
27
+ export function Switch(props: SwitchProps): JSX.Element {
28
+ return (
29
+ <KSwitch
30
+ checked={props.checked}
31
+ onChange={props.onChange}
32
+ disabled={props.disabled}
33
+ class={cn('inline-flex items-center gap-2', props.class)}
34
+ >
35
+ <KSwitch.Input />
36
+ <KSwitch.Control class="inline-flex h-5 w-9 shrink-0 items-center rounded-full bg-input px-0.5 transition-colors data-[checked]:bg-primary data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50">
37
+ <KSwitch.Thumb class="h-4 w-4 rounded-full bg-white shadow-sm transition-transform data-[checked]:translate-x-4" />
38
+ </KSwitch.Control>
39
+ <Show when={props.label}>
40
+ <KSwitch.Label class="text-sm text-foreground">{props.label}</KSwitch.Label>
41
+ </Show>
42
+ </KSwitch>
43
+ )
44
+ }
@@ -0,0 +1,128 @@
1
+ // Minimal table primitives (plain HTML + Tailwind, no headless dep). For long
2
+ // lists, virtualize with @tanstack/solid-virtual + the remeasureAfterLayout
3
+ // helper (exported from a4ui) — the parent owns the virtualizer.
4
+ import type { JSX, ParentProps } from 'solid-js'
5
+ import { splitProps } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+
9
+ interface TableProps extends ParentProps {
10
+ class?: string
11
+ }
12
+
13
+ /**
14
+ * Table root — a plain `<table>` wrapped in a horizontal-scroll container.
15
+ * Compose with {@link TableHead}, {@link TableBody}, {@link TableRow},
16
+ * {@link TableHeadCell}, and {@link TableCell}.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * <Table>
21
+ * <TableHead>
22
+ * <TableRow>
23
+ * <TableHeadCell>Name</TableHeadCell>
24
+ * </TableRow>
25
+ * </TableHead>
26
+ * <TableBody>
27
+ * <TableRow>
28
+ * <TableCell>Alfredo</TableCell>
29
+ * </TableRow>
30
+ * </TableBody>
31
+ * </Table>
32
+ * ```
33
+ */
34
+ export function Table(props: TableProps): JSX.Element {
35
+ const [local, rest] = splitProps(props, ['class', 'children'])
36
+ return (
37
+ <div class="w-full overflow-x-auto">
38
+ <table class={cn('w-full border-collapse text-sm', local.class)} {...rest}>
39
+ {local.children}
40
+ </table>
41
+ </div>
42
+ )
43
+ }
44
+
45
+ /**
46
+ * `<thead>` wrapper with the muted, bottom-bordered header styling.
47
+ *
48
+ * @example
49
+ * ```tsx
50
+ * <TableHead><TableRow><TableHeadCell>Name</TableHeadCell></TableRow></TableHead>
51
+ * ```
52
+ */
53
+ export function TableHead(props: TableProps): JSX.Element {
54
+ const [local, rest] = splitProps(props, ['class', 'children'])
55
+ return (
56
+ <thead class={cn('border-b border-border text-left text-muted-foreground', local.class)} {...rest}>
57
+ {local.children}
58
+ </thead>
59
+ )
60
+ }
61
+
62
+ /**
63
+ * `<tbody>` wrapper — no default styling beyond passing through `class`.
64
+ *
65
+ * @example
66
+ * ```tsx
67
+ * <TableBody><TableRow><TableCell>Alfredo</TableCell></TableRow></TableBody>
68
+ * ```
69
+ */
70
+ export function TableBody(props: TableProps): JSX.Element {
71
+ const [local, rest] = splitProps(props, ['class', 'children'])
72
+ return (
73
+ <tbody class={local.class} {...rest}>
74
+ {local.children}
75
+ </tbody>
76
+ )
77
+ }
78
+
79
+ /**
80
+ * `<tr>` wrapper — adds the row bottom-border and hover highlight.
81
+ *
82
+ * @example
83
+ * ```tsx
84
+ * <TableRow><TableCell>Alfredo</TableCell></TableRow>
85
+ * ```
86
+ */
87
+ export function TableRow(props: TableProps): JSX.Element {
88
+ const [local, rest] = splitProps(props, ['class', 'children'])
89
+ return (
90
+ <tr class={cn('border-b border-border last:border-0 hover:bg-muted/40', local.class)} {...rest}>
91
+ {local.children}
92
+ </tr>
93
+ )
94
+ }
95
+
96
+ /**
97
+ * `<th>` wrapper — small uppercase heading style, use inside {@link TableHead}.
98
+ *
99
+ * @example
100
+ * ```tsx
101
+ * <TableHeadCell>Name</TableHeadCell>
102
+ * ```
103
+ */
104
+ export function TableHeadCell(props: TableProps): JSX.Element {
105
+ const [local, rest] = splitProps(props, ['class', 'children'])
106
+ return (
107
+ <th class={cn('px-3 py-2 text-xs font-semibold uppercase tracking-wide', local.class)} {...rest}>
108
+ {local.children}
109
+ </th>
110
+ )
111
+ }
112
+
113
+ /**
114
+ * `<td>` wrapper — standard cell padding and vertical alignment.
115
+ *
116
+ * @example
117
+ * ```tsx
118
+ * <TableCell>Alfredo</TableCell>
119
+ * ```
120
+ */
121
+ export function TableCell(props: TableProps): JSX.Element {
122
+ const [local, rest] = splitProps(props, ['class', 'children'])
123
+ return (
124
+ <td class={cn('px-3 py-2 align-middle', local.class)} {...rest}>
125
+ {local.children}
126
+ </td>
127
+ )
128
+ }
@@ -0,0 +1,72 @@
1
+ // Accessible tabs on Kobalte's Tabs primitive.
2
+ import { Tabs as KTabs } from '@kobalte/core/tabs'
3
+ import type { JSX } from 'solid-js'
4
+ import { For } from 'solid-js'
5
+
6
+ import { cn } from '../lib/cn'
7
+
8
+ /** One tab: its unique `value`, trigger `label`, and panel `content`. */
9
+ export interface TabItem {
10
+ value: string
11
+ label: string
12
+ content: JSX.Element
13
+ }
14
+
15
+ interface TabsProps {
16
+ /** Tabs to render, in order — each contributes a trigger and a panel. */
17
+ items: TabItem[]
18
+ /** Currently selected tab's `value`. Controlled — pair with `onChange`. */
19
+ value: string
20
+ onChange: (value: string) => void
21
+ class?: string
22
+ }
23
+
24
+ /**
25
+ * Accessible tabs, built on Kobalte's Tabs primitive, with a sliding selection
26
+ * indicator.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * const [tab, setTab] = createSignal('profile')
31
+ * <Tabs
32
+ * value={tab()}
33
+ * onChange={setTab}
34
+ * items={[
35
+ * { value: 'profile', label: 'Profile', content: <ProfilePanel /> },
36
+ * { value: 'billing', label: 'Billing', content: <BillingPanel /> },
37
+ * ]}
38
+ * />
39
+ * ```
40
+ */
41
+ export function Tabs(props: TabsProps): JSX.Element {
42
+ return (
43
+ <KTabs value={props.value} onChange={props.onChange} class={props.class}>
44
+ {/* `relative` anchors the absolutely-positioned Indicator to the list's
45
+ bottom border — without it the indicator escapes to the nearest
46
+ positioned ancestor and strands itself at the bottom of the page. */}
47
+ <KTabs.List class="relative flex gap-1 border-b border-border">
48
+ <For each={props.items}>
49
+ {(item) => (
50
+ <KTabs.Trigger
51
+ value={item.value}
52
+ class={cn(
53
+ 'px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground',
54
+ 'data-[selected]:text-foreground',
55
+ )}
56
+ >
57
+ {item.label}
58
+ </KTabs.Trigger>
59
+ )}
60
+ </For>
61
+ <KTabs.Indicator class="absolute bottom-0 h-0.5 bg-primary transition-all" />
62
+ </KTabs.List>
63
+ <For each={props.items}>
64
+ {(item) => (
65
+ <KTabs.Content value={item.value} class="py-4">
66
+ {item.content}
67
+ </KTabs.Content>
68
+ )}
69
+ </For>
70
+ </KTabs>
71
+ )
72
+ }
@@ -0,0 +1,85 @@
1
+ // Controlled tag editor: the tag array is owned by the parent (value/onChange);
2
+ // only the in-progress input text is local state. Enter/comma commit a tag,
3
+ // Backspace on an empty input pops the last one — same keyboard idiom users
4
+ // expect from chip inputs elsewhere.
5
+ import type { JSX } from 'solid-js'
6
+ import { For, createSignal } from 'solid-js'
7
+ import { X } from 'lucide-solid'
8
+
9
+ import { cn } from '../lib/cn'
10
+
11
+ export interface TagInputProps {
12
+ /** Current tags (controlled). */
13
+ value: string[]
14
+ /** Called with the next tag array whenever tags are added or removed. */
15
+ onChange: (tags: string[]) => void
16
+ placeholder?: string
17
+ class?: string
18
+ }
19
+
20
+ /**
21
+ * Bordered field that turns free text into removable chips. Press Enter or comma
22
+ * to add the trimmed input as a tag (empty and duplicate values are ignored),
23
+ * click a chip's `×` to remove it, or press Backspace on an empty input to drop
24
+ * the last tag. Theme-agnostic — colors come from semantic tokens.
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * const [tags, setTags] = createSignal<string[]>(['solid', 'ui'])
29
+ * <TagInput value={tags()} onChange={setTags} placeholder="Add tag…" />
30
+ * ```
31
+ */
32
+ export function TagInput(props: TagInputProps): JSX.Element {
33
+ const [text, setText] = createSignal('')
34
+
35
+ const addTag = () => {
36
+ const tag = text().trim()
37
+ if (tag && !props.value.includes(tag)) props.onChange([...props.value, tag])
38
+ setText('')
39
+ }
40
+
41
+ const removeTag = (tag: string) => {
42
+ props.onChange(props.value.filter((t) => t !== tag))
43
+ }
44
+
45
+ const onKeyDown = (ev: KeyboardEvent) => {
46
+ if (ev.key === 'Enter' || ev.key === ',') {
47
+ ev.preventDefault()
48
+ addTag()
49
+ } else if (ev.key === 'Backspace' && text() === '' && props.value.length > 0) {
50
+ removeTag(props.value[props.value.length - 1])
51
+ }
52
+ }
53
+
54
+ return (
55
+ <div
56
+ class={cn(
57
+ 'flex flex-wrap items-center gap-1 rounded-md border border-input bg-background px-2 py-1.5',
58
+ props.class,
59
+ )}
60
+ >
61
+ <For each={props.value}>
62
+ {(tag) => (
63
+ <span class="inline-flex items-center gap-1 rounded bg-muted px-2 py-0.5 text-xs text-foreground">
64
+ {tag}
65
+ <button
66
+ type="button"
67
+ aria-label={`Remove ${tag}`}
68
+ onClick={() => removeTag(tag)}
69
+ class="inline-flex items-center text-muted-foreground transition-colors hover:text-foreground"
70
+ >
71
+ <X class="size-3" />
72
+ </button>
73
+ </span>
74
+ )}
75
+ </For>
76
+ <input
77
+ class="flex-1 border-0 bg-transparent px-1 text-sm text-foreground outline-none placeholder:text-muted-foreground"
78
+ value={text()}
79
+ placeholder={props.placeholder}
80
+ onInput={(ev) => setText(ev.currentTarget.value)}
81
+ onKeyDown={onKeyDown}
82
+ />
83
+ </div>
84
+ )
85
+ }
@@ -0,0 +1,38 @@
1
+ // Controlled multi-line text field — plain <textarea>, mirrors Input.tsx.
2
+ import type { JSX } from 'solid-js'
3
+ import { splitProps } from 'solid-js'
4
+
5
+ import { cn } from '../lib/cn'
6
+
7
+ const TEXTAREA_BASE =
8
+ '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'
9
+
10
+ interface TextareaProps extends Omit<JSX.TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onInput'> {
11
+ value: string
12
+ /** Called with the new string value on every input event (not the raw DOM event). */
13
+ onInput: (value: string) => void
14
+ class?: string
15
+ }
16
+
17
+ /**
18
+ * Controlled multi-line text field — a plain `<textarea>` styled to match
19
+ * {@link Input}. Any other native textarea attribute (e.g. `rows`, `placeholder`,
20
+ * `disabled`) is passed through.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * const [bio, setBio] = createSignal('')
25
+ * <Textarea value={bio()} onInput={setBio} placeholder="Tell us about yourself" />
26
+ * ```
27
+ */
28
+ export function Textarea(props: TextareaProps): JSX.Element {
29
+ const [local, rest] = splitProps(props, ['value', 'onInput', 'class'])
30
+ return (
31
+ <textarea
32
+ class={cn(TEXTAREA_BASE, local.class)}
33
+ value={local.value}
34
+ onInput={(ev) => local.onInput(ev.currentTarget.value)}
35
+ {...rest}
36
+ />
37
+ )
38
+ }