@c9up/nebula 0.1.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/LICENSE +21 -0
- package/README.md +256 -0
- package/dist/adapters/css.d.ts +33 -0
- package/dist/adapters/css.js +67 -0
- package/dist/adapters/index.d.ts +16 -0
- package/dist/adapters/index.js +27 -0
- package/dist/adapters/tailwind.d.ts +25 -0
- package/dist/adapters/tailwind.js +122 -0
- package/dist/adapters/types.d.ts +49 -0
- package/dist/adapters/types.js +21 -0
- package/dist/adapters/unocss.d.ts +17 -0
- package/dist/adapters/unocss.js +107 -0
- package/dist/atoms/AspectRatio.d.ts +17 -0
- package/dist/atoms/AspectRatio.js +19 -0
- package/dist/atoms/Avatar.d.ts +23 -0
- package/dist/atoms/Avatar.js +46 -0
- package/dist/atoms/Badge.d.ts +25 -0
- package/dist/atoms/Badge.js +28 -0
- package/dist/atoms/Button.d.ts +58 -0
- package/dist/atoms/Button.js +67 -0
- package/dist/atoms/Checkbox.d.ts +41 -0
- package/dist/atoms/Checkbox.js +47 -0
- package/dist/atoms/Input.d.ts +36 -0
- package/dist/atoms/Input.js +50 -0
- package/dist/atoms/Kbd.d.ts +14 -0
- package/dist/atoms/Kbd.js +17 -0
- package/dist/atoms/Label.d.ts +24 -0
- package/dist/atoms/Label.js +26 -0
- package/dist/atoms/Marker.d.ts +42 -0
- package/dist/atoms/Marker.js +55 -0
- package/dist/atoms/NativeSelect.d.ts +43 -0
- package/dist/atoms/NativeSelect.js +81 -0
- package/dist/atoms/Progress.d.ts +21 -0
- package/dist/atoms/Progress.js +48 -0
- package/dist/atoms/ScrollArea.d.ts +28 -0
- package/dist/atoms/ScrollArea.js +37 -0
- package/dist/atoms/Separator.d.ts +16 -0
- package/dist/atoms/Separator.js +21 -0
- package/dist/atoms/Skeleton.d.ts +13 -0
- package/dist/atoms/Skeleton.js +18 -0
- package/dist/atoms/Slider.d.ts +30 -0
- package/dist/atoms/Slider.js +40 -0
- package/dist/atoms/Spinner.d.ts +17 -0
- package/dist/atoms/Spinner.js +20 -0
- package/dist/atoms/Switch.d.ts +24 -0
- package/dist/atoms/Switch.js +41 -0
- package/dist/atoms/Textarea.d.ts +28 -0
- package/dist/atoms/Textarea.js +36 -0
- package/dist/atoms/Toggle.d.ts +38 -0
- package/dist/atoms/Toggle.js +45 -0
- package/dist/atoms/index.d.ts +32 -0
- package/dist/atoms/index.js +32 -0
- package/dist/cli/add.d.ts +44 -0
- package/dist/cli/add.js +99 -0
- package/dist/cli/index.d.ts +10 -0
- package/dist/cli/index.js +10 -0
- package/dist/cli/registry.d.ts +31 -0
- package/dist/cli/registry.js +83 -0
- package/dist/config.d.ts +81 -0
- package/dist/config.js +43 -0
- package/dist/configure.d.ts +34 -0
- package/dist/configure.js +85 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +41 -0
- package/dist/lib/children.d.ts +28 -0
- package/dist/lib/children.js +27 -0
- package/dist/lib/cn.d.ts +13 -0
- package/dist/lib/cn.js +13 -0
- package/dist/lib/cva.d.ts +83 -0
- package/dist/lib/cva.js +178 -0
- package/dist/lib/icons.d.ts +44 -0
- package/dist/lib/icons.js +79 -0
- package/dist/lib/id.d.ts +31 -0
- package/dist/lib/id.js +41 -0
- package/dist/lib/index.d.ts +12 -0
- package/dist/lib/index.js +12 -0
- package/dist/lib/motion.d.ts +31 -0
- package/dist/lib/motion.js +39 -0
- package/dist/lib/props.d.ts +48 -0
- package/dist/lib/props.js +59 -0
- package/dist/lib/styled.d.ts +31 -0
- package/dist/lib/styled.js +39 -0
- package/dist/molecules/Accordion.d.ts +35 -0
- package/dist/molecules/Accordion.js +117 -0
- package/dist/molecules/Alert.d.ts +30 -0
- package/dist/molecules/Alert.js +35 -0
- package/dist/molecules/Attachment.d.ts +39 -0
- package/dist/molecules/Attachment.js +103 -0
- package/dist/molecules/Breadcrumb.d.ts +29 -0
- package/dist/molecules/Breadcrumb.js +73 -0
- package/dist/molecules/Bubble.d.ts +56 -0
- package/dist/molecules/Bubble.js +92 -0
- package/dist/molecules/ButtonGroup.d.ts +23 -0
- package/dist/molecules/ButtonGroup.js +30 -0
- package/dist/molecules/Card.d.ts +17 -0
- package/dist/molecules/Card.js +17 -0
- package/dist/molecules/Collapsible.d.ts +40 -0
- package/dist/molecules/Collapsible.js +71 -0
- package/dist/molecules/Empty.d.ts +16 -0
- package/dist/molecules/Empty.js +16 -0
- package/dist/molecules/Field.d.ts +54 -0
- package/dist/molecules/Field.js +82 -0
- package/dist/molecules/InputGroup.d.ts +33 -0
- package/dist/molecules/InputGroup.js +35 -0
- package/dist/molecules/InputOTP.d.ts +33 -0
- package/dist/molecules/InputOTP.js +140 -0
- package/dist/molecules/Item.d.ts +41 -0
- package/dist/molecules/Item.js +48 -0
- package/dist/molecules/Message.d.ts +43 -0
- package/dist/molecules/Message.js +82 -0
- package/dist/molecules/Pagination.d.ts +41 -0
- package/dist/molecules/Pagination.js +144 -0
- package/dist/molecules/RadioGroup.d.ts +32 -0
- package/dist/molecules/RadioGroup.js +65 -0
- package/dist/molecules/Resizable.d.ts +37 -0
- package/dist/molecules/Resizable.js +136 -0
- package/dist/molecules/Table.d.ts +37 -0
- package/dist/molecules/Table.js +78 -0
- package/dist/molecules/Tabs.d.ts +37 -0
- package/dist/molecules/Tabs.js +109 -0
- package/dist/molecules/ToggleGroup.d.ts +37 -0
- package/dist/molecules/ToggleGroup.js +68 -0
- package/dist/molecules/Typography.d.ts +26 -0
- package/dist/molecules/Typography.js +67 -0
- package/dist/molecules/index.d.ts +37 -0
- package/dist/molecules/index.js +37 -0
- package/dist/organisms/AlertDialog.d.ts +40 -0
- package/dist/organisms/AlertDialog.js +104 -0
- package/dist/organisms/Calendar.d.ts +96 -0
- package/dist/organisms/Calendar.js +353 -0
- package/dist/organisms/Carousel.d.ts +29 -0
- package/dist/organisms/Carousel.js +128 -0
- package/dist/organisms/Chart.d.ts +55 -0
- package/dist/organisms/Chart.js +189 -0
- package/dist/organisms/Combobox.d.ts +41 -0
- package/dist/organisms/Combobox.js +102 -0
- package/dist/organisms/Command.d.ts +46 -0
- package/dist/organisms/Command.js +171 -0
- package/dist/organisms/CommandDialog.d.ts +42 -0
- package/dist/organisms/CommandDialog.js +116 -0
- package/dist/organisms/ContextMenu.d.ts +29 -0
- package/dist/organisms/ContextMenu.js +102 -0
- package/dist/organisms/DataTable.d.ts +50 -0
- package/dist/organisms/DataTable.js +257 -0
- package/dist/organisms/DatePicker.d.ts +34 -0
- package/dist/organisms/DatePicker.js +115 -0
- package/dist/organisms/DateRangePicker.d.ts +34 -0
- package/dist/organisms/DateRangePicker.js +122 -0
- package/dist/organisms/Dialog.d.ts +50 -0
- package/dist/organisms/Dialog.js +116 -0
- package/dist/organisms/Drawer.d.ts +40 -0
- package/dist/organisms/Drawer.js +131 -0
- package/dist/organisms/DropdownMenu.d.ts +30 -0
- package/dist/organisms/DropdownMenu.js +91 -0
- package/dist/organisms/Form.d.ts +114 -0
- package/dist/organisms/Form.js +151 -0
- package/dist/organisms/HoverCard.d.ts +30 -0
- package/dist/organisms/HoverCard.js +68 -0
- package/dist/organisms/Menubar.d.ts +28 -0
- package/dist/organisms/Menubar.js +147 -0
- package/dist/organisms/MessageScroller.d.ts +36 -0
- package/dist/organisms/MessageScroller.js +124 -0
- package/dist/organisms/NavigationMenu.d.ts +41 -0
- package/dist/organisms/NavigationMenu.js +139 -0
- package/dist/organisms/Popover.d.ts +33 -0
- package/dist/organisms/Popover.js +58 -0
- package/dist/organisms/Questionnaire.d.ts +74 -0
- package/dist/organisms/Questionnaire.js +235 -0
- package/dist/organisms/Select.d.ts +47 -0
- package/dist/organisms/Select.js +250 -0
- package/dist/organisms/Sheet.d.ts +34 -0
- package/dist/organisms/Sheet.js +107 -0
- package/dist/organisms/Sidebar.d.ts +100 -0
- package/dist/organisms/Sidebar.js +228 -0
- package/dist/organisms/Toaster.d.ts +67 -0
- package/dist/organisms/Toaster.js +189 -0
- package/dist/organisms/Tooltip.d.ts +39 -0
- package/dist/organisms/Tooltip.js +94 -0
- package/dist/organisms/index.d.ts +40 -0
- package/dist/organisms/index.js +40 -0
- package/dist/primitives/controllable.d.ts +38 -0
- package/dist/primitives/controllable.js +43 -0
- package/dist/primitives/dismissable.d.ts +53 -0
- package/dist/primitives/dismissable.js +158 -0
- package/dist/primitives/floating.d.ts +116 -0
- package/dist/primitives/floating.js +289 -0
- package/dist/primitives/floatingSurface.d.ts +56 -0
- package/dist/primitives/floatingSurface.js +142 -0
- package/dist/primitives/focusTrap.d.ts +47 -0
- package/dist/primitives/focusTrap.js +101 -0
- package/dist/primitives/focusable.d.ts +53 -0
- package/dist/primitives/focusable.js +109 -0
- package/dist/primitives/index.d.ts +27 -0
- package/dist/primitives/index.js +27 -0
- package/dist/primitives/menu.d.ts +102 -0
- package/dist/primitives/menu.js +356 -0
- package/dist/primitives/modalSurface.d.ts +54 -0
- package/dist/primitives/modalSurface.js +146 -0
- package/dist/primitives/portal.d.ts +34 -0
- package/dist/primitives/portal.js +42 -0
- package/dist/primitives/presence.d.ts +45 -0
- package/dist/primitives/presence.js +152 -0
- package/dist/primitives/rovingFocus.d.ts +47 -0
- package/dist/primitives/rovingFocus.js +176 -0
- package/dist/primitives/scrollLock.d.ts +21 -0
- package/dist/primitives/scrollLock.js +75 -0
- package/dist/primitives/typeahead.d.ts +39 -0
- package/dist/primitives/typeahead.js +123 -0
- package/dist/templates/AppShell.d.ts +30 -0
- package/dist/templates/AppShell.js +42 -0
- package/dist/templates/AuthLayout.d.ts +23 -0
- package/dist/templates/AuthLayout.js +42 -0
- package/dist/templates/SettingsLayout.d.ts +27 -0
- package/dist/templates/SettingsLayout.js +42 -0
- package/dist/templates/index.d.ts +16 -0
- package/dist/templates/index.js +16 -0
- package/nebula.css +2 -0
- package/package.json +92 -0
- package/registry.json +1107 -0
- package/src/adapters/css.ts +75 -0
- package/src/adapters/index.ts +40 -0
- package/src/adapters/tailwind.ts +134 -0
- package/src/adapters/types.ts +53 -0
- package/src/adapters/unocss.ts +116 -0
- package/src/atoms/AspectRatio.ts +28 -0
- package/src/atoms/Avatar.ts +67 -0
- package/src/atoms/Badge.ts +46 -0
- package/src/atoms/Button.ts +96 -0
- package/src/atoms/Checkbox.ts +76 -0
- package/src/atoms/Input.ts +76 -0
- package/src/atoms/Kbd.ts +28 -0
- package/src/atoms/Label.ts +38 -0
- package/src/atoms/Marker.ts +79 -0
- package/src/atoms/NativeSelect.ts +115 -0
- package/src/atoms/Progress.ts +64 -0
- package/src/atoms/ScrollArea.ts +53 -0
- package/src/atoms/Separator.ts +36 -0
- package/src/atoms/Skeleton.ts +24 -0
- package/src/atoms/Slider.ts +65 -0
- package/src/atoms/Spinner.ts +28 -0
- package/src/atoms/Switch.ts +56 -0
- package/src/atoms/Textarea.ts +58 -0
- package/src/atoms/Toggle.ts +67 -0
- package/src/atoms/index.ts +62 -0
- package/src/cli/add.ts +126 -0
- package/src/cli/index.ts +22 -0
- package/src/cli/registry.ts +116 -0
- package/src/config.ts +102 -0
- package/src/configure.ts +127 -0
- package/src/index.ts +53 -0
- package/src/lib/children.ts +43 -0
- package/src/lib/cn.ts +14 -0
- package/src/lib/cva.ts +249 -0
- package/src/lib/icons.ts +138 -0
- package/src/lib/id.ts +44 -0
- package/src/lib/index.ts +21 -0
- package/src/lib/motion.ts +48 -0
- package/src/lib/props.ts +83 -0
- package/src/lib/styled.ts +60 -0
- package/src/molecules/Accordion.ts +161 -0
- package/src/molecules/Alert.ts +58 -0
- package/src/molecules/Attachment.ts +132 -0
- package/src/molecules/Breadcrumb.ts +107 -0
- package/src/molecules/Bubble.ts +134 -0
- package/src/molecules/ButtonGroup.ts +55 -0
- package/src/molecules/Card.ts +42 -0
- package/src/molecules/Collapsible.ts +89 -0
- package/src/molecules/Empty.ts +43 -0
- package/src/molecules/Field.ts +131 -0
- package/src/molecules/InputGroup.ts +62 -0
- package/src/molecules/InputOTP.ts +166 -0
- package/src/molecules/Item.ts +89 -0
- package/src/molecules/Message.ts +127 -0
- package/src/molecules/Pagination.ts +197 -0
- package/src/molecules/RadioGroup.ts +104 -0
- package/src/molecules/Resizable.ts +166 -0
- package/src/molecules/Table.ts +118 -0
- package/src/molecules/Tabs.ts +148 -0
- package/src/molecules/ToggleGroup.ts +109 -0
- package/src/molecules/Typography.ts +94 -0
- package/src/molecules/index.ts +144 -0
- package/src/organisms/AlertDialog.ts +133 -0
- package/src/organisms/Calendar.ts +470 -0
- package/src/organisms/Carousel.ts +161 -0
- package/src/organisms/Chart.ts +257 -0
- package/src/organisms/Combobox.ts +140 -0
- package/src/organisms/Command.ts +211 -0
- package/src/organisms/CommandDialog.ts +149 -0
- package/src/organisms/ContextMenu.ts +125 -0
- package/src/organisms/DataTable.ts +328 -0
- package/src/organisms/DatePicker.ts +141 -0
- package/src/organisms/DateRangePicker.ts +149 -0
- package/src/organisms/Dialog.ts +162 -0
- package/src/organisms/Drawer.ts +167 -0
- package/src/organisms/DropdownMenu.ts +112 -0
- package/src/organisms/Form.ts +217 -0
- package/src/organisms/HoverCard.ts +92 -0
- package/src/organisms/Menubar.ts +167 -0
- package/src/organisms/MessageScroller.ts +145 -0
- package/src/organisms/NavigationMenu.ts +184 -0
- package/src/organisms/Popover.ts +84 -0
- package/src/organisms/Questionnaire.ts +322 -0
- package/src/organisms/Select.ts +286 -0
- package/src/organisms/Sheet.ts +144 -0
- package/src/organisms/Sidebar.ts +336 -0
- package/src/organisms/Toaster.ts +246 -0
- package/src/organisms/Tooltip.ts +124 -0
- package/src/organisms/index.ts +134 -0
- package/src/primitives/controllable.ts +64 -0
- package/src/primitives/dismissable.ts +195 -0
- package/src/primitives/floating.ts +453 -0
- package/src/primitives/floatingSurface.ts +209 -0
- package/src/primitives/focusTrap.ts +132 -0
- package/src/primitives/focusable.ts +108 -0
- package/src/primitives/index.ts +88 -0
- package/src/primitives/menu.ts +491 -0
- package/src/primitives/modalSurface.ts +199 -0
- package/src/primitives/portal.ts +61 -0
- package/src/primitives/presence.ts +185 -0
- package/src/primitives/rovingFocus.ts +212 -0
- package/src/primitives/scrollLock.ts +79 -0
- package/src/primitives/typeahead.ts +156 -0
- package/src/templates/AppShell.ts +56 -0
- package/src/templates/AuthLayout.ts +66 -0
- package/src/templates/SettingsLayout.ts +63 -0
- package/src/templates/index.ts +21 -0
- package/theme.css +151 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enter and exit animations for overlays.
|
|
3
|
+
*
|
|
4
|
+
* shadcn drives these with `animate-in` / `animate-out` from `tw-animate-css`
|
|
5
|
+
* — a Tailwind plugin, and a dependency. nebula declares the four keyframes it
|
|
6
|
+
* needs in `theme.css` and references them through arbitrary animation values,
|
|
7
|
+
* which every one of the three adapters supports and none of them needs a
|
|
8
|
+
* plugin for.
|
|
9
|
+
*
|
|
10
|
+
* The durations are asymmetric on purpose: entering is slower than leaving.
|
|
11
|
+
* A surface appearing wants to be noticed, a surface dismissed wants to be out
|
|
12
|
+
* of the way — matching them makes closing feel sluggish.
|
|
13
|
+
*
|
|
14
|
+
* Every string ends in `motion-reduce:animate-none`. That is not only a
|
|
15
|
+
* courtesy: `onExitFinished` reads the computed style to decide whether to
|
|
16
|
+
* wait, sees no animation, and removes the node immediately. Reduced motion
|
|
17
|
+
* therefore gets an instant close rather than a delayed one, with no branch in
|
|
18
|
+
* the component.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Side } from "../primitives/floating.js";
|
|
22
|
+
|
|
23
|
+
/** Popovers, menus, selects — scale up from the anchor. */
|
|
24
|
+
export const zoomInOut =
|
|
25
|
+
"data-[state=open]:animate-[nebula-zoom-in_150ms_ease-out] data-[state=closed]:animate-[nebula-zoom-out_120ms_ease-in] motion-reduce:animate-none";
|
|
26
|
+
|
|
27
|
+
/** Backdrops and tooltips — no movement, just opacity. */
|
|
28
|
+
export const fadeInOut =
|
|
29
|
+
"data-[state=open]:animate-[nebula-fade-in_150ms_ease-out] data-[state=closed]:animate-[nebula-fade-out_120ms_ease-in] motion-reduce:animate-none";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Panels that slide in from an edge — Sheet, Drawer.
|
|
33
|
+
*
|
|
34
|
+
* The transform is a utility pair rather than a keyframe, because the distance
|
|
35
|
+
* depends on the panel's own size and only `translate-x-full` knows that.
|
|
36
|
+
*/
|
|
37
|
+
export function slideFrom(side: Side): string {
|
|
38
|
+
const axis =
|
|
39
|
+
side === "left"
|
|
40
|
+
? "data-[state=closed]:-translate-x-full"
|
|
41
|
+
: side === "right"
|
|
42
|
+
? "data-[state=closed]:translate-x-full"
|
|
43
|
+
: side === "top"
|
|
44
|
+
? "data-[state=closed]:-translate-y-full"
|
|
45
|
+
: "data-[state=closed]:translate-y-full";
|
|
46
|
+
|
|
47
|
+
return `transition-transform duration-300 ease-in-out data-[state=open]:translate-x-0 data-[state=open]:translate-y-0 ${axis} motion-reduce:transition-none`;
|
|
48
|
+
}
|
package/src/lib/props.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive prop plumbing.
|
|
3
|
+
*
|
|
4
|
+
* React components take plain values and re-render. Aurora never re-renders —
|
|
5
|
+
* a binding stays live because the value handed to it is a signal or a plain
|
|
6
|
+
* accessor function, which the renderer wraps in an `effect()`. So every
|
|
7
|
+
* nebula prop that can change over time is typed `Reactive<T>`: pass a
|
|
8
|
+
* constant when it never moves, pass `() => count() > 3` when it does.
|
|
9
|
+
*
|
|
10
|
+
* Button({ disabled: true }) // static
|
|
11
|
+
* Button({ disabled: () => !form.valid }) // live
|
|
12
|
+
*
|
|
13
|
+
* `read()` collapses either form to a value. `accessor()` does the opposite —
|
|
14
|
+
* it hands the renderer something it can always subscribe to, which is what
|
|
15
|
+
* you want inside a template so the static case does not silently freeze a
|
|
16
|
+
* binding that a sibling prop makes dynamic.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A prop value that may be constant or recomputed on demand. */
|
|
20
|
+
export type Reactive<T> = T | (() => T);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Is this prop the accessor form?
|
|
24
|
+
*
|
|
25
|
+
* A type predicate rather than a cast: `Reactive<T>` where `T` is itself a
|
|
26
|
+
* function type is genuinely ambiguous, and nebula resolves it one way on
|
|
27
|
+
* purpose — a function always means "call me". No nebula prop is typed to
|
|
28
|
+
* receive a function as its *value*; callbacks (`onClick`, `onValueChange`)
|
|
29
|
+
* have their own prop types and never pass through here.
|
|
30
|
+
*/
|
|
31
|
+
function isAccessor<T>(value: Reactive<T>): value is () => T {
|
|
32
|
+
return typeof value === "function";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Collapse a reactive prop to its current value.
|
|
37
|
+
*
|
|
38
|
+
* An absent prop stays `undefined` rather than throwing, because that is
|
|
39
|
+
* exactly what `cva` wants: an axis left `undefined` falls back to its default
|
|
40
|
+
* variant. It lets a component forward props straight through —
|
|
41
|
+
* `buttonVariants({ variant: read(props.variant) })` — with no per-prop
|
|
42
|
+
* presence check, which is the difference between two lines and ten in every
|
|
43
|
+
* component in the library.
|
|
44
|
+
*/
|
|
45
|
+
export function read<T>(value: Reactive<T>): T;
|
|
46
|
+
export function read<T>(value: Reactive<T> | undefined): T | undefined;
|
|
47
|
+
export function read<T>(value: Reactive<T> | undefined): T | undefined {
|
|
48
|
+
if (value === undefined) return undefined;
|
|
49
|
+
return isAccessor(value) ? value() : value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Collapse an optional reactive prop, falling back when it is absent. */
|
|
53
|
+
export function readOr<T>(value: Reactive<T> | undefined, fallback: T): T {
|
|
54
|
+
if (value === undefined) return fallback;
|
|
55
|
+
return isAccessor(value) ? value() : value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Turn any prop into an accessor the renderer can subscribe to.
|
|
60
|
+
*
|
|
61
|
+
* Templates should bind `${accessor(props.disabled, false)}` rather than
|
|
62
|
+
* `${readOr(props.disabled, false)}`: the second reads once at construction
|
|
63
|
+
* and the attribute never updates again.
|
|
64
|
+
*/
|
|
65
|
+
export function accessor<T>(
|
|
66
|
+
value: Reactive<T> | undefined,
|
|
67
|
+
fallback: T,
|
|
68
|
+
): () => T {
|
|
69
|
+
return () => readOr(value, fallback);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Call a handler if one was supplied.
|
|
74
|
+
*
|
|
75
|
+
* Saves every component the `props.onClick?.(event)` dance and keeps the
|
|
76
|
+
* optional-callback shape identical across the library.
|
|
77
|
+
*/
|
|
78
|
+
export function callHandler<A extends readonly unknown[]>(
|
|
79
|
+
handler: ((...args: A) => void) | undefined,
|
|
80
|
+
...args: A
|
|
81
|
+
): void {
|
|
82
|
+
handler?.(...args);
|
|
83
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purely presentational parts.
|
|
3
|
+
*
|
|
4
|
+
* A compound component like Card is one interesting element and half a dozen
|
|
5
|
+
* styled boxes — `CardHeader`, `CardContent`, `CardFooter` and friends carry a
|
|
6
|
+
* class string, a `data-slot`, and nothing else. Written out longhand that is
|
|
7
|
+
* eight near-identical lines each, thirty times over, and the differences that
|
|
8
|
+
* matter get lost in the repetition.
|
|
9
|
+
*
|
|
10
|
+
* So the boxes are declared, not written:
|
|
11
|
+
*
|
|
12
|
+
* export const CardHeader = styledDiv("card-header", "flex flex-col gap-1.5 px-6")
|
|
13
|
+
*
|
|
14
|
+
* Only for parts with no behaviour. The moment one needs an event, a piece of
|
|
15
|
+
* state or an ARIA relationship it gets written out in full — hiding those
|
|
16
|
+
* behind a factory is how a component becomes hard to read, which matters
|
|
17
|
+
* doubly here because the registry copies this source into user projects for
|
|
18
|
+
* them to edit.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { component, html, type TemplateResult } from "@c9up/aurora";
|
|
22
|
+
import { type Slot, slot } from "./children.js";
|
|
23
|
+
import { cn } from "./cn.js";
|
|
24
|
+
import { type Reactive, read } from "./props.js";
|
|
25
|
+
|
|
26
|
+
export interface StyledProps {
|
|
27
|
+
children?: Slot;
|
|
28
|
+
class?: Reactive<string>;
|
|
29
|
+
id?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A `<div>` carrying a `data-slot` and a base class list. */
|
|
33
|
+
export function styledDiv(
|
|
34
|
+
name: string,
|
|
35
|
+
base: string,
|
|
36
|
+
): (props?: StyledProps) => TemplateResult {
|
|
37
|
+
return component<StyledProps>(
|
|
38
|
+
(props) =>
|
|
39
|
+
html`<div
|
|
40
|
+
data-slot="${name}"
|
|
41
|
+
id="${props.id}"
|
|
42
|
+
class="${() => cn(base, read(props.class))}"
|
|
43
|
+
>${slot(props.children)}</div>`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The same, as an inline `<span>`. */
|
|
48
|
+
export function styledSpan(
|
|
49
|
+
name: string,
|
|
50
|
+
base: string,
|
|
51
|
+
): (props?: StyledProps) => TemplateResult {
|
|
52
|
+
return component<StyledProps>(
|
|
53
|
+
(props) =>
|
|
54
|
+
html`<span
|
|
55
|
+
data-slot="${name}"
|
|
56
|
+
id="${props.id}"
|
|
57
|
+
class="${() => cn(base, read(props.class))}"
|
|
58
|
+
>${slot(props.children)}</span>`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accordion — a stack of collapsible sections.
|
|
3
|
+
*
|
|
4
|
+
* Data-driven rather than composed: an accordion is a list, and describing it
|
|
5
|
+
* as one is both shorter to write and the only shape that lets the group
|
|
6
|
+
* enforce `type: "single"` — which needs to know about every section at once,
|
|
7
|
+
* something Radix can only do through context.
|
|
8
|
+
*
|
|
9
|
+
* The keyboard behaviour is the WAI-ARIA accordion pattern, delegated to
|
|
10
|
+
* `rovingFocus` over the headers: arrows move between sections, Home and End
|
|
11
|
+
* jump to the ends, and the headers are one tab stop rather than one each.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { component, html, onMount, onUnmount, signal } from "@c9up/aurora";
|
|
15
|
+
import type { Child, Slot } from "../lib/children.js";
|
|
16
|
+
import { slot } from "../lib/children.js";
|
|
17
|
+
import { cn } from "../lib/cn.js";
|
|
18
|
+
import { ChevronDownIcon } from "../lib/icons.js";
|
|
19
|
+
import { uid } from "../lib/id.js";
|
|
20
|
+
import { type Reactive, read } from "../lib/props.js";
|
|
21
|
+
import { type RovingFocus, rovingFocus } from "../primitives/rovingFocus.js";
|
|
22
|
+
|
|
23
|
+
export interface AccordionItem {
|
|
24
|
+
/** Stable key. Used for the open-state set and for the ARIA ids. */
|
|
25
|
+
value: string;
|
|
26
|
+
/** The header content. */
|
|
27
|
+
trigger: Child;
|
|
28
|
+
/** The panel content. */
|
|
29
|
+
content: Slot;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AccordionProps {
|
|
34
|
+
items: readonly AccordionItem[];
|
|
35
|
+
/** `"single"` closes the open section when another opens. Default `"single"`. */
|
|
36
|
+
type?: "single" | "multiple";
|
|
37
|
+
/** Open at first render. A string, or several for `type: "multiple"`. */
|
|
38
|
+
defaultValue?: string | readonly string[];
|
|
39
|
+
/** `"single"` accordions can normally be closed entirely. */
|
|
40
|
+
collapsible?: boolean;
|
|
41
|
+
onValueChange?: (open: readonly string[]) => void;
|
|
42
|
+
class?: Reactive<string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const Accordion = component<AccordionProps>((props) => {
|
|
46
|
+
const type = props.type ?? "single";
|
|
47
|
+
const open = signal<readonly string[]>(normaliseInitial(props.defaultValue));
|
|
48
|
+
const rootId = uid("accordion");
|
|
49
|
+
|
|
50
|
+
function isOpen(value: string): boolean {
|
|
51
|
+
return open().includes(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toggle(value: string): void {
|
|
55
|
+
const current = open();
|
|
56
|
+
const next = nextOpenSet(current, value, type, props.collapsible !== false);
|
|
57
|
+
open(next);
|
|
58
|
+
props.onValueChange?.(next);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Roving focus is attached after mount because it queries the container for
|
|
62
|
+
// its items, and the container does not exist until then.
|
|
63
|
+
let group: RovingFocus | undefined;
|
|
64
|
+
onMount(() => {
|
|
65
|
+
group = rovingFocus({
|
|
66
|
+
container: () => document.getElementById(rootId),
|
|
67
|
+
itemSelector: "[data-slot='accordion-trigger']",
|
|
68
|
+
orientation: "vertical",
|
|
69
|
+
});
|
|
70
|
+
group.sync();
|
|
71
|
+
});
|
|
72
|
+
onUnmount(() => group?.destroy());
|
|
73
|
+
|
|
74
|
+
return html`<div
|
|
75
|
+
data-slot="accordion"
|
|
76
|
+
id="${rootId}"
|
|
77
|
+
class="${() => cn("flex w-full flex-col", read(props.class))}"
|
|
78
|
+
>
|
|
79
|
+
${props.items.map((item) => renderItem(item, isOpen, toggle))}
|
|
80
|
+
</div>`;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
function renderItem(
|
|
84
|
+
item: AccordionItem,
|
|
85
|
+
isOpen: (value: string) => boolean,
|
|
86
|
+
toggle: (value: string) => void,
|
|
87
|
+
): Child {
|
|
88
|
+
const triggerId = uid("accordion-trigger");
|
|
89
|
+
const panelId = uid("accordion-panel");
|
|
90
|
+
const state = (): string => (isOpen(item.value) ? "open" : "closed");
|
|
91
|
+
|
|
92
|
+
return html`<div
|
|
93
|
+
data-slot="accordion-item"
|
|
94
|
+
data-state="${state}"
|
|
95
|
+
class="border-b last:border-b-0"
|
|
96
|
+
>
|
|
97
|
+
<h3 class="flex">
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
data-slot="accordion-trigger"
|
|
101
|
+
data-nebula-item
|
|
102
|
+
data-state="${state}"
|
|
103
|
+
data-disabled="${item.disabled === true ? "" : undefined}"
|
|
104
|
+
id="${triggerId}"
|
|
105
|
+
aria-expanded="${() => (isOpen(item.value) ? "true" : "false")}"
|
|
106
|
+
aria-controls="${panelId}"
|
|
107
|
+
?disabled="${item.disabled === true}"
|
|
108
|
+
class="focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium outline-none transition-all hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180"
|
|
109
|
+
@click="${() => toggle(item.value)}"
|
|
110
|
+
>
|
|
111
|
+
${item.trigger}
|
|
112
|
+
${ChevronDownIcon({
|
|
113
|
+
class:
|
|
114
|
+
"text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200",
|
|
115
|
+
})}
|
|
116
|
+
</button>
|
|
117
|
+
</h3>
|
|
118
|
+
<div
|
|
119
|
+
data-slot="accordion-content"
|
|
120
|
+
id="${panelId}"
|
|
121
|
+
role="region"
|
|
122
|
+
aria-labelledby="${triggerId}"
|
|
123
|
+
?inert="${() => !isOpen(item.value)}"
|
|
124
|
+
class="grid text-sm transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none"
|
|
125
|
+
style="${() => `grid-template-rows: ${isOpen(item.value) ? "1fr" : "0fr"}`}"
|
|
126
|
+
>
|
|
127
|
+
<div class="overflow-hidden"><div class="pt-0 pb-4">${slot(item.content)}</div></div>
|
|
128
|
+
</div>
|
|
129
|
+
</div>`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function normaliseInitial(
|
|
133
|
+
value: string | readonly string[] | undefined,
|
|
134
|
+
): readonly string[] {
|
|
135
|
+
if (value === undefined) return [];
|
|
136
|
+
return typeof value === "string" ? [value] : value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The open set after activating `value`.
|
|
141
|
+
*
|
|
142
|
+
* `collapsible: false` on a single accordion is what stops the last open
|
|
143
|
+
* section from closing — the pattern used when one section must always be
|
|
144
|
+
* showing, and the reason this is not simply a toggle.
|
|
145
|
+
*/
|
|
146
|
+
function nextOpenSet(
|
|
147
|
+
current: readonly string[],
|
|
148
|
+
value: string,
|
|
149
|
+
type: "single" | "multiple",
|
|
150
|
+
collapsible: boolean,
|
|
151
|
+
): readonly string[] {
|
|
152
|
+
const isOpen = current.includes(value);
|
|
153
|
+
|
|
154
|
+
if (type === "multiple") {
|
|
155
|
+
return isOpen
|
|
156
|
+
? current.filter((entry) => entry !== value)
|
|
157
|
+
: [...current, value];
|
|
158
|
+
}
|
|
159
|
+
if (isOpen) return collapsible ? [] : current;
|
|
160
|
+
return [value];
|
|
161
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alert — an inline message about the surrounding content.
|
|
3
|
+
*
|
|
4
|
+
* `role="alert"` only for the destructive variant. The role is a live region:
|
|
5
|
+
* it interrupts a screen reader the moment the element appears. That is right
|
|
6
|
+
* for an error and wrong for the informational variant, which would talk over
|
|
7
|
+
* whatever the user was reading to announce something they did not ask about.
|
|
8
|
+
*
|
|
9
|
+
* The grid layout is what lets an optional leading icon align with the title
|
|
10
|
+
* while the description wraps under both, without either part knowing whether
|
|
11
|
+
* the other is present.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { component, html } from "@c9up/aurora";
|
|
15
|
+
import { type Slot, slot } from "../lib/children.js";
|
|
16
|
+
import { cva, type VariantProps } from "../lib/cva.js";
|
|
17
|
+
import { type Reactive, read, readOr } from "../lib/props.js";
|
|
18
|
+
import { styledDiv } from "../lib/styled.js";
|
|
19
|
+
|
|
20
|
+
export const alertVariants = cva(
|
|
21
|
+
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
|
22
|
+
{
|
|
23
|
+
variants: {
|
|
24
|
+
variant: {
|
|
25
|
+
default: "bg-card text-card-foreground",
|
|
26
|
+
destructive:
|
|
27
|
+
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
defaultVariants: { variant: "default" },
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
export type AlertVariants = VariantProps<typeof alertVariants>;
|
|
35
|
+
|
|
36
|
+
export interface AlertProps {
|
|
37
|
+
children?: Slot;
|
|
38
|
+
variant?: Reactive<AlertVariants["variant"]>;
|
|
39
|
+
class?: Reactive<string>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const Alert = component<AlertProps>((props) => {
|
|
43
|
+
return html`<div
|
|
44
|
+
data-slot="alert"
|
|
45
|
+
role="${() => (readOr(props.variant, "default") === "destructive" ? "alert" : "note")}"
|
|
46
|
+
class="${() => alertVariants({ variant: read(props.variant), class: read(props.class) })}"
|
|
47
|
+
>${slot(props.children)}</div>`;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const AlertTitle = styledDiv(
|
|
51
|
+
"alert-title",
|
|
52
|
+
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
export const AlertDescription = styledDiv(
|
|
56
|
+
"alert-description",
|
|
57
|
+
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
|
58
|
+
);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attachment — a file carried by a message.
|
|
3
|
+
*
|
|
4
|
+
* Two shapes from one component: an image renders as a thumbnail, anything
|
|
5
|
+
* else as a chip with a name and a size. Splitting them into two components
|
|
6
|
+
* would push the choice onto the caller, who usually has a MIME type and no
|
|
7
|
+
* opinion.
|
|
8
|
+
*
|
|
9
|
+
* The size is formatted in binary units because that is what a file manager
|
|
10
|
+
* shows for the same file — a "1.2 MB" here and a "1.1 MiB" there for one
|
|
11
|
+
* upload reads as a bug.
|
|
12
|
+
*
|
|
13
|
+
* A removable attachment is a `<button>` beside the link, never a click
|
|
14
|
+
* handler on the chip itself. Merging the two makes "open" and "delete" the
|
|
15
|
+
* same gesture, distinguished only by where you land.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { component, html } from "@c9up/aurora";
|
|
19
|
+
import { cn } from "../lib/cn.js";
|
|
20
|
+
import { XIcon } from "../lib/icons.js";
|
|
21
|
+
import { type Reactive, read } from "../lib/props.js";
|
|
22
|
+
|
|
23
|
+
export interface AttachmentProps {
|
|
24
|
+
name: string;
|
|
25
|
+
/** Bytes. Omitted for something not yet uploaded. */
|
|
26
|
+
size?: number;
|
|
27
|
+
/** MIME type. Anything under `image/` renders as a thumbnail. */
|
|
28
|
+
type?: string;
|
|
29
|
+
/** Opens the file. Without it the chip is inert. */
|
|
30
|
+
href?: string;
|
|
31
|
+
/** Thumbnail source, for an image. Defaults to `href`. */
|
|
32
|
+
previewSrc?: string;
|
|
33
|
+
/** Shows a remove button. */
|
|
34
|
+
onRemove?: () => void;
|
|
35
|
+
class?: Reactive<string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Human-readable byte count, binary units.
|
|
40
|
+
*
|
|
41
|
+
* Deliberately not `Intl.NumberFormat`'s `unit: "byte"` style: it only speaks
|
|
42
|
+
* decimal units (kB, MB), which disagrees with every desktop file manager.
|
|
43
|
+
*/
|
|
44
|
+
export function formatBytes(bytes: number): string {
|
|
45
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "";
|
|
46
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
47
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
48
|
+
let value = bytes / 1024;
|
|
49
|
+
let unit = 0;
|
|
50
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
51
|
+
value /= 1024;
|
|
52
|
+
unit += 1;
|
|
53
|
+
}
|
|
54
|
+
// One decimal below 10, none above — "9.4 MB" is useful, "941.3 MB" is noise.
|
|
55
|
+
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const Attachment = component<AttachmentProps>((props) => {
|
|
59
|
+
const isImage = props.type?.startsWith("image/") === true;
|
|
60
|
+
const preview = props.previewSrc ?? props.href;
|
|
61
|
+
|
|
62
|
+
const removeButton =
|
|
63
|
+
props.onRemove === undefined
|
|
64
|
+
? null
|
|
65
|
+
: html`<button
|
|
66
|
+
type="button"
|
|
67
|
+
data-slot="attachment-remove"
|
|
68
|
+
aria-label="${`Remove ${props.name}`}"
|
|
69
|
+
class="text-muted-foreground hover:text-foreground absolute end-1 top-1 rounded-sm bg-background/80 p-0.5 transition-colors"
|
|
70
|
+
@click="${() => props.onRemove?.()}"
|
|
71
|
+
>${XIcon({ class: "size-3.5" })}</button>`;
|
|
72
|
+
|
|
73
|
+
if (isImage && preview !== undefined) {
|
|
74
|
+
return html`<div
|
|
75
|
+
data-slot="attachment"
|
|
76
|
+
data-kind="image"
|
|
77
|
+
class="${() => cn("relative w-fit overflow-hidden rounded-md border", read(props.class))}"
|
|
78
|
+
>
|
|
79
|
+
<a href="${props.href}" class="block">
|
|
80
|
+
<img
|
|
81
|
+
src="${preview}"
|
|
82
|
+
alt="${props.name}"
|
|
83
|
+
loading="lazy"
|
|
84
|
+
class="max-h-48 max-w-64 object-cover"
|
|
85
|
+
/>
|
|
86
|
+
</a>
|
|
87
|
+
${removeButton}
|
|
88
|
+
</div>`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const body = html`<span
|
|
92
|
+
data-slot="attachment-icon"
|
|
93
|
+
aria-hidden="true"
|
|
94
|
+
class="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded text-[0.625rem] font-medium uppercase"
|
|
95
|
+
>${extensionOf(props.name)}</span>
|
|
96
|
+
<span class="flex min-w-0 flex-col">
|
|
97
|
+
<span class="truncate text-sm font-medium">${props.name}</span>
|
|
98
|
+
${
|
|
99
|
+
props.size === undefined
|
|
100
|
+
? null
|
|
101
|
+
: html`<span class="text-muted-foreground text-xs tabular-nums"
|
|
102
|
+
>${formatBytes(props.size)}</span
|
|
103
|
+
>`
|
|
104
|
+
}
|
|
105
|
+
</span>`;
|
|
106
|
+
|
|
107
|
+
return html`<div
|
|
108
|
+
data-slot="attachment"
|
|
109
|
+
data-kind="file"
|
|
110
|
+
class="${() =>
|
|
111
|
+
cn(
|
|
112
|
+
"bg-background relative flex w-fit max-w-64 items-center gap-2 rounded-md border p-2 pe-7",
|
|
113
|
+
read(props.class),
|
|
114
|
+
)}"
|
|
115
|
+
>
|
|
116
|
+
${
|
|
117
|
+
props.href === undefined
|
|
118
|
+
? html`<span class="flex min-w-0 items-center gap-2">${body}</span>`
|
|
119
|
+
: html`<a href="${props.href}" class="flex min-w-0 items-center gap-2 hover:underline"
|
|
120
|
+
>${body}</a
|
|
121
|
+
>`
|
|
122
|
+
}
|
|
123
|
+
${removeButton}
|
|
124
|
+
</div>`;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/** The extension, for the icon tile. `"report.final.pdf"` → `"pdf"`. */
|
|
128
|
+
function extensionOf(name: string): string {
|
|
129
|
+
const dot = name.lastIndexOf(".");
|
|
130
|
+
if (dot <= 0 || dot === name.length - 1) return "?";
|
|
131
|
+
return name.slice(dot + 1).slice(0, 4);
|
|
132
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breadcrumb — the trail back up the hierarchy.
|
|
3
|
+
*
|
|
4
|
+
* `aria-label="breadcrumb"` on the `<nav>` and `aria-current="page"` on the
|
|
5
|
+
* last entry. Both matter: the label is how a screen reader distinguishes this
|
|
6
|
+
* navigation from the other landmarks on the page, and `aria-current` is what
|
|
7
|
+
* tells the user which crumb is where they are — the visual weight change does
|
|
8
|
+
* not carry that.
|
|
9
|
+
*
|
|
10
|
+
* The separators are `aria-hidden` and marked `role="presentation"`. Read
|
|
11
|
+
* aloud, a chevron between every crumb is noise; the list structure already
|
|
12
|
+
* conveys the sequence.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { component, html } from "@c9up/aurora";
|
|
16
|
+
import type { Child } from "../lib/children.js";
|
|
17
|
+
import { cn } from "../lib/cn.js";
|
|
18
|
+
import { ChevronRightIcon, MoreHorizontalIcon } from "../lib/icons.js";
|
|
19
|
+
import { type Reactive, read } from "../lib/props.js";
|
|
20
|
+
|
|
21
|
+
export interface Crumb {
|
|
22
|
+
label: Child;
|
|
23
|
+
/** Absent on the current page, and on a collapsed ellipsis. */
|
|
24
|
+
href?: string;
|
|
25
|
+
/** Renders an ellipsis standing in for hidden ancestors. */
|
|
26
|
+
ellipsis?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BreadcrumbProps {
|
|
30
|
+
items: readonly Crumb[];
|
|
31
|
+
/** Replaces the chevron between entries. */
|
|
32
|
+
separator?: Child;
|
|
33
|
+
class?: Reactive<string>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const Breadcrumb = component<BreadcrumbProps>((props) => {
|
|
37
|
+
return html`<nav
|
|
38
|
+
data-slot="breadcrumb"
|
|
39
|
+
aria-label="breadcrumb"
|
|
40
|
+
class="${() => cn(read(props.class))}"
|
|
41
|
+
>
|
|
42
|
+
<ol
|
|
43
|
+
data-slot="breadcrumb-list"
|
|
44
|
+
class="text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5"
|
|
45
|
+
>
|
|
46
|
+
${props.items.map((item, index) =>
|
|
47
|
+
renderCrumb(
|
|
48
|
+
item,
|
|
49
|
+
index === props.items.length - 1,
|
|
50
|
+
index > 0,
|
|
51
|
+
props.separator,
|
|
52
|
+
),
|
|
53
|
+
)}
|
|
54
|
+
</ol>
|
|
55
|
+
</nav>`;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function renderCrumb(
|
|
59
|
+
item: Crumb,
|
|
60
|
+
isLast: boolean,
|
|
61
|
+
needsSeparator: boolean,
|
|
62
|
+
separator: Child,
|
|
63
|
+
): Child {
|
|
64
|
+
return [
|
|
65
|
+
needsSeparator
|
|
66
|
+
? html`<li
|
|
67
|
+
data-slot="breadcrumb-separator"
|
|
68
|
+
role="presentation"
|
|
69
|
+
aria-hidden="true"
|
|
70
|
+
class="[&>svg]:size-3.5"
|
|
71
|
+
>${separator ?? ChevronRightIcon()}</li>`
|
|
72
|
+
: null,
|
|
73
|
+
html`<li data-slot="breadcrumb-item" class="inline-flex items-center gap-1.5">
|
|
74
|
+
${renderCrumbBody(item, isLast)}
|
|
75
|
+
</li>`,
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function renderCrumbBody(item: Crumb, isLast: boolean): Child {
|
|
80
|
+
if (item.ellipsis === true) {
|
|
81
|
+
return html`<span
|
|
82
|
+
data-slot="breadcrumb-ellipsis"
|
|
83
|
+
role="presentation"
|
|
84
|
+
aria-hidden="true"
|
|
85
|
+
class="flex size-9 items-center justify-center"
|
|
86
|
+
>
|
|
87
|
+
${MoreHorizontalIcon({ class: "size-4" })}
|
|
88
|
+
<span class="sr-only">More</span>
|
|
89
|
+
</span>`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (isLast || item.href === undefined) {
|
|
93
|
+
return html`<span
|
|
94
|
+
data-slot="breadcrumb-page"
|
|
95
|
+
role="link"
|
|
96
|
+
aria-disabled="true"
|
|
97
|
+
aria-current="page"
|
|
98
|
+
class="text-foreground font-normal"
|
|
99
|
+
>${item.label}</span>`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return html`<a
|
|
103
|
+
data-slot="breadcrumb-link"
|
|
104
|
+
href="${item.href}"
|
|
105
|
+
class="hover:text-foreground transition-colors"
|
|
106
|
+
>${item.label}</a>`;
|
|
107
|
+
}
|