@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,103 @@
|
|
|
1
|
+
// Bell-triggered notification panel built on A4ui's Popover primitive.
|
|
2
|
+
import { Bell, X } from 'lucide-solid'
|
|
3
|
+
import { For, type JSX, Show } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn'
|
|
6
|
+
import { Popover } from './Popover'
|
|
7
|
+
|
|
8
|
+
/** A single entry rendered by {@link NotificationCenter}. */
|
|
9
|
+
export interface NotificationItem {
|
|
10
|
+
id: string
|
|
11
|
+
title: string
|
|
12
|
+
description?: string
|
|
13
|
+
time?: string
|
|
14
|
+
read?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface NotificationCenterProps {
|
|
18
|
+
/** Notifications to display, newest first. */
|
|
19
|
+
items: NotificationItem[]
|
|
20
|
+
/** Called with an item's id when its dismiss button is clicked. */
|
|
21
|
+
onDismiss?: (id: string) => void
|
|
22
|
+
/** Called when the "Mark all read" action is clicked. */
|
|
23
|
+
onMarkAllRead?: () => void
|
|
24
|
+
class?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Bell button that opens a popover listing notifications, with an unread count
|
|
29
|
+
* badge, per-item dismiss, and a "mark all read" action. Built on {@link Popover}.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```tsx
|
|
33
|
+
* <NotificationCenter
|
|
34
|
+
* items={items}
|
|
35
|
+
* onDismiss={(id) => remove(id)}
|
|
36
|
+
* onMarkAllRead={() => markAll()}
|
|
37
|
+
* />
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function NotificationCenter(props: NotificationCenterProps): JSX.Element {
|
|
41
|
+
const unread = () => props.items.filter((item) => !item.read).length
|
|
42
|
+
|
|
43
|
+
const trigger = (
|
|
44
|
+
<span class={cn('relative inline-grid place-items-center rounded-md p-2 text-foreground', props.class)}>
|
|
45
|
+
<Bell class="h-5 w-5" />
|
|
46
|
+
<Show when={unread() > 0}>
|
|
47
|
+
<span class="absolute -right-1 -top-1 grid h-4 min-w-4 place-items-center rounded-full bg-primary px-1 text-[10px] text-primary-foreground">
|
|
48
|
+
{unread()}
|
|
49
|
+
</span>
|
|
50
|
+
</Show>
|
|
51
|
+
</span>
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Popover trigger={trigger} class="w-80 p-0">
|
|
56
|
+
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
|
57
|
+
<span class="text-sm font-semibold text-foreground">Notifications</span>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
class="text-xs font-medium text-primary hover:underline"
|
|
61
|
+
onClick={() => props.onMarkAllRead?.()}
|
|
62
|
+
>
|
|
63
|
+
Mark all read
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
<Show
|
|
67
|
+
when={props.items.length > 0}
|
|
68
|
+
fallback={<p class="px-4 py-8 text-center text-sm text-muted-foreground">You're all caught up</p>}
|
|
69
|
+
>
|
|
70
|
+
<ul class="max-h-96 divide-y divide-border overflow-y-auto">
|
|
71
|
+
<For each={props.items}>
|
|
72
|
+
{(item) => (
|
|
73
|
+
<li class="flex items-start gap-2 px-4 py-3">
|
|
74
|
+
<div class="min-w-0 flex-1">
|
|
75
|
+
<p class="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
|
76
|
+
<Show when={!item.read}>
|
|
77
|
+
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
|
|
78
|
+
</Show>
|
|
79
|
+
<span class="truncate">{item.title}</span>
|
|
80
|
+
</p>
|
|
81
|
+
<Show when={item.description}>
|
|
82
|
+
<p class="mt-0.5 text-xs text-muted-foreground">{item.description}</p>
|
|
83
|
+
</Show>
|
|
84
|
+
<Show when={item.time}>
|
|
85
|
+
<p class="mt-1 text-[11px] text-muted-foreground">{item.time}</p>
|
|
86
|
+
</Show>
|
|
87
|
+
</div>
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
aria-label="Dismiss"
|
|
91
|
+
class="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
92
|
+
onClick={() => props.onDismiss?.(item.id)}
|
|
93
|
+
>
|
|
94
|
+
<X class="h-4 w-4" />
|
|
95
|
+
</button>
|
|
96
|
+
</li>
|
|
97
|
+
)}
|
|
98
|
+
</For>
|
|
99
|
+
</ul>
|
|
100
|
+
</Show>
|
|
101
|
+
</Popover>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Numeric field with −/+ steppers on Kobalte's NumberField primitive.
|
|
2
|
+
import { NumberField as KNumberField } from '@kobalte/core/number-field'
|
|
3
|
+
import type { JSX } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn'
|
|
6
|
+
|
|
7
|
+
interface NumberInputProps {
|
|
8
|
+
value: number
|
|
9
|
+
onChange: (value: number) => void
|
|
10
|
+
/** Lower bound; the decrement trigger disables itself at this value. */
|
|
11
|
+
min?: number
|
|
12
|
+
/** Upper bound; the increment trigger disables itself at this value. */
|
|
13
|
+
max?: number
|
|
14
|
+
class?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const TRIGGER_CLASS =
|
|
18
|
+
'grid h-9 w-9 shrink-0 place-items-center border border-input bg-background text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Numeric field with −/+ stepper buttons, built on Kobalte's `NumberField` primitive.
|
|
22
|
+
* Use for bounded numeric input (quantities, counts) where clicking to nudge the
|
|
23
|
+
* value is preferable to typing alone.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* <NumberInput value={qty()} onChange={setQty} min={0} max={99} />
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function NumberInput(props: NumberInputProps): JSX.Element {
|
|
31
|
+
return (
|
|
32
|
+
<KNumberField
|
|
33
|
+
rawValue={props.value}
|
|
34
|
+
onRawValueChange={(v) => props.onChange(v)}
|
|
35
|
+
minValue={props.min}
|
|
36
|
+
maxValue={props.max}
|
|
37
|
+
class={cn('inline-flex items-stretch', props.class)}
|
|
38
|
+
>
|
|
39
|
+
<KNumberField.DecrementTrigger class={cn(TRIGGER_CLASS, 'rounded-l-md')} aria-label="Decrement">
|
|
40
|
+
−
|
|
41
|
+
</KNumberField.DecrementTrigger>
|
|
42
|
+
<KNumberField.Input class="w-16 border-y border-input bg-background px-3 py-2 text-center text-sm text-foreground outline-none transition-colors a4-field" />
|
|
43
|
+
<KNumberField.IncrementTrigger class={cn(TRIGGER_CLASS, 'rounded-r-md')} aria-label="Increment">
|
|
44
|
+
+
|
|
45
|
+
</KNumberField.IncrementTrigger>
|
|
46
|
+
</KNumberField>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Consistent page header: optional breadcrumb (last crumb highlighted), title,
|
|
2
|
+
// subtitle, and a right-aligned actions slot.
|
|
3
|
+
import { For, Show, type JSX } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
interface PageHeaderProps {
|
|
6
|
+
title: string
|
|
7
|
+
subtitle?: string
|
|
8
|
+
/** Trail of crumb labels, in order; the last one is rendered highlighted as the current page. */
|
|
9
|
+
breadcrumb?: string[]
|
|
10
|
+
/** Right-aligned slot for page-level actions (buttons, menus, etc.). */
|
|
11
|
+
actions?: JSX.Element
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Consistent top-of-page header: optional breadcrumb trail, title, subtitle,
|
|
16
|
+
* and a right-aligned actions slot. Use at the top of a route/page for a
|
|
17
|
+
* uniform layout across the app.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```tsx
|
|
21
|
+
* <PageHeader
|
|
22
|
+
* title="Invoices"
|
|
23
|
+
* subtitle="Manage billing documents"
|
|
24
|
+
* breadcrumb={['Home', 'Billing', 'Invoices']}
|
|
25
|
+
* actions={<Button>New invoice</Button>}
|
|
26
|
+
* />
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function PageHeader(props: PageHeaderProps): JSX.Element {
|
|
30
|
+
return (
|
|
31
|
+
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
|
32
|
+
<div>
|
|
33
|
+
<Show when={props.breadcrumb?.length}>
|
|
34
|
+
<div class="flex items-center gap-2 text-[12px] text-muted-foreground">
|
|
35
|
+
<For each={props.breadcrumb}>
|
|
36
|
+
{(crumb, i) => (
|
|
37
|
+
<>
|
|
38
|
+
<Show when={i() > 0}>
|
|
39
|
+
{/* Same "/" separator as the standalone Breadcrumb component. */}
|
|
40
|
+
<span class="text-muted-foreground/60">/</span>
|
|
41
|
+
</Show>
|
|
42
|
+
<span classList={{ 'text-foreground': i() === (props.breadcrumb?.length ?? 0) - 1 }}>
|
|
43
|
+
{crumb}
|
|
44
|
+
</span>
|
|
45
|
+
</>
|
|
46
|
+
)}
|
|
47
|
+
</For>
|
|
48
|
+
</div>
|
|
49
|
+
</Show>
|
|
50
|
+
<h1 class="mt-1 text-2xl font-bold tracking-tight">{props.title}</h1>
|
|
51
|
+
<Show when={props.subtitle}>
|
|
52
|
+
<p class="mt-0.5 text-[13px] text-muted-foreground">{props.subtitle}</p>
|
|
53
|
+
</Show>
|
|
54
|
+
</div>
|
|
55
|
+
<Show when={props.actions}>
|
|
56
|
+
<div class="flex items-center gap-2">{props.actions}</div>
|
|
57
|
+
</Show>
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Prev/next pager with a "Page X of Y" label and an optional left-aligned
|
|
2
|
+
// summary slot (e.g. "1,234 registros · 1–50"). Client-side; the parent owns
|
|
3
|
+
// the page signal and slices its own data.
|
|
4
|
+
import { ChevronLeft, ChevronRight } from 'lucide-solid'
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
interface PaginationProps {
|
|
8
|
+
/** Current page, 1-indexed. */
|
|
9
|
+
page: number
|
|
10
|
+
totalPages: number
|
|
11
|
+
onChange: (page: number) => void
|
|
12
|
+
/** Left-aligned slot, e.g. a row-count summary like "1,234 registros · 1–50". */
|
|
13
|
+
summary?: JSX.Element
|
|
14
|
+
/** Override default English labels/text for i18n. */
|
|
15
|
+
labels?: {
|
|
16
|
+
previous?: string
|
|
17
|
+
next?: string
|
|
18
|
+
/** Formats the center label; default is `` `Page ${page} of ${total}` ``. */
|
|
19
|
+
page?: (page: number, total: number) => string
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Prev/next pager with a "Page X of Y" label and an optional summary slot.
|
|
25
|
+
* Purely client-side and controlled — the parent owns the `page` signal and
|
|
26
|
+
* is responsible for slicing its own data to match.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```tsx
|
|
30
|
+
* <Pagination page={page()} totalPages={totalPages()} onChange={setPage} />
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function Pagination(props: PaginationProps): JSX.Element {
|
|
34
|
+
return (
|
|
35
|
+
<div class="flex flex-col gap-3 border-t border-border p-3 sm:flex-row sm:items-center sm:justify-between">
|
|
36
|
+
<div class="text-[12px] text-muted-foreground">{props.summary}</div>
|
|
37
|
+
<div class="flex items-center gap-1">
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
class="grid h-8 w-8 place-items-center rounded-lg border border-input transition hover:bg-muted disabled:pointer-events-none disabled:opacity-40"
|
|
41
|
+
disabled={props.page <= 1}
|
|
42
|
+
aria-label={props.labels?.previous ?? 'Previous page'}
|
|
43
|
+
onClick={() => props.onChange(props.page - 1)}
|
|
44
|
+
>
|
|
45
|
+
<ChevronLeft class="h-4 w-4" />
|
|
46
|
+
</button>
|
|
47
|
+
<span class="px-2 font-mono text-[12px] tabular-nums">
|
|
48
|
+
{(props.labels?.page ?? ((p, t) => `Page ${p} of ${t}`))(props.page, props.totalPages)}
|
|
49
|
+
</span>
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
class="grid h-8 w-8 place-items-center rounded-lg border border-input transition hover:bg-muted disabled:pointer-events-none disabled:opacity-40"
|
|
53
|
+
disabled={props.page >= props.totalPages}
|
|
54
|
+
aria-label={props.labels?.next ?? 'Next page'}
|
|
55
|
+
onClick={() => props.onChange(props.page + 1)}
|
|
56
|
+
>
|
|
57
|
+
<ChevronRight class="h-4 w-4" />
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Click-triggered floating panel on Kobalte's Popover primitive.
|
|
2
|
+
import { Popover as KPopover } from '@kobalte/core/popover'
|
|
3
|
+
import type { JSX, ParentProps } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn'
|
|
6
|
+
|
|
7
|
+
interface PopoverProps extends ParentProps {
|
|
8
|
+
/** Element that opens the popover on click; wrapped in an inline-flex trigger. */
|
|
9
|
+
trigger: JSX.Element
|
|
10
|
+
class?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Click-triggered floating panel, built on Kobalte's `Popover` primitive
|
|
15
|
+
* (handles positioning, focus, dismiss, and portaling). Use for contextual
|
|
16
|
+
* content anchored to a trigger element, such as a filter panel or menu.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* <Popover trigger={<Button>Filters</Button>}>
|
|
21
|
+
* <p>Panel content</p>
|
|
22
|
+
* </Popover>
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export function Popover(props: PopoverProps): JSX.Element {
|
|
26
|
+
return (
|
|
27
|
+
<KPopover>
|
|
28
|
+
<KPopover.Trigger class="inline-flex">{props.trigger}</KPopover.Trigger>
|
|
29
|
+
<KPopover.Portal>
|
|
30
|
+
<KPopover.Content
|
|
31
|
+
class={cn(
|
|
32
|
+
'bg-glass z-50 rounded-lg border border-border p-4 text-card-foreground shadow-lg',
|
|
33
|
+
props.class,
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
<KPopover.Arrow />
|
|
37
|
+
{props.children}
|
|
38
|
+
</KPopover.Content>
|
|
39
|
+
</KPopover.Portal>
|
|
40
|
+
</KPopover>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Renders children into a detached DOM node (default: <body>), escaping overflow
|
|
2
|
+
// and stacking-context traps. A thin wrapper over Solid's own Portal so it's
|
|
3
|
+
// available alongside the rest of the library.
|
|
4
|
+
import type { JSX } from 'solid-js'
|
|
5
|
+
import { Portal as SolidPortal } from 'solid-js/web'
|
|
6
|
+
|
|
7
|
+
/** Props for {@link Portal}. */
|
|
8
|
+
export interface PortalProps {
|
|
9
|
+
/** Where to mount. Defaults to `document.body`. */
|
|
10
|
+
mount?: Node
|
|
11
|
+
/** Use a Shadow DOM root for the portal container. */
|
|
12
|
+
useShadow?: boolean
|
|
13
|
+
children: JSX.Element
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Renders its children into a different place in the DOM (by default
|
|
18
|
+
* `document.body`), so overlays aren't clipped by an ancestor's `overflow` or
|
|
19
|
+
* transformed stacking context. Built on Solid's `Portal`.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```tsx
|
|
23
|
+
* <Portal>
|
|
24
|
+
* <div class="fixed inset-0 grid place-items-center">Floating layer</div>
|
|
25
|
+
* </Portal>
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function Portal(props: PortalProps): JSX.Element {
|
|
29
|
+
return (
|
|
30
|
+
<SolidPortal mount={props.mount} useShadow={props.useShadow}>
|
|
31
|
+
{props.children}
|
|
32
|
+
</SolidPortal>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Determinate progress bar on Kobalte's Progress primitive.
|
|
2
|
+
import { Progress as KProgress } from '@kobalte/core/progress'
|
|
3
|
+
import type { JSX } from 'solid-js'
|
|
4
|
+
import { Show } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
|
|
8
|
+
interface ProgressProps {
|
|
9
|
+
value: number
|
|
10
|
+
/** Upper bound of `value`. Default: 100. */
|
|
11
|
+
max?: number
|
|
12
|
+
/** Label shown above the bar, paired with an auto-generated value label (e.g. "50%"). */
|
|
13
|
+
label?: string
|
|
14
|
+
class?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Determinate progress bar, built on Kobalte's `Progress` primitive. Use to
|
|
19
|
+
* show completion of a known-length task (upload, multi-step form, etc.).
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```tsx
|
|
23
|
+
* <Progress value={uploaded()} max={totalBytes()} label="Uploading" />
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function Progress(props: ProgressProps): JSX.Element {
|
|
27
|
+
// Kobalte's Progress.Fill has no intrinsic width, so a bare block div fills the
|
|
28
|
+
// whole track (every bar reads 100%). Set the width from value/max ourselves.
|
|
29
|
+
const percent = () => {
|
|
30
|
+
const max = props.max ?? 100
|
|
31
|
+
if (max <= 0) return 0
|
|
32
|
+
return Math.max(0, Math.min(100, (props.value / max) * 100))
|
|
33
|
+
}
|
|
34
|
+
return (
|
|
35
|
+
<KProgress
|
|
36
|
+
value={props.value}
|
|
37
|
+
minValue={0}
|
|
38
|
+
maxValue={props.max ?? 100}
|
|
39
|
+
class={cn('flex flex-col gap-1.5', props.class)}
|
|
40
|
+
>
|
|
41
|
+
<Show when={props.label}>
|
|
42
|
+
<div class="flex items-center justify-between text-sm text-foreground">
|
|
43
|
+
<KProgress.Label>{props.label}</KProgress.Label>
|
|
44
|
+
<KProgress.ValueLabel class="text-muted-foreground" />
|
|
45
|
+
</div>
|
|
46
|
+
</Show>
|
|
47
|
+
<KProgress.Track class="h-2 overflow-hidden rounded-full bg-muted">
|
|
48
|
+
<KProgress.Fill
|
|
49
|
+
class="h-full rounded-full bg-primary transition-all duration-500"
|
|
50
|
+
style={{ width: `${percent()}%` }}
|
|
51
|
+
/>
|
|
52
|
+
</KProgress.Track>
|
|
53
|
+
</KProgress>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Accessible single-choice group on Kobalte's RadioGroup primitive.
|
|
2
|
+
import { RadioGroup as KRadioGroup } from '@kobalte/core/radio-group'
|
|
3
|
+
import type { JSX } from 'solid-js'
|
|
4
|
+
import { For, Show } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
|
|
8
|
+
/** A single selectable choice within a {@link RadioGroup}. */
|
|
9
|
+
export interface RadioOption {
|
|
10
|
+
value: string
|
|
11
|
+
label: string
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface RadioGroupProps {
|
|
16
|
+
value: string
|
|
17
|
+
onChange: (value: string) => void
|
|
18
|
+
options: RadioOption[]
|
|
19
|
+
/** Group label rendered above the options. */
|
|
20
|
+
label?: string
|
|
21
|
+
class?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Accessible single-choice group, built on Kobalte's `RadioGroup` primitive
|
|
26
|
+
* (arrow-key navigation, ARIA roles). Use when all options should stay
|
|
27
|
+
* visible at once, unlike {@link Select} which hides options until opened.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <RadioGroup
|
|
32
|
+
* label="Plan"
|
|
33
|
+
* value={plan()}
|
|
34
|
+
* onChange={setPlan}
|
|
35
|
+
* options={[
|
|
36
|
+
* { value: 'free', label: 'Free' },
|
|
37
|
+
* { value: 'pro', label: 'Pro' },
|
|
38
|
+
* ]}
|
|
39
|
+
* />
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export function RadioGroup(props: RadioGroupProps): JSX.Element {
|
|
43
|
+
return (
|
|
44
|
+
<KRadioGroup value={props.value} onChange={props.onChange} class={cn('flex flex-col gap-2', props.class)}>
|
|
45
|
+
<Show when={props.label}>
|
|
46
|
+
<KRadioGroup.Label class="text-sm font-medium text-foreground">{props.label}</KRadioGroup.Label>
|
|
47
|
+
</Show>
|
|
48
|
+
<For each={props.options}>
|
|
49
|
+
{(option) => (
|
|
50
|
+
<KRadioGroup.Item
|
|
51
|
+
value={option.value}
|
|
52
|
+
disabled={option.disabled}
|
|
53
|
+
class="flex items-center gap-2 text-sm text-foreground data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50"
|
|
54
|
+
>
|
|
55
|
+
<KRadioGroup.ItemInput />
|
|
56
|
+
<KRadioGroup.ItemControl class="grid h-4 w-4 place-items-center rounded-full border border-input transition-colors data-[checked]:border-primary">
|
|
57
|
+
<KRadioGroup.ItemIndicator class="h-2 w-2 rounded-full bg-primary" />
|
|
58
|
+
</KRadioGroup.ItemControl>
|
|
59
|
+
<KRadioGroup.ItemLabel>{option.label}</KRadioGroup.ItemLabel>
|
|
60
|
+
</KRadioGroup.Item>
|
|
61
|
+
)}
|
|
62
|
+
</For>
|
|
63
|
+
</KRadioGroup>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Star rating input. Filled/empty stars are driven by the current value (or a
|
|
2
|
+
// transient hover preview); interactive mode renders each star as a keyboard-
|
|
3
|
+
// accessible button, readonly mode renders a plain, labelled row of icons.
|
|
4
|
+
import { Star } from 'lucide-solid'
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
import { createSignal, For, Show } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
export interface RatingProps {
|
|
11
|
+
/** Currently selected rating, from 0 to `max`. */
|
|
12
|
+
value: number
|
|
13
|
+
/** Called with the new rating when a star is clicked or set via keyboard. */
|
|
14
|
+
onChange?: (value: number) => void
|
|
15
|
+
/** Number of stars to render. Default: 5. */
|
|
16
|
+
max?: number
|
|
17
|
+
/** Render a non-interactive, display-only rating. Default: false. */
|
|
18
|
+
readonly?: boolean
|
|
19
|
+
class?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Star rating control. In interactive mode each star is a button: click or use
|
|
24
|
+
* the Left/Right arrow keys to set the rating, and hovering previews it. In
|
|
25
|
+
* `readonly` mode it renders as a static, labelled row of stars.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```tsx
|
|
29
|
+
* <Rating value={rating()} onChange={setRating} max={5} />
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function Rating(props: RatingProps): JSX.Element {
|
|
33
|
+
const [hover, setHover] = createSignal(0)
|
|
34
|
+
const max = () => props.max ?? 5
|
|
35
|
+
const stars = () => Array.from({ length: max() }, (_, i) => i + 1)
|
|
36
|
+
|
|
37
|
+
// The value used to paint stars: hover preview takes precedence when active.
|
|
38
|
+
const active = () => (hover() > 0 ? hover() : props.value)
|
|
39
|
+
|
|
40
|
+
const set = (n: number) => {
|
|
41
|
+
if (props.readonly) return
|
|
42
|
+
props.onChange?.(n)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const starClass = (index: number) =>
|
|
46
|
+
cn(
|
|
47
|
+
'h-5 w-5 transition-colors',
|
|
48
|
+
index <= active() ? 'fill-primary text-primary' : 'fill-transparent text-muted-foreground',
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Show
|
|
53
|
+
when={!props.readonly}
|
|
54
|
+
fallback={
|
|
55
|
+
<div
|
|
56
|
+
class={cn('inline-flex items-center gap-1', props.class)}
|
|
57
|
+
role="img"
|
|
58
|
+
aria-label={`Rating ${props.value} of ${max()}`}
|
|
59
|
+
>
|
|
60
|
+
<For each={stars()}>{(n) => <Star class={starClass(n)} aria-hidden="true" />}</For>
|
|
61
|
+
</div>
|
|
62
|
+
}
|
|
63
|
+
>
|
|
64
|
+
<div
|
|
65
|
+
class={cn('inline-flex items-center gap-1', props.class)}
|
|
66
|
+
role="radiogroup"
|
|
67
|
+
onMouseLeave={() => setHover(0)}
|
|
68
|
+
>
|
|
69
|
+
<For each={stars()}>
|
|
70
|
+
{(n) => (
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
role="radio"
|
|
74
|
+
aria-checked={props.value === n}
|
|
75
|
+
aria-label={`Rate ${n} of ${max()}`}
|
|
76
|
+
class="rounded outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
77
|
+
onMouseEnter={() => setHover(n)}
|
|
78
|
+
onFocus={() => setHover(n)}
|
|
79
|
+
onBlur={() => setHover(0)}
|
|
80
|
+
onClick={() => set(n)}
|
|
81
|
+
onKeyDown={(e) => {
|
|
82
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
|
83
|
+
e.preventDefault()
|
|
84
|
+
set(Math.min(props.value + 1, max()))
|
|
85
|
+
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
|
86
|
+
e.preventDefault()
|
|
87
|
+
set(Math.max(props.value - 1, 0))
|
|
88
|
+
}
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
<Star class={starClass(n)} aria-hidden="true" />
|
|
92
|
+
</button>
|
|
93
|
+
)}
|
|
94
|
+
</For>
|
|
95
|
+
</div>
|
|
96
|
+
</Show>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Full-status result screen: a centered vertical stack for terminal states like
|
|
2
|
+
// success confirmations, errors, or HTTP 404/500 pages. A large status icon (or
|
|
3
|
+
// the big HTTP number) sits in a tinted circle above the title/description; the
|
|
4
|
+
// fixed semantic status colors mirror Alert (emerald/amber/rose) while the rest
|
|
5
|
+
// stays theme-agnostic via semantic tokens.
|
|
6
|
+
import { CircleCheck, CircleX, Compass, Info, ServerCrash, TriangleAlert } from 'lucide-solid'
|
|
7
|
+
import type { Component, JSX } from 'solid-js'
|
|
8
|
+
import { Show } from 'solid-js'
|
|
9
|
+
import { Dynamic } from 'solid-js/web'
|
|
10
|
+
|
|
11
|
+
import { cn } from '../lib/cn'
|
|
12
|
+
|
|
13
|
+
/** Semantic status of a {@link Result}; drives the icon, tint, and optional HTTP number. */
|
|
14
|
+
export type ResultStatus = 'success' | 'error' | 'info' | 'warning' | '404' | '500'
|
|
15
|
+
|
|
16
|
+
export interface ResultProps {
|
|
17
|
+
/** Result status; drives the icon and color. Defaults to `'info'`. */
|
|
18
|
+
status?: ResultStatus
|
|
19
|
+
/** Bold headline describing the outcome. */
|
|
20
|
+
title: string
|
|
21
|
+
/** Optional supporting line under the title. */
|
|
22
|
+
description?: string
|
|
23
|
+
/** Optional action slot (e.g. Buttons) rendered below the text. */
|
|
24
|
+
actions?: JSX.Element
|
|
25
|
+
class?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const STATUS: Record<
|
|
29
|
+
ResultStatus,
|
|
30
|
+
{ Icon: Component<{ class?: string }>; icon: string; tint: string; code?: string }
|
|
31
|
+
> = {
|
|
32
|
+
success: { Icon: CircleCheck, icon: 'text-emerald-500', tint: 'bg-emerald-500/10' },
|
|
33
|
+
error: { Icon: CircleX, icon: 'text-destructive', tint: 'bg-destructive/10' },
|
|
34
|
+
warning: { Icon: TriangleAlert, icon: 'text-amber-500', tint: 'bg-amber-500/10' },
|
|
35
|
+
info: { Icon: Info, icon: 'text-primary', tint: 'bg-primary/10' },
|
|
36
|
+
'404': { Icon: Compass, icon: 'text-muted-foreground', tint: 'bg-muted', code: '404' },
|
|
37
|
+
'500': { Icon: ServerCrash, icon: 'text-destructive', tint: 'bg-destructive/10', code: '500' },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Full-status result screen — a large status icon in a tinted circle, a title,
|
|
42
|
+
* optional description, and an optional actions slot — for terminal states such
|
|
43
|
+
* as success/error confirmations or HTTP 404/500 pages. For `'404'`/`'500'` the
|
|
44
|
+
* big HTTP number is shown above the icon.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```tsx
|
|
48
|
+
* <Result
|
|
49
|
+
* status="success"
|
|
50
|
+
* title="Payment complete"
|
|
51
|
+
* description="A receipt has been sent to your email."
|
|
52
|
+
* actions={<Button>Back to dashboard</Button>}
|
|
53
|
+
* />
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export function Result(props: ResultProps): JSX.Element {
|
|
57
|
+
const status = () => STATUS[props.status ?? 'info']
|
|
58
|
+
return (
|
|
59
|
+
<div class={cn('flex flex-col items-center py-12 text-center', props.class)}>
|
|
60
|
+
<Show when={status().code}>
|
|
61
|
+
<p class="text-5xl font-bold text-muted-foreground">{status().code}</p>
|
|
62
|
+
</Show>
|
|
63
|
+
<div class={cn('grid h-16 w-16 place-items-center rounded-full', status().tint)}>
|
|
64
|
+
<Dynamic component={status().Icon} class={cn('h-8 w-8', status().icon)} />
|
|
65
|
+
</div>
|
|
66
|
+
<p class="mt-4 text-xl font-bold text-foreground">{props.title}</p>
|
|
67
|
+
<Show when={props.description}>
|
|
68
|
+
<p class="mt-2 max-w-md text-muted-foreground">{props.description}</p>
|
|
69
|
+
</Show>
|
|
70
|
+
<Show when={props.actions}>
|
|
71
|
+
<div class="mt-6">{props.actions}</div>
|
|
72
|
+
</Show>
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|