@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,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InputOTP — a one-time code entered one character per box.
|
|
3
|
+
*
|
|
4
|
+
* The behaviour users expect from this control is entirely in the edge cases,
|
|
5
|
+
* and each one is a separate rule:
|
|
6
|
+
*
|
|
7
|
+
* - Typing advances to the next box; the last one stays put rather than
|
|
8
|
+
* wrapping round to the first.
|
|
9
|
+
* - Backspace in an empty box clears the *previous* one and moves back, which
|
|
10
|
+
* is what makes holding it down erase the whole code.
|
|
11
|
+
* - Pasting a code fills every box from wherever it was pasted, so the common
|
|
12
|
+
* case — copy the code out of the email, click the first box, paste — works.
|
|
13
|
+
* - `inputmode="numeric"` and `autocomplete="one-time-code"` are what get the
|
|
14
|
+
* numeric keypad on mobile and the OS's SMS autofill suggestion. Without the
|
|
15
|
+
* second, a user has to leave the app to read the code.
|
|
16
|
+
*
|
|
17
|
+
* The joined value is mirrored into a hidden input so the control submits as
|
|
18
|
+
* one field, rather than six.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { component, html, signal } from "@c9up/aurora";
|
|
22
|
+
import { cn } from "../lib/cn.js";
|
|
23
|
+
import { uid } from "../lib/id.js";
|
|
24
|
+
import { type Reactive, read, readOr } from "../lib/props.js";
|
|
25
|
+
|
|
26
|
+
export interface InputOTPProps {
|
|
27
|
+
/** Number of boxes. Default `6`. */
|
|
28
|
+
length?: number;
|
|
29
|
+
name?: string;
|
|
30
|
+
disabled?: Reactive<boolean>;
|
|
31
|
+
/** Insert a visual gap after this many boxes. `3` gives `123 456`. */
|
|
32
|
+
groupAfter?: number;
|
|
33
|
+
class?: Reactive<string>;
|
|
34
|
+
onValueChange?: (value: string) => void;
|
|
35
|
+
/** Fired once every box is filled. */
|
|
36
|
+
onComplete?: (value: string) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const slotClasses =
|
|
40
|
+
"border-input relative flex size-9 items-center justify-center border-y border-r text-center text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md focus:border-ring focus:ring-ring/50 focus:z-10 focus:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50";
|
|
41
|
+
|
|
42
|
+
export const InputOTP = component<InputOTPProps>((props) => {
|
|
43
|
+
const length = props.length ?? 6;
|
|
44
|
+
const groupId = uid("input-otp");
|
|
45
|
+
const characters = signal<readonly string[]>(new Array(length).fill(""));
|
|
46
|
+
|
|
47
|
+
function boxes(): HTMLInputElement[] {
|
|
48
|
+
const root = document.getElementById(groupId);
|
|
49
|
+
if (root === null) return [];
|
|
50
|
+
const found: HTMLInputElement[] = [];
|
|
51
|
+
for (const node of root.querySelectorAll("input[data-otp-index]")) {
|
|
52
|
+
if (node instanceof HTMLInputElement) found.push(node);
|
|
53
|
+
}
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function focusBox(index: number): void {
|
|
58
|
+
const target = boxes()[Math.min(Math.max(index, 0), length - 1)];
|
|
59
|
+
target?.focus();
|
|
60
|
+
target?.select();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function commit(next: readonly string[]): void {
|
|
64
|
+
characters(next);
|
|
65
|
+
const joined = next.join("");
|
|
66
|
+
props.onValueChange?.(joined);
|
|
67
|
+
if (joined.length === length && !next.includes(""))
|
|
68
|
+
props.onComplete?.(joined);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function write(index: number, character: string): void {
|
|
72
|
+
const next = [...characters()];
|
|
73
|
+
next[index] = character;
|
|
74
|
+
commit(next);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function onInput(index: number, event: Event): void {
|
|
78
|
+
const target = event.target;
|
|
79
|
+
if (!(target instanceof HTMLInputElement)) return;
|
|
80
|
+
|
|
81
|
+
// A box already holding a character receives the new one appended; keep
|
|
82
|
+
// the last, which is what the user just typed.
|
|
83
|
+
const typed = target.value.slice(-1);
|
|
84
|
+
target.value = typed;
|
|
85
|
+
write(index, typed);
|
|
86
|
+
if (typed !== "" && index < length - 1) focusBox(index + 1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function onKeyDown(index: number, event: KeyboardEvent): void {
|
|
90
|
+
if (event.key === "Backspace") {
|
|
91
|
+
if (characters()[index] === "" && index > 0) {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
write(index - 1, "");
|
|
94
|
+
focusBox(index - 1);
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (event.key === "ArrowLeft") {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
focusBox(index - 1);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (event.key === "ArrowRight") {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
focusBox(index + 1);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function onPaste(index: number, event: ClipboardEvent): void {
|
|
110
|
+
const pasted = event.clipboardData?.getData("text") ?? "";
|
|
111
|
+
if (pasted === "") return;
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
|
|
114
|
+
const next = [...characters()];
|
|
115
|
+
let cursor = index;
|
|
116
|
+
for (const character of pasted) {
|
|
117
|
+
if (cursor >= length) break;
|
|
118
|
+
next[cursor] = character;
|
|
119
|
+
cursor += 1;
|
|
120
|
+
}
|
|
121
|
+
commit(next);
|
|
122
|
+
|
|
123
|
+
// Reflect into the DOM: the boxes are uncontrolled between keystrokes, so
|
|
124
|
+
// writing the signal alone would leave the pasted characters invisible.
|
|
125
|
+
const inputs = boxes();
|
|
126
|
+
next.forEach((character, position) => {
|
|
127
|
+
const input = inputs[position];
|
|
128
|
+
if (input !== undefined) input.value = character;
|
|
129
|
+
});
|
|
130
|
+
focusBox(Math.min(cursor, length - 1));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const indices = Array.from({ length }, (_unused, index) => index);
|
|
134
|
+
|
|
135
|
+
return html`<div
|
|
136
|
+
data-slot="input-otp"
|
|
137
|
+
id="${groupId}"
|
|
138
|
+
class="${() => cn("flex items-center gap-2", read(props.class))}"
|
|
139
|
+
>
|
|
140
|
+
<div class="flex items-center">
|
|
141
|
+
${indices.map(
|
|
142
|
+
(index) => html`${
|
|
143
|
+
props.groupAfter !== undefined &&
|
|
144
|
+
index > 0 &&
|
|
145
|
+
index % props.groupAfter === 0
|
|
146
|
+
? html`<span aria-hidden="true" class="mx-1 text-muted-foreground">-</span>`
|
|
147
|
+
: null
|
|
148
|
+
}<input
|
|
149
|
+
data-slot="input-otp-slot"
|
|
150
|
+
data-otp-index="${index}"
|
|
151
|
+
type="text"
|
|
152
|
+
inputmode="numeric"
|
|
153
|
+
autocomplete="${index === 0 ? "one-time-code" : "off"}"
|
|
154
|
+
maxlength="1"
|
|
155
|
+
aria-label="${`Character ${index + 1} of ${length}`}"
|
|
156
|
+
?disabled="${() => readOr(props.disabled, false)}"
|
|
157
|
+
class="${slotClasses}"
|
|
158
|
+
@input="${(event: Event) => onInput(index, event)}"
|
|
159
|
+
@keydown="${(event: KeyboardEvent) => onKeyDown(index, event)}"
|
|
160
|
+
@paste="${(event: ClipboardEvent) => onPaste(index, event)}"
|
|
161
|
+
/>`,
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
<input type="hidden" name="${props.name}" .value="${() => characters().join("")}" />
|
|
165
|
+
</div>`;
|
|
166
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item — one row in a list of things.
|
|
3
|
+
*
|
|
4
|
+
* The generic list row: a leading media slot, a title and description, and a
|
|
5
|
+
* trailing action. Sidebar entries, settings rows, search results and command
|
|
6
|
+
* results are all this shape, and each of them growing its own version is how
|
|
7
|
+
* a list of things ends up with four different vertical rhythms.
|
|
8
|
+
*
|
|
9
|
+
* `ItemContent` takes `min-w-0` so a long title truncates instead of pushing
|
|
10
|
+
* the trailing action off the row — the single most common bug in a flex row
|
|
11
|
+
* built by hand.
|
|
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 } from "../lib/props.js";
|
|
18
|
+
import { styledDiv } from "../lib/styled.js";
|
|
19
|
+
|
|
20
|
+
export const itemVariants = cva(
|
|
21
|
+
"group/item flex flex-wrap items-center rounded-md border border-transparent text-sm outline-none transition-colors focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] [a&]:hover:bg-accent/50",
|
|
22
|
+
{
|
|
23
|
+
variants: {
|
|
24
|
+
variant: {
|
|
25
|
+
default: "bg-transparent",
|
|
26
|
+
outline: "border-border",
|
|
27
|
+
muted: "bg-muted/50",
|
|
28
|
+
},
|
|
29
|
+
size: {
|
|
30
|
+
default: "gap-4 p-4",
|
|
31
|
+
sm: "gap-2.5 px-4 py-3",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
defaultVariants: { variant: "default", size: "default" },
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
export type ItemVariants = VariantProps<typeof itemVariants>;
|
|
39
|
+
|
|
40
|
+
export interface ItemProps {
|
|
41
|
+
children?: Slot;
|
|
42
|
+
variant?: Reactive<ItemVariants["variant"]>;
|
|
43
|
+
size?: Reactive<ItemVariants["size"]>;
|
|
44
|
+
class?: Reactive<string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const Item = component<ItemProps>((props) => {
|
|
48
|
+
return html`<div
|
|
49
|
+
data-slot="item"
|
|
50
|
+
class="${() =>
|
|
51
|
+
itemVariants({
|
|
52
|
+
variant: read(props.variant),
|
|
53
|
+
size: read(props.size),
|
|
54
|
+
class: read(props.class),
|
|
55
|
+
})}"
|
|
56
|
+
>${slot(props.children)}</div>`;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const ItemGroup = styledDiv(
|
|
60
|
+
"item-group",
|
|
61
|
+
"group/item-group flex flex-col",
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
export const ItemMedia = styledDiv(
|
|
65
|
+
"item-media",
|
|
66
|
+
"flex shrink-0 items-center justify-center gap-2 [&_svg:not([class*='size-'])]:size-4",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
export const ItemContent = styledDiv(
|
|
70
|
+
"item-content",
|
|
71
|
+
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none min-w-0",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
export const ItemTitle = styledDiv(
|
|
75
|
+
"item-title",
|
|
76
|
+
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
export const ItemDescription = styledDiv(
|
|
80
|
+
"item-description",
|
|
81
|
+
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
export const ItemActions = styledDiv("item-actions", "flex items-center gap-2");
|
|
85
|
+
|
|
86
|
+
export const ItemSeparator = styledDiv(
|
|
87
|
+
"item-separator",
|
|
88
|
+
"bg-border my-0 h-px w-full",
|
|
89
|
+
);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message — one turn in a conversation.
|
|
3
|
+
*
|
|
4
|
+
* Avatar, author, timestamp and a `Bubble`, laid out in reading order and
|
|
5
|
+
* mirrored for an outgoing turn. `align: "end"` uses `flex-row-reverse`, so
|
|
6
|
+
* the avatar moves to the inline end and the whole row follows the writing
|
|
7
|
+
* direction — the same logical treatment as the bubble itself.
|
|
8
|
+
*
|
|
9
|
+
* The timestamp is a `<time datetime>`, always. The visible text is formatted
|
|
10
|
+
* for the reader and may be relative ("2 min ago"), which a machine cannot
|
|
11
|
+
* parse; `datetime` carries the ISO instant alongside it so a screen reader,
|
|
12
|
+
* a scraper or a copy-paste keeps the real value.
|
|
13
|
+
*
|
|
14
|
+
* `role="article"` groups the parts, so a screen reader announces the author
|
|
15
|
+
* once rather than reading a loose avatar, a name, a time and a paragraph as
|
|
16
|
+
* four unrelated things.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { component, html } from "@c9up/aurora";
|
|
20
|
+
import { Avatar } from "../atoms/Avatar.js";
|
|
21
|
+
import type { Child } from "../lib/children.js";
|
|
22
|
+
import { type Slot, slot } from "../lib/children.js";
|
|
23
|
+
import { cn } from "../lib/cn.js";
|
|
24
|
+
import { type Reactive, read } from "../lib/props.js";
|
|
25
|
+
import { Bubble, type BubbleProps, type BubbleReaction } from "./Bubble.js";
|
|
26
|
+
|
|
27
|
+
export interface MessageProps {
|
|
28
|
+
/** The turn's content. Rendered inside a `Bubble`. */
|
|
29
|
+
children?: Slot;
|
|
30
|
+
author?: Child;
|
|
31
|
+
avatarSrc?: string;
|
|
32
|
+
/** Initials, when there is no picture. */
|
|
33
|
+
avatarFallback?: Child;
|
|
34
|
+
/** A `Date`, or an ISO string. Rendered as `<time datetime>`. */
|
|
35
|
+
timestamp?: Date | string;
|
|
36
|
+
/** How the timestamp reads. Defaults to a short local time. */
|
|
37
|
+
formatTime?: (date: Date) => string;
|
|
38
|
+
/** `"end"` for the current user's own turn. */
|
|
39
|
+
align?: BubbleProps["align"];
|
|
40
|
+
variant?: BubbleProps["variant"];
|
|
41
|
+
reactions?: readonly BubbleReaction[];
|
|
42
|
+
/** `Attachment`s, under the bubble. */
|
|
43
|
+
attachments?: Slot;
|
|
44
|
+
/** Shown under the bubble in place of a timestamp — "Sending…", "Failed". */
|
|
45
|
+
status?: Child;
|
|
46
|
+
class?: Reactive<string>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Accept both shapes without making every caller construct a `Date`. */
|
|
50
|
+
function asDate(value: Date | string): Date | undefined {
|
|
51
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
52
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const Message = component<MessageProps>((props) => {
|
|
56
|
+
const outgoing = props.align === "end";
|
|
57
|
+
const time =
|
|
58
|
+
props.timestamp === undefined ? undefined : asDate(props.timestamp);
|
|
59
|
+
const timeFormat = new Intl.DateTimeFormat(undefined, { timeStyle: "short" });
|
|
60
|
+
|
|
61
|
+
const hasAvatar =
|
|
62
|
+
props.avatarSrc !== undefined || props.avatarFallback !== undefined;
|
|
63
|
+
|
|
64
|
+
return html`<div
|
|
65
|
+
data-slot="message"
|
|
66
|
+
role="article"
|
|
67
|
+
data-align="${outgoing ? "end" : "start"}"
|
|
68
|
+
class="${() =>
|
|
69
|
+
cn(
|
|
70
|
+
"flex w-full items-end gap-2",
|
|
71
|
+
outgoing ? "flex-row-reverse" : "flex-row",
|
|
72
|
+
read(props.class),
|
|
73
|
+
)}"
|
|
74
|
+
>
|
|
75
|
+
${
|
|
76
|
+
hasAvatar
|
|
77
|
+
? Avatar({
|
|
78
|
+
src: props.avatarSrc,
|
|
79
|
+
alt: typeof props.author === "string" ? props.author : "",
|
|
80
|
+
fallback: props.avatarFallback,
|
|
81
|
+
class: "size-7 shrink-0",
|
|
82
|
+
})
|
|
83
|
+
: null
|
|
84
|
+
}
|
|
85
|
+
<div class="${cn("flex min-w-0 flex-1 flex-col gap-1", outgoing ? "items-end" : "items-start")}">
|
|
86
|
+
${
|
|
87
|
+
props.author === undefined
|
|
88
|
+
? null
|
|
89
|
+
: html`<span data-slot="message-author" class="text-muted-foreground px-1 text-xs font-medium"
|
|
90
|
+
>${props.author}</span
|
|
91
|
+
>`
|
|
92
|
+
}
|
|
93
|
+
${Bubble({
|
|
94
|
+
variant: props.variant,
|
|
95
|
+
align: props.align,
|
|
96
|
+
reactions: props.reactions,
|
|
97
|
+
children: props.children,
|
|
98
|
+
})}
|
|
99
|
+
${
|
|
100
|
+
props.attachments === undefined
|
|
101
|
+
? null
|
|
102
|
+
: html`<div data-slot="message-attachments" class="flex flex-wrap gap-2">
|
|
103
|
+
${slot(props.attachments)}
|
|
104
|
+
</div>`
|
|
105
|
+
}
|
|
106
|
+
${renderFooter(props, time, timeFormat)}
|
|
107
|
+
</div>
|
|
108
|
+
</div>`;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
function renderFooter(
|
|
112
|
+
props: MessageProps,
|
|
113
|
+
time: Date | undefined,
|
|
114
|
+
timeFormat: Intl.DateTimeFormat,
|
|
115
|
+
): Child {
|
|
116
|
+
if (props.status !== undefined) {
|
|
117
|
+
return html`<span data-slot="message-status" class="text-muted-foreground px-1 text-xs"
|
|
118
|
+
>${props.status}</span
|
|
119
|
+
>`;
|
|
120
|
+
}
|
|
121
|
+
if (time === undefined) return null;
|
|
122
|
+
return html`<time
|
|
123
|
+
data-slot="message-time"
|
|
124
|
+
datetime="${time.toISOString()}"
|
|
125
|
+
class="text-muted-foreground px-1 text-xs tabular-nums"
|
|
126
|
+
>${(props.formatTime ?? ((date: Date) => timeFormat.format(date)))(time)}</time>`;
|
|
127
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pagination — page links for a long list.
|
|
3
|
+
*
|
|
4
|
+
* The interesting part is `pageWindow`, which decides which page numbers to
|
|
5
|
+
* show. Rendering all of them breaks at a few hundred pages; the usual fix is
|
|
6
|
+
* a fixed window around the current page, which then jitters in width as you
|
|
7
|
+
* approach either end — the control visibly reflows while you click through
|
|
8
|
+
* it. `pageWindow` keeps the count constant instead, so the row stays the same
|
|
9
|
+
* size from page 1 to page 500.
|
|
10
|
+
*
|
|
11
|
+
* `aria-current="page"` marks the current page. Without it the only signal is
|
|
12
|
+
* the highlight, which a screen-reader user does not get.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { component, html } from "@c9up/aurora";
|
|
16
|
+
import { buttonVariants } from "../atoms/Button.js";
|
|
17
|
+
import type { Child } from "../lib/children.js";
|
|
18
|
+
import { cn } from "../lib/cn.js";
|
|
19
|
+
import {
|
|
20
|
+
ChevronLeftIcon,
|
|
21
|
+
ChevronRightIcon,
|
|
22
|
+
MoreHorizontalIcon,
|
|
23
|
+
} from "../lib/icons.js";
|
|
24
|
+
import { type Reactive, read } from "../lib/props.js";
|
|
25
|
+
|
|
26
|
+
export interface PaginationProps {
|
|
27
|
+
page: Reactive<number>;
|
|
28
|
+
pageCount: Reactive<number>;
|
|
29
|
+
/** How many numbered links to show, ellipses included. Default `7`. */
|
|
30
|
+
window?: number;
|
|
31
|
+
/** Build an href per page. Omit for a button-driven pagination. */
|
|
32
|
+
href?: (page: number) => string;
|
|
33
|
+
onPageChange?: (page: number) => void;
|
|
34
|
+
class?: Reactive<string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `null` marks a gap that renders as an ellipsis. */
|
|
38
|
+
export type PageSlot = number | null;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Which page numbers to show, at a fixed total width.
|
|
42
|
+
*
|
|
43
|
+
* Always the first page, the last page, a run around the current one, and one
|
|
44
|
+
* or two gaps — adding up to exactly `size` slots wherever the current page
|
|
45
|
+
* sits. The constant width is the whole reason this is computed rather than a
|
|
46
|
+
* range being sliced: a window that simply narrows near the ends makes the
|
|
47
|
+
* control reflow as you click through it, and the buttons move under the
|
|
48
|
+
* pointer.
|
|
49
|
+
*
|
|
50
|
+
* Near an end there is only one gap, so the run is one slot wider to make up
|
|
51
|
+
* for it. That is the case a naive implementation gets wrong.
|
|
52
|
+
*/
|
|
53
|
+
export function pageWindow(
|
|
54
|
+
page: number,
|
|
55
|
+
pageCount: number,
|
|
56
|
+
size = 7,
|
|
57
|
+
): readonly PageSlot[] {
|
|
58
|
+
if (pageCount <= size) return range(1, pageCount);
|
|
59
|
+
|
|
60
|
+
// Below five slots there is no room for first, last, a gap and a run, so
|
|
61
|
+
// the shape this function promises cannot be built.
|
|
62
|
+
const slots = Math.max(size, 5);
|
|
63
|
+
const current = Math.min(Math.max(page, 1), pageCount);
|
|
64
|
+
|
|
65
|
+
// Near an end the run absorbs the first (or last) page and there is a
|
|
66
|
+
// single gap, so it holds `slots - 2` entries. In the middle the first and
|
|
67
|
+
// last pages are separate and there are two gaps, leaving `slots - 4`.
|
|
68
|
+
const runNearEnd = slots - 2;
|
|
69
|
+
const runInMiddle = slots - 4;
|
|
70
|
+
|
|
71
|
+
if (current <= runNearEnd - 1) {
|
|
72
|
+
return [...range(1, runNearEnd), null, pageCount];
|
|
73
|
+
}
|
|
74
|
+
if (current >= pageCount - runNearEnd + 2) {
|
|
75
|
+
return [1, null, ...range(pageCount - runNearEnd + 1, pageCount)];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const before = Math.floor((runInMiddle - 1) / 2);
|
|
79
|
+
const start = current - before;
|
|
80
|
+
return [1, null, ...range(start, start + runInMiddle - 1), null, pageCount];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function range(from: number, to: number): number[] {
|
|
84
|
+
const out: number[] = [];
|
|
85
|
+
for (let value = from; value <= to; value += 1) out.push(value);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const Pagination = component<PaginationProps>((props) => {
|
|
90
|
+
const page = (): number => read(props.page) ?? 1;
|
|
91
|
+
const pageCount = (): number => Math.max(1, read(props.pageCount) ?? 1);
|
|
92
|
+
|
|
93
|
+
const go = (target: number) => (event: MouseEvent) => {
|
|
94
|
+
if (props.onPageChange === undefined) return;
|
|
95
|
+
// Only intercept when the caller wants callback-driven paging. With an
|
|
96
|
+
// `href` and no handler the link must navigate normally.
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
props.onPageChange(Math.min(Math.max(target, 1), pageCount()));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return html`<nav
|
|
102
|
+
data-slot="pagination"
|
|
103
|
+
role="navigation"
|
|
104
|
+
aria-label="pagination"
|
|
105
|
+
class="${() => cn("mx-auto flex w-full justify-center", read(props.class))}"
|
|
106
|
+
>
|
|
107
|
+
<ul data-slot="pagination-content" class="flex flex-row items-center gap-1">
|
|
108
|
+
<li>
|
|
109
|
+
${link({
|
|
110
|
+
label: html`${ChevronLeftIcon({ class: "size-4" })}<span class="hidden sm:block">Previous</span>`,
|
|
111
|
+
ariaLabel: "Go to previous page",
|
|
112
|
+
href: props.href?.(page() - 1),
|
|
113
|
+
disabled: () => page() <= 1,
|
|
114
|
+
onClick: go(page() - 1),
|
|
115
|
+
size: "default",
|
|
116
|
+
extra: "gap-1 px-2.5 sm:pl-2.5",
|
|
117
|
+
})}
|
|
118
|
+
</li>
|
|
119
|
+
${() =>
|
|
120
|
+
pageWindow(page(), pageCount(), props.window ?? 7).map(
|
|
121
|
+
(entry, index) =>
|
|
122
|
+
entry === null
|
|
123
|
+
? html`<li aria-hidden="true">
|
|
124
|
+
<span class="flex size-9 items-center justify-center">
|
|
125
|
+
${MoreHorizontalIcon({ class: "size-4" })}
|
|
126
|
+
<span class="sr-only">More pages</span>
|
|
127
|
+
</span>
|
|
128
|
+
</li>`
|
|
129
|
+
: html`<li>
|
|
130
|
+
${link({
|
|
131
|
+
label: String(entry),
|
|
132
|
+
ariaLabel: `Go to page ${entry}`,
|
|
133
|
+
href: props.href?.(entry),
|
|
134
|
+
current: entry === page(),
|
|
135
|
+
onClick: go(entry),
|
|
136
|
+
size: "icon",
|
|
137
|
+
key: index,
|
|
138
|
+
})}
|
|
139
|
+
</li>`,
|
|
140
|
+
)}
|
|
141
|
+
<li>
|
|
142
|
+
${link({
|
|
143
|
+
label: html`<span class="hidden sm:block">Next</span>${ChevronRightIcon({ class: "size-4" })}`,
|
|
144
|
+
ariaLabel: "Go to next page",
|
|
145
|
+
href: props.href?.(page() + 1),
|
|
146
|
+
disabled: () => page() >= pageCount(),
|
|
147
|
+
onClick: go(page() + 1),
|
|
148
|
+
size: "default",
|
|
149
|
+
extra: "gap-1 px-2.5 sm:pr-2.5",
|
|
150
|
+
})}
|
|
151
|
+
</li>
|
|
152
|
+
</ul>
|
|
153
|
+
</nav>`;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
interface LinkSpec {
|
|
157
|
+
label: Child;
|
|
158
|
+
ariaLabel: string;
|
|
159
|
+
href?: string;
|
|
160
|
+
current?: boolean;
|
|
161
|
+
disabled?: () => boolean;
|
|
162
|
+
onClick: (event: MouseEvent) => void;
|
|
163
|
+
size: "default" | "icon";
|
|
164
|
+
extra?: string;
|
|
165
|
+
key?: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One page link.
|
|
170
|
+
*
|
|
171
|
+
* Always an `<a>`, even without an `href`: pagination is navigation, and the
|
|
172
|
+
* role is what a screen reader uses to offer "list of links" here. The
|
|
173
|
+
* disabled ends carry `aria-disabled` plus `pointer-events-none` rather than
|
|
174
|
+
* being removed, so the control keeps its width at both ends of the range.
|
|
175
|
+
*/
|
|
176
|
+
function link(spec: LinkSpec): Child {
|
|
177
|
+
const disabled = (): boolean => spec.disabled?.() === true;
|
|
178
|
+
|
|
179
|
+
return html`<a
|
|
180
|
+
data-slot="pagination-link"
|
|
181
|
+
href="${spec.href}"
|
|
182
|
+
aria-label="${spec.ariaLabel}"
|
|
183
|
+
aria-current="${spec.current === true ? "page" : undefined}"
|
|
184
|
+
aria-disabled="${() => (disabled() ? "true" : undefined)}"
|
|
185
|
+
tabindex="${() => (disabled() ? -1 : 0)}"
|
|
186
|
+
class="${() =>
|
|
187
|
+
cn(
|
|
188
|
+
buttonVariants({
|
|
189
|
+
variant: spec.current === true ? "outline" : "ghost",
|
|
190
|
+
size: spec.size,
|
|
191
|
+
}),
|
|
192
|
+
spec.extra,
|
|
193
|
+
disabled() ? "pointer-events-none opacity-50" : "",
|
|
194
|
+
)}"
|
|
195
|
+
@click="${spec.onClick}"
|
|
196
|
+
>${spec.label}</a>`;
|
|
197
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RadioGroup — pick exactly one option.
|
|
3
|
+
*
|
|
4
|
+
* Native `<input type="radio">` throughout. The browser already implements the
|
|
5
|
+
* radio group pattern on a shared `name`: arrows move and select, only one can
|
|
6
|
+
* be checked, and the group is one tab stop. Radix reimplements all of that
|
|
7
|
+
* because it needs a stylable element; `appearance-none` gets nebula the same
|
|
8
|
+
* styling freedom while the behaviour stays the browser's.
|
|
9
|
+
*
|
|
10
|
+
* The indicator dot is a sibling shown by `peer-checked:`, so selecting a
|
|
11
|
+
* radio runs no JavaScript at all.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { component, html } from "@c9up/aurora";
|
|
15
|
+
import type { Child } from "../lib/children.js";
|
|
16
|
+
import { cn } from "../lib/cn.js";
|
|
17
|
+
import { uid } from "../lib/id.js";
|
|
18
|
+
import { type Reactive, read, readOr } from "../lib/props.js";
|
|
19
|
+
|
|
20
|
+
export interface RadioOption {
|
|
21
|
+
value: string;
|
|
22
|
+
label: Child;
|
|
23
|
+
description?: Child;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RadioGroupProps {
|
|
28
|
+
/** Shared `name`, which is what makes the browser treat these as one group. */
|
|
29
|
+
name: string;
|
|
30
|
+
options: readonly RadioOption[];
|
|
31
|
+
value?: Reactive<string | undefined>;
|
|
32
|
+
defaultValue?: string;
|
|
33
|
+
disabled?: Reactive<boolean>;
|
|
34
|
+
orientation?: "vertical" | "horizontal";
|
|
35
|
+
class?: Reactive<string>;
|
|
36
|
+
onValueChange?: (value: string) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const radioClasses =
|
|
40
|
+
"peer size-4 shrink-0 appearance-none rounded-full border border-input bg-transparent shadow-xs outline-none transition-shadow dark:bg-input/30 checked:border-primary focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50";
|
|
41
|
+
|
|
42
|
+
export const RadioGroup = component<RadioGroupProps>((props) => {
|
|
43
|
+
const selected = (): string | undefined => {
|
|
44
|
+
const controlled = read(props.value);
|
|
45
|
+
return controlled === undefined ? props.defaultValue : controlled;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return html`<div
|
|
49
|
+
role="radiogroup"
|
|
50
|
+
data-slot="radio-group"
|
|
51
|
+
aria-orientation="${props.orientation ?? "vertical"}"
|
|
52
|
+
class="${() =>
|
|
53
|
+
cn(
|
|
54
|
+
"grid gap-3",
|
|
55
|
+
props.orientation === "horizontal" ? "grid-flow-col auto-cols-max" : "",
|
|
56
|
+
read(props.class),
|
|
57
|
+
)}"
|
|
58
|
+
>
|
|
59
|
+
${props.options.map((option) => renderOption(option, props, selected))}
|
|
60
|
+
</div>`;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function renderOption(
|
|
64
|
+
option: RadioOption,
|
|
65
|
+
props: RadioGroupProps,
|
|
66
|
+
selected: () => string | undefined,
|
|
67
|
+
): Child {
|
|
68
|
+
const id = uid("radio");
|
|
69
|
+
const descriptionId =
|
|
70
|
+
option.description === undefined ? undefined : uid("radio-description");
|
|
71
|
+
|
|
72
|
+
return html`<div class="flex items-start gap-3">
|
|
73
|
+
<span class="relative flex size-4 shrink-0 items-center justify-center">
|
|
74
|
+
<input
|
|
75
|
+
type="radio"
|
|
76
|
+
data-slot="radio-group-item"
|
|
77
|
+
id="${id}"
|
|
78
|
+
name="${props.name}"
|
|
79
|
+
value="${option.value}"
|
|
80
|
+
aria-describedby="${descriptionId}"
|
|
81
|
+
?disabled="${() => option.disabled === true || readOr(props.disabled, false)}"
|
|
82
|
+
.checked="${() => selected() === option.value}"
|
|
83
|
+
class="${radioClasses}"
|
|
84
|
+
@change="${() => props.onValueChange?.(option.value)}"
|
|
85
|
+
/>
|
|
86
|
+
<span
|
|
87
|
+
class="bg-primary pointer-events-none absolute size-2 rounded-full opacity-0 transition-opacity peer-checked:opacity-100"
|
|
88
|
+
></span>
|
|
89
|
+
</span>
|
|
90
|
+
<div class="grid gap-1 leading-none">
|
|
91
|
+
<label
|
|
92
|
+
for="${id}"
|
|
93
|
+
class="text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
|
|
94
|
+
>${option.label}</label>
|
|
95
|
+
${
|
|
96
|
+
option.description === undefined
|
|
97
|
+
? null
|
|
98
|
+
: html`<p id="${descriptionId}" class="text-muted-foreground text-sm">
|
|
99
|
+
${option.description}
|
|
100
|
+
</p>`
|
|
101
|
+
}
|
|
102
|
+
</div>
|
|
103
|
+
</div>`;
|
|
104
|
+
}
|