@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,176 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Roving focus — arrow-key navigation over a group of items.
|
|
4
|
+
*
|
|
5
|
+
* The WAI-ARIA composite-widget pattern: a menu, listbox, tab list or toolbar
|
|
6
|
+
* is *one* tab stop, and arrows move within it. Exactly one item carries
|
|
7
|
+
* `tabindex="0"` at a time; the rest are `-1`. That is what stops a 40-item
|
|
8
|
+
* menu from swallowing 40 presses of Tab.
|
|
9
|
+
*
|
|
10
|
+
* Used by DropdownMenu, ContextMenu, Menubar, Select, Combobox, Command, Tabs
|
|
11
|
+
* and ToggleGroup. The differences between them are configuration, not
|
|
12
|
+
* behaviour: orientation, whether the ends wrap, and whether moving focus also
|
|
13
|
+
* selects.
|
|
14
|
+
*
|
|
15
|
+
* Items are found by querying the container on each keypress rather than being
|
|
16
|
+
* registered up front. Menu contents are reactive — filtered by a Command
|
|
17
|
+
* input, grown by a loaded page — and a registry captured at mount goes stale
|
|
18
|
+
* the moment they change. The query costs a few microseconds per keypress,
|
|
19
|
+
* which is not a budget worth optimising against correctness.
|
|
20
|
+
*/
|
|
21
|
+
import { focusSilently, isVisible } from "./focusable.js";
|
|
22
|
+
const DEFAULT_ITEM_SELECTOR = "[data-nebula-item]";
|
|
23
|
+
export function rovingFocus(options) {
|
|
24
|
+
const selector = options.itemSelector ?? DEFAULT_ITEM_SELECTOR;
|
|
25
|
+
const orientation = options.orientation ?? "vertical";
|
|
26
|
+
const loop = options.loop !== false;
|
|
27
|
+
function items() {
|
|
28
|
+
const container = options.container();
|
|
29
|
+
if (container === null)
|
|
30
|
+
return [];
|
|
31
|
+
const found = [];
|
|
32
|
+
for (const node of container.querySelectorAll(selector)) {
|
|
33
|
+
if (!(node instanceof HTMLElement))
|
|
34
|
+
continue;
|
|
35
|
+
// Disabled items stay in the DOM for layout and screen-reader context
|
|
36
|
+
// but must not be landed on — matching how a disabled menu item behaves
|
|
37
|
+
// everywhere else.
|
|
38
|
+
if (node.hasAttribute("data-disabled"))
|
|
39
|
+
continue;
|
|
40
|
+
if (node.getAttribute("aria-disabled") === "true")
|
|
41
|
+
continue;
|
|
42
|
+
if (!isVisible(node))
|
|
43
|
+
continue;
|
|
44
|
+
found.push(node);
|
|
45
|
+
}
|
|
46
|
+
return found;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* One item at `tabindex="0"`, everything else at `-1`.
|
|
50
|
+
*
|
|
51
|
+
* The active one is whichever currently holds focus, falling back to the
|
|
52
|
+
* first. The fallback is what makes the group re-enterable by Tab after
|
|
53
|
+
* focus has left it.
|
|
54
|
+
*/
|
|
55
|
+
function sync() {
|
|
56
|
+
const list = items();
|
|
57
|
+
const focused = document.activeElement;
|
|
58
|
+
const activeIndex = focused instanceof HTMLElement ? list.indexOf(focused) : -1;
|
|
59
|
+
const active = activeIndex === -1 ? 0 : activeIndex;
|
|
60
|
+
list.forEach((item, index) => {
|
|
61
|
+
item.tabIndex = index === active ? 0 : -1;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function move(delta) {
|
|
65
|
+
const list = items();
|
|
66
|
+
if (list.length === 0)
|
|
67
|
+
return;
|
|
68
|
+
const focused = document.activeElement;
|
|
69
|
+
const current = focused instanceof HTMLElement ? list.indexOf(focused) : -1;
|
|
70
|
+
const next = step(current, delta, list.length, loop);
|
|
71
|
+
if (next === null)
|
|
72
|
+
return;
|
|
73
|
+
const target = list[next];
|
|
74
|
+
if (target === undefined)
|
|
75
|
+
return;
|
|
76
|
+
for (const item of list)
|
|
77
|
+
item.tabIndex = -1;
|
|
78
|
+
target.tabIndex = 0;
|
|
79
|
+
focusSilently(target);
|
|
80
|
+
options.onFocusChange?.(target);
|
|
81
|
+
}
|
|
82
|
+
function focusEdge(edge) {
|
|
83
|
+
const list = items();
|
|
84
|
+
const target = edge === "first" ? list[0] : list[list.length - 1];
|
|
85
|
+
if (target === undefined)
|
|
86
|
+
return;
|
|
87
|
+
for (const item of list)
|
|
88
|
+
item.tabIndex = -1;
|
|
89
|
+
target.tabIndex = 0;
|
|
90
|
+
focusSilently(target);
|
|
91
|
+
options.onFocusChange?.(target);
|
|
92
|
+
}
|
|
93
|
+
function onKeyDown(event) {
|
|
94
|
+
const container = options.container();
|
|
95
|
+
if (container === null)
|
|
96
|
+
return;
|
|
97
|
+
if (event.target instanceof Node && !container.contains(event.target))
|
|
98
|
+
return;
|
|
99
|
+
// A modifier means the user is talking to the browser, not the widget.
|
|
100
|
+
if (event.altKey || event.ctrlKey || event.metaKey)
|
|
101
|
+
return;
|
|
102
|
+
const direction = directionFor(event.key, orientation);
|
|
103
|
+
if (direction !== 0) {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
move(direction);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (event.key === "Home") {
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
focusEdge("first");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (event.key === "End") {
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
focusEdge("last");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
119
|
+
const active = document.activeElement;
|
|
120
|
+
if (!(active instanceof HTMLElement))
|
|
121
|
+
return;
|
|
122
|
+
if (!items().includes(active))
|
|
123
|
+
return;
|
|
124
|
+
// Space would scroll the page and Enter would submit a surrounding form;
|
|
125
|
+
// inside a composite widget both mean "activate this item".
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
options.onSelect?.(active, event);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
131
|
+
let destroyed = false;
|
|
132
|
+
return {
|
|
133
|
+
focusFirst: () => focusEdge("first"),
|
|
134
|
+
focusLast: () => focusEdge("last"),
|
|
135
|
+
items,
|
|
136
|
+
sync,
|
|
137
|
+
destroy() {
|
|
138
|
+
if (destroyed)
|
|
139
|
+
return;
|
|
140
|
+
destroyed = true;
|
|
141
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** `-1` for previous, `+1` for next, `0` when the key is not ours. */
|
|
146
|
+
function directionFor(key, orientation) {
|
|
147
|
+
const vertical = orientation === "vertical" || orientation === "both";
|
|
148
|
+
const horizontal = orientation === "horizontal" || orientation === "both";
|
|
149
|
+
if (vertical && key === "ArrowDown")
|
|
150
|
+
return 1;
|
|
151
|
+
if (vertical && key === "ArrowUp")
|
|
152
|
+
return -1;
|
|
153
|
+
if (horizontal && key === "ArrowRight")
|
|
154
|
+
return 1;
|
|
155
|
+
if (horizontal && key === "ArrowLeft")
|
|
156
|
+
return -1;
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Next index, or `null` when the move is refused at an unlooped end.
|
|
161
|
+
*
|
|
162
|
+
* `current === -1` means focus is not on an item yet — the first arrow press
|
|
163
|
+
* after opening a menu. It enters at the first item going down and the last
|
|
164
|
+
* going up, which is what makes ArrowUp on a fresh menu land on its bottom
|
|
165
|
+
* entry the way every native menu does.
|
|
166
|
+
*/
|
|
167
|
+
function step(current, delta, length, loop) {
|
|
168
|
+
if (current === -1)
|
|
169
|
+
return delta > 0 ? 0 : length - 1;
|
|
170
|
+
const next = current + delta;
|
|
171
|
+
if (next >= 0 && next < length)
|
|
172
|
+
return next;
|
|
173
|
+
if (!loop)
|
|
174
|
+
return null;
|
|
175
|
+
return next < 0 ? length - 1 : 0;
|
|
176
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Body scroll lock — stop the page scrolling behind a modal surface.
|
|
3
|
+
*
|
|
4
|
+
* Two details separate a working lock from the naive `overflow: hidden`:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Reference counting.** Two modals can be open at once (a confirm dialog
|
|
7
|
+
* over a sheet). The second lock must not re-read the "original" overflow,
|
|
8
|
+
* which is by then already `hidden`, and the first release must not unlock
|
|
9
|
+
* while the second is still up.
|
|
10
|
+
* 2. **Scrollbar compensation.** Hiding overflow removes the scrollbar, the
|
|
11
|
+
* viewport widens by its width, and the whole page jumps sideways as the
|
|
12
|
+
* modal opens. Padding the body by exactly that width holds it still.
|
|
13
|
+
*
|
|
14
|
+
* The saved styles are the *inline* ones, not the computed ones. Restoring a
|
|
15
|
+
* computed value would write a hardcoded padding onto a body that had none,
|
|
16
|
+
* and the layout would stay shifted after the last modal closed.
|
|
17
|
+
*/
|
|
18
|
+
/** Lock page scrolling. Returns the matching unlock; safe to call twice. */
|
|
19
|
+
export declare function lockScroll(): () => void;
|
|
20
|
+
/** Is the page currently locked? Exposed for tests. */
|
|
21
|
+
export declare function isScrollLocked(): boolean;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Body scroll lock — stop the page scrolling behind a modal surface.
|
|
4
|
+
*
|
|
5
|
+
* Two details separate a working lock from the naive `overflow: hidden`:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Reference counting.** Two modals can be open at once (a confirm dialog
|
|
8
|
+
* over a sheet). The second lock must not re-read the "original" overflow,
|
|
9
|
+
* which is by then already `hidden`, and the first release must not unlock
|
|
10
|
+
* while the second is still up.
|
|
11
|
+
* 2. **Scrollbar compensation.** Hiding overflow removes the scrollbar, the
|
|
12
|
+
* viewport widens by its width, and the whole page jumps sideways as the
|
|
13
|
+
* modal opens. Padding the body by exactly that width holds it still.
|
|
14
|
+
*
|
|
15
|
+
* The saved styles are the *inline* ones, not the computed ones. Restoring a
|
|
16
|
+
* computed value would write a hardcoded padding onto a body that had none,
|
|
17
|
+
* and the layout would stay shifted after the last modal closed.
|
|
18
|
+
*/
|
|
19
|
+
let depth = 0;
|
|
20
|
+
let savedOverflow = "";
|
|
21
|
+
let savedPaddingRight = "";
|
|
22
|
+
/** Lock page scrolling. Returns the matching unlock; safe to call twice. */
|
|
23
|
+
export function lockScroll() {
|
|
24
|
+
if (typeof document === "undefined")
|
|
25
|
+
return () => { };
|
|
26
|
+
depth += 1;
|
|
27
|
+
if (depth === 1)
|
|
28
|
+
applyLock();
|
|
29
|
+
let released = false;
|
|
30
|
+
return () => {
|
|
31
|
+
if (released)
|
|
32
|
+
return;
|
|
33
|
+
released = true;
|
|
34
|
+
depth -= 1;
|
|
35
|
+
if (depth === 0)
|
|
36
|
+
removeLock();
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Is the page currently locked? Exposed for tests. */
|
|
40
|
+
export function isScrollLocked() {
|
|
41
|
+
return depth > 0;
|
|
42
|
+
}
|
|
43
|
+
function applyLock() {
|
|
44
|
+
const body = document.body;
|
|
45
|
+
savedOverflow = body.style.overflow;
|
|
46
|
+
savedPaddingRight = body.style.paddingRight;
|
|
47
|
+
const gap = scrollbarWidth();
|
|
48
|
+
if (gap > 0) {
|
|
49
|
+
// Add to whatever padding the body already has rather than replacing it.
|
|
50
|
+
const existing = Number.parseFloat(getComputedStyle(body).paddingRight);
|
|
51
|
+
const base = Number.isNaN(existing) ? 0 : existing;
|
|
52
|
+
body.style.paddingRight = `${base + gap}px`;
|
|
53
|
+
}
|
|
54
|
+
body.style.overflow = "hidden";
|
|
55
|
+
}
|
|
56
|
+
function removeLock() {
|
|
57
|
+
const body = document.body;
|
|
58
|
+
body.style.overflow = savedOverflow;
|
|
59
|
+
body.style.paddingRight = savedPaddingRight;
|
|
60
|
+
savedOverflow = "";
|
|
61
|
+
savedPaddingRight = "";
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Width of the vertical scrollbar, or 0 when it overlays the content.
|
|
65
|
+
*
|
|
66
|
+
* `innerWidth - documentElement.clientWidth` measures the real gutter, which
|
|
67
|
+
* is 0 on macOS overlay scrollbars and on touch devices. Measuring it beats
|
|
68
|
+
* assuming a value, because assuming produces a *reverse* jump on exactly the
|
|
69
|
+
* platforms where there is no scrollbar to compensate for.
|
|
70
|
+
*/
|
|
71
|
+
function scrollbarWidth() {
|
|
72
|
+
if (typeof window === "undefined")
|
|
73
|
+
return 0;
|
|
74
|
+
return Math.max(0, window.innerWidth - document.documentElement.clientWidth);
|
|
75
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-ahead — jump to an item by typing its first letters.
|
|
3
|
+
*
|
|
4
|
+
* Native `<select>` does it, so a listbox or menu that does not feels broken.
|
|
5
|
+
* The WAI-ARIA practices call for it on listbox, menu, tree and combobox.
|
|
6
|
+
*
|
|
7
|
+
* Two rules carry the whole behaviour, and both come from the native control:
|
|
8
|
+
*
|
|
9
|
+
* - Keys typed within the timeout build one search string, so "st" reaches
|
|
10
|
+
* "Stockholm" rather than stopping at anything beginning with "t".
|
|
11
|
+
* - Repeating a single letter cycles through the items starting with it, which
|
|
12
|
+
* is how you reach the third "S" entry without knowing the rest of its name.
|
|
13
|
+
*
|
|
14
|
+
* Matching starts *after* the currently focused item and wraps, so a match
|
|
15
|
+
* always moves forward — otherwise every search would snap back to the first
|
|
16
|
+
* hit in the list and the cycling rule could never take effect.
|
|
17
|
+
*/
|
|
18
|
+
export interface TypeaheadOptions {
|
|
19
|
+
/** Current candidates, in display order. Re-read on every keypress. */
|
|
20
|
+
items: () => readonly HTMLElement[];
|
|
21
|
+
/** The item to search from. Defaults to whatever has focus. */
|
|
22
|
+
current?: () => HTMLElement | null;
|
|
23
|
+
/** A match was found. */
|
|
24
|
+
onMatch: (item: HTMLElement) => void;
|
|
25
|
+
/** Text to match against. Defaults to the item's trimmed text content. */
|
|
26
|
+
textOf?: (item: HTMLElement) => string;
|
|
27
|
+
/** How long keys keep accumulating. Default `1000`ms. */
|
|
28
|
+
resetAfter?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface Typeahead {
|
|
31
|
+
/**
|
|
32
|
+
* Feed a keydown in. Returns `true` when the key was consumed as search
|
|
33
|
+
* input, so the caller can skip its own handling for that press.
|
|
34
|
+
*/
|
|
35
|
+
handleKey(event: KeyboardEvent): boolean;
|
|
36
|
+
/** Drop the buffer — call when the surface closes. */
|
|
37
|
+
reset(): void;
|
|
38
|
+
}
|
|
39
|
+
export declare function typeahead(options: TypeaheadOptions): Typeahead;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Type-ahead — jump to an item by typing its first letters.
|
|
4
|
+
*
|
|
5
|
+
* Native `<select>` does it, so a listbox or menu that does not feels broken.
|
|
6
|
+
* The WAI-ARIA practices call for it on listbox, menu, tree and combobox.
|
|
7
|
+
*
|
|
8
|
+
* Two rules carry the whole behaviour, and both come from the native control:
|
|
9
|
+
*
|
|
10
|
+
* - Keys typed within the timeout build one search string, so "st" reaches
|
|
11
|
+
* "Stockholm" rather than stopping at anything beginning with "t".
|
|
12
|
+
* - Repeating a single letter cycles through the items starting with it, which
|
|
13
|
+
* is how you reach the third "S" entry without knowing the rest of its name.
|
|
14
|
+
*
|
|
15
|
+
* Matching starts *after* the currently focused item and wraps, so a match
|
|
16
|
+
* always moves forward — otherwise every search would snap back to the first
|
|
17
|
+
* hit in the list and the cycling rule could never take effect.
|
|
18
|
+
*/
|
|
19
|
+
const RESET_AFTER_MS = 1000;
|
|
20
|
+
export function typeahead(options) {
|
|
21
|
+
const resetAfter = options.resetAfter ?? RESET_AFTER_MS;
|
|
22
|
+
let buffer = "";
|
|
23
|
+
let timer;
|
|
24
|
+
function reset() {
|
|
25
|
+
buffer = "";
|
|
26
|
+
if (timer !== undefined)
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
timer = undefined;
|
|
29
|
+
}
|
|
30
|
+
function restartTimer() {
|
|
31
|
+
if (timer !== undefined)
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
timer = setTimeout(reset, resetAfter);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
reset,
|
|
37
|
+
handleKey(event) {
|
|
38
|
+
if (!isSearchKey(event, buffer.length > 0))
|
|
39
|
+
return false;
|
|
40
|
+
buffer += event.key.toLowerCase();
|
|
41
|
+
restartTimer();
|
|
42
|
+
const items = options.items();
|
|
43
|
+
if (items.length === 0)
|
|
44
|
+
return true;
|
|
45
|
+
// A buffer of one repeated character means "next item starting with it",
|
|
46
|
+
// not "item starting with that string repeated".
|
|
47
|
+
const query = allSameCharacter(buffer) ? (buffer[0] ?? "") : buffer;
|
|
48
|
+
const skipCurrent = allSameCharacter(buffer) && buffer.length > 1;
|
|
49
|
+
const from = startIndex(items, options.current?.() ?? focusedElement());
|
|
50
|
+
const match = findFrom(items, query, from, skipCurrent, options.textOf);
|
|
51
|
+
if (match !== null)
|
|
52
|
+
options.onMatch(match);
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Is this keypress search input?
|
|
59
|
+
*
|
|
60
|
+
* Single printable characters only. `event.key` is `"a"` for a letter but
|
|
61
|
+
* `"ArrowDown"` or `"Enter"` for a control key, so a length check separates
|
|
62
|
+
* them without a keycode table. Modifiers are excluded because Ctrl+F belongs
|
|
63
|
+
* to the browser — but Shift is not, since it is how you type a capital.
|
|
64
|
+
*
|
|
65
|
+
* Space is the one character that depends on context. On an empty buffer it
|
|
66
|
+
* means "activate the focused item" and must reach the caller untouched; once
|
|
67
|
+
* a search is under way it is a word separator, and swallowing it is what lets
|
|
68
|
+
* "new y" reach "New York".
|
|
69
|
+
*/
|
|
70
|
+
function isSearchKey(event, hasBuffer) {
|
|
71
|
+
if (event.ctrlKey || event.metaKey || event.altKey)
|
|
72
|
+
return false;
|
|
73
|
+
if (event.key.length !== 1)
|
|
74
|
+
return false;
|
|
75
|
+
if (event.key === " ")
|
|
76
|
+
return hasBuffer;
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
function allSameCharacter(value) {
|
|
80
|
+
if (value.length < 2)
|
|
81
|
+
return true;
|
|
82
|
+
const first = value[0];
|
|
83
|
+
for (const character of value) {
|
|
84
|
+
if (character !== first)
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
function focusedElement() {
|
|
90
|
+
if (typeof document === "undefined")
|
|
91
|
+
return null;
|
|
92
|
+
const active = document.activeElement;
|
|
93
|
+
return active instanceof HTMLElement ? active : null;
|
|
94
|
+
}
|
|
95
|
+
function startIndex(items, current) {
|
|
96
|
+
if (current === null)
|
|
97
|
+
return 0;
|
|
98
|
+
const index = items.indexOf(current);
|
|
99
|
+
return index === -1 ? 0 : index;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* First item matching `query`, searching forward from `from` and wrapping.
|
|
103
|
+
*
|
|
104
|
+
* `skipCurrent` is the cycling rule: repeating a letter must advance past the
|
|
105
|
+
* item already selected. A fresh search keeps the current item eligible, so
|
|
106
|
+
* typing the first letters of what is already highlighted does not jump away.
|
|
107
|
+
*/
|
|
108
|
+
function findFrom(items, query, from, skipCurrent, textOf) {
|
|
109
|
+
if (query === "")
|
|
110
|
+
return null;
|
|
111
|
+
const offset = skipCurrent ? 1 : 0;
|
|
112
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
113
|
+
const item = items[(from + i + offset) % items.length];
|
|
114
|
+
if (item === undefined)
|
|
115
|
+
continue;
|
|
116
|
+
const text = (textOf?.(item) ?? item.textContent ?? "")
|
|
117
|
+
.trim()
|
|
118
|
+
.toLowerCase();
|
|
119
|
+
if (text.startsWith(query))
|
|
120
|
+
return item;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AppShell — sidebar, header, content.
|
|
3
|
+
*
|
|
4
|
+
* The layout behind almost every signed-in page. Shipping it is what makes
|
|
5
|
+
* nebula an atomic-design library rather than a component bag: shadcn stops at
|
|
6
|
+
* organisms and leaves the page skeleton to be rebuilt per app, which is why
|
|
7
|
+
* two pages in the same product end up with different header heights.
|
|
8
|
+
*
|
|
9
|
+
* `h-svh` on the shell and `overflow-auto` on the content column, not on the
|
|
10
|
+
* body. That is what keeps the header and the sidebar fixed while only the
|
|
11
|
+
* content scrolls — pinning them with `position: fixed` instead means
|
|
12
|
+
* hand-maintaining a top padding that matches the header, and it breaks the
|
|
13
|
+
* moment the header wraps to two lines.
|
|
14
|
+
*
|
|
15
|
+
* `svh` rather than `vh` because mobile browsers shrink the viewport as their
|
|
16
|
+
* address bar retracts; `vh` leaves the last hundred pixels of a full-height
|
|
17
|
+
* layout under the browser chrome.
|
|
18
|
+
*/
|
|
19
|
+
import { type Slot } from "../lib/children.js";
|
|
20
|
+
import { type Reactive } from "../lib/props.js";
|
|
21
|
+
export interface AppShellProps {
|
|
22
|
+
/** The navigation column — usually a `Sidebar`. */
|
|
23
|
+
sidebar?: Slot;
|
|
24
|
+
/** The top bar, spanning the content column. */
|
|
25
|
+
header?: Slot;
|
|
26
|
+
children?: Slot;
|
|
27
|
+
class?: Reactive<string>;
|
|
28
|
+
contentClass?: Reactive<string>;
|
|
29
|
+
}
|
|
30
|
+
export declare const AppShell: (props?: AppShellProps | undefined) => import("@c9up/aurora").TemplateResult;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AppShell — sidebar, header, content.
|
|
3
|
+
*
|
|
4
|
+
* The layout behind almost every signed-in page. Shipping it is what makes
|
|
5
|
+
* nebula an atomic-design library rather than a component bag: shadcn stops at
|
|
6
|
+
* organisms and leaves the page skeleton to be rebuilt per app, which is why
|
|
7
|
+
* two pages in the same product end up with different header heights.
|
|
8
|
+
*
|
|
9
|
+
* `h-svh` on the shell and `overflow-auto` on the content column, not on the
|
|
10
|
+
* body. That is what keeps the header and the sidebar fixed while only the
|
|
11
|
+
* content scrolls — pinning them with `position: fixed` instead means
|
|
12
|
+
* hand-maintaining a top padding that matches the header, and it breaks the
|
|
13
|
+
* moment the header wraps to two lines.
|
|
14
|
+
*
|
|
15
|
+
* `svh` rather than `vh` because mobile browsers shrink the viewport as their
|
|
16
|
+
* address bar retracts; `vh` leaves the last hundred pixels of a full-height
|
|
17
|
+
* layout under the browser chrome.
|
|
18
|
+
*/
|
|
19
|
+
import { component, html } from "@c9up/aurora";
|
|
20
|
+
import { slot } from "../lib/children.js";
|
|
21
|
+
import { cn } from "../lib/cn.js";
|
|
22
|
+
import { read } from "../lib/props.js";
|
|
23
|
+
export const AppShell = component((props) => {
|
|
24
|
+
return html `<div
|
|
25
|
+
data-slot="app-shell"
|
|
26
|
+
class="${() => cn("bg-background flex h-svh w-full overflow-hidden", read(props.class))}"
|
|
27
|
+
>
|
|
28
|
+
${slot(props.sidebar)}
|
|
29
|
+
<div data-slot="app-shell-main" class="flex min-w-0 flex-1 flex-col">
|
|
30
|
+
${props.header === undefined
|
|
31
|
+
? null
|
|
32
|
+
: html `<header
|
|
33
|
+
data-slot="app-shell-header"
|
|
34
|
+
class="bg-background flex h-14 shrink-0 items-center gap-2 border-b px-4"
|
|
35
|
+
>${slot(props.header)}</header>`}
|
|
36
|
+
<main
|
|
37
|
+
data-slot="app-shell-content"
|
|
38
|
+
class="${() => cn("min-h-0 flex-1 overflow-auto p-4 md:p-6", read(props.contentClass))}"
|
|
39
|
+
>${slot(props.children)}</main>
|
|
40
|
+
</div>
|
|
41
|
+
</div>`;
|
|
42
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthLayout — a centred card for sign-in, sign-up and password reset.
|
|
3
|
+
*
|
|
4
|
+
* `min-h-svh` with the card centred by flex, rather than absolute positioning
|
|
5
|
+
* and a translate. Centring by transform breaks as soon as the card grows
|
|
6
|
+
* taller than the viewport — a sign-up form with four fields and a validation
|
|
7
|
+
* error on each — because the top half goes above the fold with nothing to
|
|
8
|
+
* scroll to.
|
|
9
|
+
*/
|
|
10
|
+
import type { Child } from "../lib/children.js";
|
|
11
|
+
import { type Slot } from "../lib/children.js";
|
|
12
|
+
import { type Reactive } from "../lib/props.js";
|
|
13
|
+
export interface AuthLayoutProps {
|
|
14
|
+
/** Product name or logo, above the card. */
|
|
15
|
+
brand?: Slot;
|
|
16
|
+
title: Child;
|
|
17
|
+
description?: Child;
|
|
18
|
+
children?: Slot;
|
|
19
|
+
/** Under the card — "Already have an account?" and the like. */
|
|
20
|
+
footer?: Slot;
|
|
21
|
+
class?: Reactive<string>;
|
|
22
|
+
}
|
|
23
|
+
export declare const AuthLayout: (props?: AuthLayoutProps | undefined) => import("@c9up/aurora").TemplateResult;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthLayout — a centred card for sign-in, sign-up and password reset.
|
|
3
|
+
*
|
|
4
|
+
* `min-h-svh` with the card centred by flex, rather than absolute positioning
|
|
5
|
+
* and a translate. Centring by transform breaks as soon as the card grows
|
|
6
|
+
* taller than the viewport — a sign-up form with four fields and a validation
|
|
7
|
+
* error on each — because the top half goes above the fold with nothing to
|
|
8
|
+
* scroll to.
|
|
9
|
+
*/
|
|
10
|
+
import { component, html } from "@c9up/aurora";
|
|
11
|
+
import { slot } from "../lib/children.js";
|
|
12
|
+
import { cn } from "../lib/cn.js";
|
|
13
|
+
import { read } from "../lib/props.js";
|
|
14
|
+
export const AuthLayout = component((props) => {
|
|
15
|
+
return html `<div
|
|
16
|
+
data-slot="auth-layout"
|
|
17
|
+
class="${() => cn("bg-muted/40 flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10", read(props.class))}"
|
|
18
|
+
>
|
|
19
|
+
<div class="flex w-full max-w-sm flex-col gap-6">
|
|
20
|
+
${props.brand === undefined
|
|
21
|
+
? null
|
|
22
|
+
: html `<div class="flex justify-center">${slot(props.brand)}</div>`}
|
|
23
|
+
<div
|
|
24
|
+
data-slot="auth-card"
|
|
25
|
+
class="bg-card text-card-foreground flex flex-col gap-6 rounded-xl border p-6 shadow-sm"
|
|
26
|
+
>
|
|
27
|
+
<div class="flex flex-col gap-1.5 text-center">
|
|
28
|
+
<h1 class="text-xl leading-none font-semibold">${props.title}</h1>
|
|
29
|
+
${props.description === undefined
|
|
30
|
+
? null
|
|
31
|
+
: html `<p class="text-muted-foreground text-sm">${props.description}</p>`}
|
|
32
|
+
</div>
|
|
33
|
+
${slot(props.children)}
|
|
34
|
+
</div>
|
|
35
|
+
${props.footer === undefined
|
|
36
|
+
? null
|
|
37
|
+
: html `<div class="text-muted-foreground text-center text-sm">
|
|
38
|
+
${slot(props.footer)}
|
|
39
|
+
</div>`}
|
|
40
|
+
</div>
|
|
41
|
+
</div>`;
|
|
42
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SettingsLayout — a section list beside a settings panel.
|
|
3
|
+
*
|
|
4
|
+
* The list is a real `<nav>` of links, not a Tabs component. Settings sections
|
|
5
|
+
* are pages: they need their own URL so a link to "Billing" works, and so the
|
|
6
|
+
* back button returns to the previous section rather than leaving the page
|
|
7
|
+
* entirely. Tabs would give the same look and none of that.
|
|
8
|
+
*
|
|
9
|
+
* The list sits above the panel on narrow screens and beside it from `md` up,
|
|
10
|
+
* which is the one responsive decision this layout has to make.
|
|
11
|
+
*/
|
|
12
|
+
import type { Child } from "../lib/children.js";
|
|
13
|
+
import { type Slot } from "../lib/children.js";
|
|
14
|
+
import { type Reactive } from "../lib/props.js";
|
|
15
|
+
export interface SettingsSection {
|
|
16
|
+
label: Child;
|
|
17
|
+
href: string;
|
|
18
|
+
active?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface SettingsLayoutProps {
|
|
21
|
+
title: Child;
|
|
22
|
+
description?: Child;
|
|
23
|
+
sections: readonly SettingsSection[];
|
|
24
|
+
children?: Slot;
|
|
25
|
+
class?: Reactive<string>;
|
|
26
|
+
}
|
|
27
|
+
export declare const SettingsLayout: (props?: SettingsLayoutProps | undefined) => import("@c9up/aurora").TemplateResult;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SettingsLayout — a section list beside a settings panel.
|
|
3
|
+
*
|
|
4
|
+
* The list is a real `<nav>` of links, not a Tabs component. Settings sections
|
|
5
|
+
* are pages: they need their own URL so a link to "Billing" works, and so the
|
|
6
|
+
* back button returns to the previous section rather than leaving the page
|
|
7
|
+
* entirely. Tabs would give the same look and none of that.
|
|
8
|
+
*
|
|
9
|
+
* The list sits above the panel on narrow screens and beside it from `md` up,
|
|
10
|
+
* which is the one responsive decision this layout has to make.
|
|
11
|
+
*/
|
|
12
|
+
import { component, html } from "@c9up/aurora";
|
|
13
|
+
import { slot } from "../lib/children.js";
|
|
14
|
+
import { cn } from "../lib/cn.js";
|
|
15
|
+
import { read } from "../lib/props.js";
|
|
16
|
+
export const SettingsLayout = component((props) => {
|
|
17
|
+
return html `<div
|
|
18
|
+
data-slot="settings-layout"
|
|
19
|
+
class="${() => cn("flex flex-col gap-6 p-4 md:p-10", read(props.class))}"
|
|
20
|
+
>
|
|
21
|
+
<div class="flex flex-col gap-1.5">
|
|
22
|
+
<h1 class="text-2xl font-semibold tracking-tight">${props.title}</h1>
|
|
23
|
+
${props.description === undefined
|
|
24
|
+
? null
|
|
25
|
+
: html `<p class="text-muted-foreground text-sm">${props.description}</p>`}
|
|
26
|
+
</div>
|
|
27
|
+
<div class="flex flex-col gap-8 md:flex-row md:gap-12">
|
|
28
|
+
<nav
|
|
29
|
+
data-slot="settings-nav"
|
|
30
|
+
aria-label="Settings sections"
|
|
31
|
+
class="flex gap-2 overflow-x-auto md:w-48 md:shrink-0 md:flex-col md:overflow-visible"
|
|
32
|
+
>
|
|
33
|
+
${props.sections.map((section) => html `<a
|
|
34
|
+
href="${section.href}"
|
|
35
|
+
aria-current="${section.active === true ? "page" : undefined}"
|
|
36
|
+
class="hover:bg-accent hover:text-accent-foreground aria-[current=page]:bg-accent aria-[current=page]:font-medium rounded-md px-3 py-2 text-sm whitespace-nowrap transition-colors"
|
|
37
|
+
>${section.label}</a>`)}
|
|
38
|
+
</nav>
|
|
39
|
+
<div data-slot="settings-panel" class="min-w-0 flex-1">${slot(props.children)}</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>`;
|
|
42
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Templates — page skeletons.
|
|
3
|
+
*
|
|
4
|
+
* Atomic design's fourth level, and the one shadcn does not have: it stops at
|
|
5
|
+
* organisms and ships "blocks" as copy-paste examples instead. The gap shows
|
|
6
|
+
* up as drift — two pages in the same product with different header heights
|
|
7
|
+
* and different content padding, because each was assembled from organisms by
|
|
8
|
+
* hand.
|
|
9
|
+
*
|
|
10
|
+
* A template is layout only. It positions slots and owns no state, no data and
|
|
11
|
+
* no behaviour, which is what keeps it usable for pages it was not written
|
|
12
|
+
* with in mind.
|
|
13
|
+
*/
|
|
14
|
+
export { AppShell, type AppShellProps } from "./AppShell.js";
|
|
15
|
+
export { AuthLayout, type AuthLayoutProps } from "./AuthLayout.js";
|
|
16
|
+
export { SettingsLayout, type SettingsLayoutProps, type SettingsSection, } from "./SettingsLayout.js";
|