@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.
- package/README.md +36 -26
- package/dist/full.css +3042 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +8 -1
- package/src/charts/BarChart.tsx +55 -0
- package/src/charts/DonutChart.tsx +127 -0
- package/src/charts/Sparkline.tsx +101 -0
- package/src/charts/index.ts +5 -0
- package/src/commerce/CartLine.tsx +84 -0
- package/src/commerce/CartSummary.tsx +70 -0
- package/src/commerce/FilterGroup.tsx +70 -0
- package/src/commerce/PriceTag.tsx +62 -0
- package/src/commerce/ProductCard.tsx +121 -0
- package/src/commerce/ProductGrid.tsx +27 -0
- package/src/commerce/QuantityStepper.tsx +59 -0
- package/src/commerce/index.ts +10 -0
- package/src/elements.tsx +167 -0
- package/src/index.ts +166 -0
- package/src/layout/AppShell.tsx +102 -0
- package/src/layout/ChristmasBackground.tsx +177 -0
- package/src/layout/EffectsToggle.tsx +41 -0
- package/src/layout/NavGroup.tsx +45 -0
- package/src/layout/SnowScenery.tsx +76 -0
- package/src/layout/SpaceBackground.tsx +459 -0
- package/src/layout/ThemeToggle.tsx +40 -0
- package/src/layout/ThemedScenery.tsx +179 -0
- package/src/layout/sceneEffects.ts +49 -0
- package/src/lib/cn.ts +17 -0
- package/src/lib/effects.ts +71 -0
- package/src/lib/media.ts +27 -0
- package/src/lib/motion.ts +191 -0
- package/src/lib/theme.ts +113 -0
- package/src/lib/virtual.ts +33 -0
- package/src/styles/space.css +510 -0
- package/src/styles/tokens.css +213 -0
- package/src/themes/index.ts +102 -0
- package/src/themes/palettes.ts +368 -0
- package/src/ui/Accordion.tsx +65 -0
- package/src/ui/Affix.tsx +72 -0
- package/src/ui/Alert.tsx +55 -0
- package/src/ui/AlertDialog.tsx +58 -0
- package/src/ui/Anchor.tsx +100 -0
- package/src/ui/Avatar.tsx +34 -0
- package/src/ui/AvatarGroup.tsx +55 -0
- package/src/ui/BackToTop.tsx +51 -0
- package/src/ui/Badge.tsx +46 -0
- package/src/ui/BottomNavigation.tsx +65 -0
- package/src/ui/Breadcrumb.tsx +64 -0
- package/src/ui/Button.tsx +49 -0
- package/src/ui/Calendar.tsx +40 -0
- package/src/ui/CalendarHeatmap.tsx +180 -0
- package/src/ui/Card.tsx +84 -0
- package/src/ui/Carousel.tsx +121 -0
- package/src/ui/Cascader.tsx +158 -0
- package/src/ui/Checkbox.tsx +35 -0
- package/src/ui/Clock.tsx +221 -0
- package/src/ui/Collapse.tsx +48 -0
- package/src/ui/ColorPicker.tsx +89 -0
- package/src/ui/Combobox.tsx +67 -0
- package/src/ui/Command.tsx +158 -0
- package/src/ui/Comment.tsx +91 -0
- package/src/ui/ContextMenu.tsx +58 -0
- package/src/ui/Countdown.tsx +123 -0
- package/src/ui/DataGrid.tsx +177 -0
- package/src/ui/DateField.tsx +177 -0
- package/src/ui/DateRangePicker.tsx +204 -0
- package/src/ui/DateTimeField.tsx +68 -0
- package/src/ui/Descriptions.tsx +56 -0
- package/src/ui/Drawer.tsx +72 -0
- package/src/ui/Dropdown.tsx +71 -0
- package/src/ui/Dropzone.tsx +88 -0
- package/src/ui/Empty.tsx +53 -0
- package/src/ui/FileUpload.tsx +198 -0
- package/src/ui/FloatingActionButton.tsx +48 -0
- package/src/ui/Form.tsx +108 -0
- package/src/ui/Highlight.tsx +53 -0
- package/src/ui/HoverCard.tsx +42 -0
- package/src/ui/Image.tsx +66 -0
- package/src/ui/Input.tsx +38 -0
- package/src/ui/Kbd.tsx +25 -0
- package/src/ui/List.tsx +71 -0
- package/src/ui/Marquee.tsx +48 -0
- package/src/ui/Mentions.tsx +170 -0
- package/src/ui/Meter.tsx +57 -0
- package/src/ui/Modal.tsx +106 -0
- package/src/ui/MultiSelect.tsx +236 -0
- package/src/ui/NotificationCenter.tsx +103 -0
- package/src/ui/NumberInput.tsx +48 -0
- package/src/ui/PageHeader.tsx +60 -0
- package/src/ui/Pagination.tsx +62 -0
- package/src/ui/Popover.tsx +42 -0
- package/src/ui/Portal.tsx +34 -0
- package/src/ui/Progress.tsx +55 -0
- package/src/ui/RadioGroup.tsx +65 -0
- package/src/ui/Rating.tsx +98 -0
- package/src/ui/Result.tsx +75 -0
- package/src/ui/RingProgress.tsx +75 -0
- package/src/ui/SegmentedControl.tsx +61 -0
- package/src/ui/Select.tsx +55 -0
- package/src/ui/Separator.tsx +39 -0
- package/src/ui/Skeleton.tsx +23 -0
- package/src/ui/Slider.tsx +57 -0
- package/src/ui/Sortable.tsx +174 -0
- package/src/ui/SpeedDial.tsx +75 -0
- package/src/ui/Spinner.tsx +33 -0
- package/src/ui/Splitter.tsx +115 -0
- package/src/ui/Stat.tsx +74 -0
- package/src/ui/Stepper.tsx +134 -0
- package/src/ui/Switch.tsx +44 -0
- package/src/ui/Table.tsx +128 -0
- package/src/ui/Tabs.tsx +72 -0
- package/src/ui/TagInput.tsx +85 -0
- package/src/ui/Textarea.tsx +38 -0
- package/src/ui/TimeField.tsx +298 -0
- package/src/ui/Timeline.tsx +89 -0
- package/src/ui/Toast.tsx +68 -0
- package/src/ui/Toggle.tsx +41 -0
- package/src/ui/ToggleGroup.tsx +59 -0
- package/src/ui/Tooltip.tsx +42 -0
- package/src/ui/Tour.tsx +197 -0
- package/src/ui/Transfer.tsx +112 -0
- package/src/ui/Tree.tsx +100 -0
- package/src/ui/TreeSelect.tsx +151 -0
- package/src/ui/VirtualList.tsx +81 -0
- 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
|
+
}
|
package/src/ui/Stat.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/ui/Table.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/ui/Tabs.tsx
ADDED
|
@@ -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
|
+
}
|