@lyeve-labs/ui-kit 0.11.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/components/AccordionItem.svelte +1 -1
- package/dist/components/Autocomplete.svelte +191 -125
- package/dist/components/Autocomplete.svelte.d.ts +29 -8
- package/dist/components/Button.svelte +26 -4
- package/dist/components/Card.svelte +61 -3
- package/dist/components/Card.svelte.d.ts +24 -2
- package/dist/components/Checkbox.svelte +174 -59
- package/dist/components/Checkbox.svelte.d.ts +20 -3
- package/dist/components/CheckboxGroup.svelte +162 -0
- package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
- package/dist/components/Collapsible.svelte +142 -0
- package/dist/components/Collapsible.svelte.d.ts +32 -0
- package/dist/components/CopyButton.svelte +126 -0
- package/dist/components/CopyButton.svelte.d.ts +14 -0
- package/dist/components/DatePicker.svelte +48 -6
- package/dist/components/DateTimePicker.svelte +337 -0
- package/dist/components/DateTimePicker.svelte.d.ts +26 -0
- package/dist/components/DescriptionList.svelte +78 -0
- package/dist/components/DescriptionList.svelte.d.ts +34 -0
- package/dist/components/Drawer.svelte +15 -4
- package/dist/components/Field.svelte +104 -0
- package/dist/components/Field.svelte.d.ts +46 -0
- package/dist/components/FileInput.svelte +5 -2
- package/dist/components/FormMessage.svelte +85 -0
- package/dist/components/FormMessage.svelte.d.ts +11 -0
- package/dist/components/Input.svelte +1 -1
- package/dist/components/Label.svelte +7 -1
- package/dist/components/Label.svelte.d.ts +6 -0
- package/dist/components/Modal.svelte +25 -8
- package/dist/components/MultiSelect.svelte +199 -109
- package/dist/components/MultiSelect.svelte.d.ts +22 -9
- package/dist/components/NumberInput.svelte +8 -4
- package/dist/components/PageHeader.svelte +37 -4
- package/dist/components/PageHeader.svelte.d.ts +15 -0
- package/dist/components/PageShell.svelte +85 -0
- package/dist/components/PageShell.svelte.d.ts +38 -0
- package/dist/components/Pagination.svelte +58 -17
- package/dist/components/Panel.svelte +101 -0
- package/dist/components/Panel.svelte.d.ts +39 -0
- package/dist/components/PasswordInput.svelte +139 -0
- package/dist/components/PasswordInput.svelte.d.ts +29 -0
- package/dist/components/Radio.svelte +152 -32
- package/dist/components/Radio.svelte.d.ts +16 -1
- package/dist/components/RadioGroup.svelte +118 -71
- package/dist/components/RadioGroup.svelte.d.ts +39 -9
- package/dist/components/SectionHeading.svelte +39 -0
- package/dist/components/SectionHeading.svelte.d.ts +21 -0
- package/dist/components/SegmentedControl.svelte +194 -0
- package/dist/components/SegmentedControl.svelte.d.ts +55 -0
- package/dist/components/Select.svelte +471 -46
- package/dist/components/Select.svelte.d.ts +95 -6
- package/dist/components/SidebarNav.svelte +259 -0
- package/dist/components/SidebarNav.svelte.d.ts +17 -0
- package/dist/components/Stat.svelte +53 -2
- package/dist/components/Stat.svelte.d.ts +31 -0
- package/dist/components/Textarea.svelte +1 -1
- package/dist/components/TimePicker.svelte +480 -0
- package/dist/components/TimePicker.svelte.d.ts +23 -0
- package/dist/components/Toaster.svelte +9 -2
- package/dist/components/Toggle.svelte +5 -1
- package/dist/components/Toggle.svelte.d.ts +2 -0
- package/dist/components/Toolbar.svelte +39 -0
- package/dist/components/Toolbar.svelte.d.ts +26 -0
- package/dist/components/Tooltip.svelte +48 -12
- package/dist/components/TreeView.svelte +339 -0
- package/dist/components/TreeView.svelte.d.ts +37 -0
- package/dist/components/dialog/Dialog.svelte +15 -58
- package/dist/components/dialog/dialog-manager.svelte.d.ts +2 -2
- package/dist/components/dialog/dialog-manager.svelte.js +21 -21
- package/dist/index.d.ts +25 -1
- package/dist/index.js +20 -1
- package/dist/internal/calendar.d.ts +119 -0
- package/dist/internal/calendar.js +225 -0
- package/dist/internal/choice.d.ts +136 -0
- package/dist/internal/choice.js +179 -0
- package/dist/internal/field.d.ts +31 -0
- package/dist/internal/field.js +42 -1
- package/dist/internal/filter.d.ts +80 -0
- package/dist/internal/filter.js +80 -0
- package/dist/internal/layout.d.ts +119 -0
- package/dist/internal/layout.js +132 -0
- package/dist/internal/listbox.svelte.d.ts +77 -0
- package/dist/internal/listbox.svelte.js +438 -0
- package/dist/internal/nav-expansion.svelte.d.ts +36 -0
- package/dist/internal/nav-expansion.svelte.js +144 -0
- package/dist/internal/nav-tree.d.ts +68 -0
- package/dist/internal/nav-tree.js +102 -0
- package/dist/internal/overlay.d.ts +25 -0
- package/dist/internal/overlay.js +92 -0
- package/dist/internal/panel.d.ts +100 -0
- package/dist/internal/panel.js +109 -0
- package/dist/internal/rollup.d.ts +52 -0
- package/dist/internal/rollup.js +67 -0
- package/dist/internal/time.d.ts +103 -0
- package/dist/internal/time.js +166 -0
- package/dist/internal/tree.d.ts +86 -0
- package/dist/internal/tree.js +111 -0
- package/dist/styles/theme.css +66 -25
- package/package.json +4 -2
- package/src/lib/styles/theme.css +66 -25
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which groups in a sidebar are open, and why.
|
|
3
|
+
*
|
|
4
|
+
* Expansion looks like one boolean per group and is really three sources
|
|
5
|
+
* disagreeing: the tree says a group ships open, the current page says its
|
|
6
|
+
* ancestors have to be open or the reader cannot see where they are, and the
|
|
7
|
+
* reader says they closed that group and meant it. A component that keeps a
|
|
8
|
+
* flat set of open ids loses the third one the moment the second changes,
|
|
9
|
+
* which is how a group reopens itself every time the reader navigates inside
|
|
10
|
+
* it.
|
|
11
|
+
*
|
|
12
|
+
* So the state here is not "open ids". It is the decisions the reader has
|
|
13
|
+
* made, which are the only part worth persisting, and a default computed from
|
|
14
|
+
* the tree and the path underneath them. A reader decision always wins, and
|
|
15
|
+
* until there is one the group follows the page.
|
|
16
|
+
*
|
|
17
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
18
|
+
*/
|
|
19
|
+
import { activeTrail, flattenNav } from './nav-tree.js';
|
|
20
|
+
/**
|
|
21
|
+
* localStorage is absent on the server and throws on access in a private
|
|
22
|
+
* window and wherever the reader has blocked site data, so it is reached for
|
|
23
|
+
* behind both a typeof guard and a catch. Expansion is a convenience; nothing
|
|
24
|
+
* here may be the reason a sidebar fails to render.
|
|
25
|
+
*/
|
|
26
|
+
function storage() {
|
|
27
|
+
try {
|
|
28
|
+
return typeof localStorage === 'undefined' ? undefined : localStorage;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function readStored(key) {
|
|
35
|
+
if (!key)
|
|
36
|
+
return {};
|
|
37
|
+
const store = storage();
|
|
38
|
+
if (!store)
|
|
39
|
+
return {};
|
|
40
|
+
try {
|
|
41
|
+
const raw = store.getItem(key);
|
|
42
|
+
if (!raw)
|
|
43
|
+
return {};
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
46
|
+
return {};
|
|
47
|
+
// Another version of the app, or a reader editing the value by hand, can
|
|
48
|
+
// leave anything at all under this key. Only booleans survive the read.
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const [id, open] of Object.entries(parsed)) {
|
|
51
|
+
if (typeof open === 'boolean')
|
|
52
|
+
out[id] = open;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function writeStored(key, value) {
|
|
61
|
+
if (!key)
|
|
62
|
+
return;
|
|
63
|
+
const store = storage();
|
|
64
|
+
if (!store)
|
|
65
|
+
return;
|
|
66
|
+
try {
|
|
67
|
+
store.setItem(key, JSON.stringify(value));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// A private window answers a write with a quota error. The session keeps
|
|
71
|
+
// its expansion in memory instead.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function hasChildren(node) {
|
|
75
|
+
return Array.isArray(node.children) && node.children.length > 0;
|
|
76
|
+
}
|
|
77
|
+
export function createNavExpansion(options) {
|
|
78
|
+
// Read once, with the key the sidebar mounted with. A key that changes later
|
|
79
|
+
// names a different sidebar, which is a different component instance.
|
|
80
|
+
const key = options.storageKey();
|
|
81
|
+
/** The reader's own decisions. An id absent here has not been decided. */
|
|
82
|
+
let overrides = $state(readStored(key));
|
|
83
|
+
const trail = $derived(options.expandActive()
|
|
84
|
+
? new Set(activeTrail(options.items(), options.activePath()))
|
|
85
|
+
: new Set());
|
|
86
|
+
const nodes = $derived(flattenNav(options.items()));
|
|
87
|
+
function commit(next) {
|
|
88
|
+
overrides = next;
|
|
89
|
+
writeStored(key, next);
|
|
90
|
+
}
|
|
91
|
+
function ancestorsOf(id) {
|
|
92
|
+
const walk = (branch, chain) => {
|
|
93
|
+
for (const node of branch) {
|
|
94
|
+
if (node.id === id)
|
|
95
|
+
return chain;
|
|
96
|
+
if (node.children && node.children.length > 0) {
|
|
97
|
+
const found = walk(node.children, [...chain, node.id]);
|
|
98
|
+
if (found)
|
|
99
|
+
return found;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
};
|
|
104
|
+
return walk(options.items(), []) ?? [];
|
|
105
|
+
}
|
|
106
|
+
function isExpanded(id) {
|
|
107
|
+
if (Object.prototype.hasOwnProperty.call(overrides, id))
|
|
108
|
+
return overrides[id];
|
|
109
|
+
if (nodes.some((entry) => entry.node.id === id && entry.node.defaultExpanded))
|
|
110
|
+
return true;
|
|
111
|
+
return trail.has(id);
|
|
112
|
+
}
|
|
113
|
+
function expand(id) {
|
|
114
|
+
if (!options.exclusive()) {
|
|
115
|
+
commit({ ...overrides, [id]: true });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// Exclusive closes every other group, except the ones the opened node sits
|
|
119
|
+
// inside: collapsing an ancestor would hide the group that was just asked
|
|
120
|
+
// for. Ids the tree no longer holds keep whatever they had, so a stored
|
|
121
|
+
// key survives a tree that has not finished loading.
|
|
122
|
+
const keep = new Set([id, ...ancestorsOf(id)]);
|
|
123
|
+
const next = { ...overrides };
|
|
124
|
+
for (const { node } of nodes) {
|
|
125
|
+
if (hasChildren(node))
|
|
126
|
+
next[node.id] = keep.has(node.id);
|
|
127
|
+
}
|
|
128
|
+
commit(next);
|
|
129
|
+
}
|
|
130
|
+
function collapse(id) {
|
|
131
|
+
// Descendants keep their own decisions. They are not on screen while this
|
|
132
|
+
// group is shut, and reopening it should restore what the reader left.
|
|
133
|
+
commit({ ...overrides, [id]: false });
|
|
134
|
+
}
|
|
135
|
+
function toggle(id) {
|
|
136
|
+
if (isExpanded(id)) {
|
|
137
|
+
collapse(id);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
expand(id);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { isExpanded, toggle, expand, collapse };
|
|
144
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a sidebar decides which of its links is the current page.
|
|
3
|
+
*
|
|
4
|
+
* The rule every app shell reaches for is
|
|
5
|
+
*
|
|
6
|
+
* pathname === href || pathname.startsWith(href + '/')
|
|
7
|
+
*
|
|
8
|
+
* applied to every entry in the list. It is wrong in two ways that only show
|
|
9
|
+
* up once the list has more than one level. A parent link keeps claiming the
|
|
10
|
+
* page while the reader is on one of its children, so a settings sub-page
|
|
11
|
+
* lights two rows at once and neither of them is where the reader is. And two
|
|
12
|
+
* leaves where one href is a prefix of the other, /settings beside
|
|
13
|
+
* /settings/team, mark themselves together for the same reason, even though
|
|
14
|
+
* they are siblings and only one of them can be open.
|
|
15
|
+
*
|
|
16
|
+
* The fix is that prefix matching is a property of a node that owns a section,
|
|
17
|
+
* not of every node. A leaf answers for its own path and nothing below it; a
|
|
18
|
+
* node with children answers for its whole subtree, because that is what makes
|
|
19
|
+
* an ancestor able to say "you are somewhere in here" while its child says
|
|
20
|
+
* "you are here". Either default is overridable per node, and 'none' opts a
|
|
21
|
+
* node out of path matching entirely.
|
|
22
|
+
*
|
|
23
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
24
|
+
*/
|
|
25
|
+
import type { Component } from 'svelte';
|
|
26
|
+
import type { AccentTone } from './tone.js';
|
|
27
|
+
export interface NavNode {
|
|
28
|
+
/** Stable across renders. aria-controls is built from it and an each is keyed by it. */
|
|
29
|
+
id: string;
|
|
30
|
+
label: string;
|
|
31
|
+
href?: string;
|
|
32
|
+
icon?: Component<{
|
|
33
|
+
size?: number;
|
|
34
|
+
class?: string;
|
|
35
|
+
}>;
|
|
36
|
+
children?: NavNode[];
|
|
37
|
+
/** A count or status beside the label. */
|
|
38
|
+
badge?: string | number;
|
|
39
|
+
badgeTone?: AccentTone;
|
|
40
|
+
/** Open on first render. */
|
|
41
|
+
defaultExpanded?: boolean;
|
|
42
|
+
/** How activePath matches. Defaults to 'exact' for a leaf and 'prefix' for a node with children. */
|
|
43
|
+
match?: 'exact' | 'prefix' | 'none';
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
}
|
|
46
|
+
export type NavTree = NavNode[];
|
|
47
|
+
/**
|
|
48
|
+
* True when this node is the current page.
|
|
49
|
+
*
|
|
50
|
+
* Path only. A disabled node still reports the truth about its href; whether
|
|
51
|
+
* it is rendered as a link is a separate decision the component makes.
|
|
52
|
+
*/
|
|
53
|
+
export declare function isActive(node: NavNode, activePath: string): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* The ids from the root down to the matched leaf, so its ancestors can open
|
|
56
|
+
* and mark themselves.
|
|
57
|
+
*
|
|
58
|
+
* Children are searched before the node itself, so a group never shadows the
|
|
59
|
+
* child that holds the more specific answer. The first match in document order
|
|
60
|
+
* wins: two nodes claiming one path is a tree the author has to fix, and
|
|
61
|
+
* silently picking one of them by length would hide it.
|
|
62
|
+
*/
|
|
63
|
+
export declare function activeTrail(items: NavTree, activePath: string): string[];
|
|
64
|
+
/** Depth-first flatten, for tests and for keyboard order. */
|
|
65
|
+
export declare function flattenNav(items: NavTree): {
|
|
66
|
+
node: NavNode;
|
|
67
|
+
depth: number;
|
|
68
|
+
}[];
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a sidebar decides which of its links is the current page.
|
|
3
|
+
*
|
|
4
|
+
* The rule every app shell reaches for is
|
|
5
|
+
*
|
|
6
|
+
* pathname === href || pathname.startsWith(href + '/')
|
|
7
|
+
*
|
|
8
|
+
* applied to every entry in the list. It is wrong in two ways that only show
|
|
9
|
+
* up once the list has more than one level. A parent link keeps claiming the
|
|
10
|
+
* page while the reader is on one of its children, so a settings sub-page
|
|
11
|
+
* lights two rows at once and neither of them is where the reader is. And two
|
|
12
|
+
* leaves where one href is a prefix of the other, /settings beside
|
|
13
|
+
* /settings/team, mark themselves together for the same reason, even though
|
|
14
|
+
* they are siblings and only one of them can be open.
|
|
15
|
+
*
|
|
16
|
+
* The fix is that prefix matching is a property of a node that owns a section,
|
|
17
|
+
* not of every node. A leaf answers for its own path and nothing below it; a
|
|
18
|
+
* node with children answers for its whole subtree, because that is what makes
|
|
19
|
+
* an ancestor able to say "you are somewhere in here" while its child says
|
|
20
|
+
* "you are here". Either default is overridable per node, and 'none' opts a
|
|
21
|
+
* node out of path matching entirely.
|
|
22
|
+
*
|
|
23
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Trailing slashes are noise: a router hands over /settings on one route and
|
|
27
|
+
* /settings/ on another, and the two name the same page. Everything else is
|
|
28
|
+
* left alone, because activePath is documented as a pathname and a query or a
|
|
29
|
+
* hash that reached here is a caller bug worth seeing rather than absorbing.
|
|
30
|
+
*/
|
|
31
|
+
function normalize(path) {
|
|
32
|
+
if (path === '')
|
|
33
|
+
return '';
|
|
34
|
+
const trimmed = path.replace(/\/+$/, '');
|
|
35
|
+
return trimmed === '' ? '/' : trimmed;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A node with children owns a section, so it answers for everything under it.
|
|
39
|
+
* A leaf answers for its own path alone.
|
|
40
|
+
*/
|
|
41
|
+
function matchMode(node) {
|
|
42
|
+
if (node.match)
|
|
43
|
+
return node.match;
|
|
44
|
+
return node.children && node.children.length > 0 ? 'prefix' : 'exact';
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* True when this node is the current page.
|
|
48
|
+
*
|
|
49
|
+
* Path only. A disabled node still reports the truth about its href; whether
|
|
50
|
+
* it is rendered as a link is a separate decision the component makes.
|
|
51
|
+
*/
|
|
52
|
+
export function isActive(node, activePath) {
|
|
53
|
+
if (!node.href)
|
|
54
|
+
return false;
|
|
55
|
+
const mode = matchMode(node);
|
|
56
|
+
if (mode === 'none')
|
|
57
|
+
return false;
|
|
58
|
+
const href = normalize(node.href);
|
|
59
|
+
const path = normalize(activePath);
|
|
60
|
+
if (href === '' || path === '')
|
|
61
|
+
return false;
|
|
62
|
+
if (path === href)
|
|
63
|
+
return true;
|
|
64
|
+
// A root href normalizes to "/", so the prefix it tests for is "//" and it
|
|
65
|
+
// claims nothing but itself. A dashboard mounted at / would otherwise own
|
|
66
|
+
// every page in the app.
|
|
67
|
+
return mode === 'prefix' && path.startsWith(`${href}/`);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The ids from the root down to the matched leaf, so its ancestors can open
|
|
71
|
+
* and mark themselves.
|
|
72
|
+
*
|
|
73
|
+
* Children are searched before the node itself, so a group never shadows the
|
|
74
|
+
* child that holds the more specific answer. The first match in document order
|
|
75
|
+
* wins: two nodes claiming one path is a tree the author has to fix, and
|
|
76
|
+
* silently picking one of them by length would hide it.
|
|
77
|
+
*/
|
|
78
|
+
export function activeTrail(items, activePath) {
|
|
79
|
+
for (const node of items) {
|
|
80
|
+
if (node.children && node.children.length > 0) {
|
|
81
|
+
const below = activeTrail(node.children, activePath);
|
|
82
|
+
if (below.length > 0)
|
|
83
|
+
return [node.id, ...below];
|
|
84
|
+
}
|
|
85
|
+
if (isActive(node, activePath))
|
|
86
|
+
return [node.id];
|
|
87
|
+
}
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
/** Depth-first flatten, for tests and for keyboard order. */
|
|
91
|
+
export function flattenNav(items) {
|
|
92
|
+
const out = [];
|
|
93
|
+
const walk = (nodes, depth) => {
|
|
94
|
+
for (const node of nodes) {
|
|
95
|
+
out.push({ node, depth });
|
|
96
|
+
if (node.children && node.children.length > 0)
|
|
97
|
+
walk(node.children, depth + 1);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
walk(items, 0);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The behaviour every modal surface owes a keyboard and screen reader user.
|
|
3
|
+
*
|
|
4
|
+
* Dialog carried a correct implementation and Modal and Drawer carried none:
|
|
5
|
+
* both declared `aria-modal="true"` while leaving focus behind them in the
|
|
6
|
+
* page, so a screen reader user was told a modal had opened and then went on
|
|
7
|
+
* reading the document underneath it, and a keyboard user tabbed straight out
|
|
8
|
+
* of the panel with no way back. The behaviour lives here now so a fourth
|
|
9
|
+
* overlay cannot ship without it.
|
|
10
|
+
*
|
|
11
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
12
|
+
*/
|
|
13
|
+
export declare function lockBodyScroll(): void;
|
|
14
|
+
export declare function unlockBodyScroll(): void;
|
|
15
|
+
/**
|
|
16
|
+
* Svelte action for the panel element of a modal overlay.
|
|
17
|
+
*
|
|
18
|
+
* <div use:overlay role="dialog" aria-modal="true">
|
|
19
|
+
*
|
|
20
|
+
* Moves focus in on mount, keeps Tab inside the panel, locks the page behind
|
|
21
|
+
* it, and returns focus to whatever opened it on unmount.
|
|
22
|
+
*/
|
|
23
|
+
export declare function overlay(node: HTMLElement): {
|
|
24
|
+
destroy(): void;
|
|
25
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The behaviour every modal surface owes a keyboard and screen reader user.
|
|
3
|
+
*
|
|
4
|
+
* Dialog carried a correct implementation and Modal and Drawer carried none:
|
|
5
|
+
* both declared `aria-modal="true"` while leaving focus behind them in the
|
|
6
|
+
* page, so a screen reader user was told a modal had opened and then went on
|
|
7
|
+
* reading the document underneath it, and a keyboard user tabbed straight out
|
|
8
|
+
* of the panel with no way back. The behaviour lives here now so a fourth
|
|
9
|
+
* overlay cannot ship without it.
|
|
10
|
+
*
|
|
11
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Elements that can hold focus. `[tabindex="-1"]` is excluded because it is
|
|
15
|
+
* programmatically focusable but not part of the tab sequence, which is what
|
|
16
|
+
* the trap is wrapping.
|
|
17
|
+
*/
|
|
18
|
+
const FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
19
|
+
/**
|
|
20
|
+
* Counted rather than boolean: a dialog opened from inside a drawer must not
|
|
21
|
+
* restore scrolling when only the inner one closes.
|
|
22
|
+
*/
|
|
23
|
+
let bodyLockCount = 0;
|
|
24
|
+
export function lockBodyScroll() {
|
|
25
|
+
bodyLockCount++;
|
|
26
|
+
if (typeof document !== 'undefined') {
|
|
27
|
+
document.body.style.overflow = 'hidden';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function unlockBodyScroll() {
|
|
31
|
+
bodyLockCount--;
|
|
32
|
+
if (bodyLockCount <= 0) {
|
|
33
|
+
bodyLockCount = 0;
|
|
34
|
+
if (typeof document !== 'undefined') {
|
|
35
|
+
document.body.style.overflow = '';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function focusable(node) {
|
|
40
|
+
return [...node.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Svelte action for the panel element of a modal overlay.
|
|
44
|
+
*
|
|
45
|
+
* <div use:overlay role="dialog" aria-modal="true">
|
|
46
|
+
*
|
|
47
|
+
* Moves focus in on mount, keeps Tab inside the panel, locks the page behind
|
|
48
|
+
* it, and returns focus to whatever opened it on unmount.
|
|
49
|
+
*/
|
|
50
|
+
export function overlay(node) {
|
|
51
|
+
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
52
|
+
lockBodyScroll();
|
|
53
|
+
// A panel with nothing focusable still has to receive focus, or the screen
|
|
54
|
+
// reader stays on the element behind the overlay and reads the wrong thing.
|
|
55
|
+
const first = focusable(node)[0];
|
|
56
|
+
if (first) {
|
|
57
|
+
first.focus();
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
node.tabIndex = -1;
|
|
61
|
+
node.focus();
|
|
62
|
+
}
|
|
63
|
+
function onkeydown(e) {
|
|
64
|
+
if (e.key !== 'Tab')
|
|
65
|
+
return;
|
|
66
|
+
const items = focusable(node);
|
|
67
|
+
if (items.length === 0) {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const head = items[0];
|
|
72
|
+
const tail = items[items.length - 1];
|
|
73
|
+
if (e.shiftKey && document.activeElement === head) {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
tail.focus();
|
|
76
|
+
}
|
|
77
|
+
else if (!e.shiftKey && document.activeElement === tail) {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
head.focus();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
node.addEventListener('keydown', onkeydown);
|
|
83
|
+
return {
|
|
84
|
+
destroy() {
|
|
85
|
+
node.removeEventListener('keydown', onkeydown);
|
|
86
|
+
unlockBodyScroll();
|
|
87
|
+
// The opener can be gone by now, for instance a row action whose row the
|
|
88
|
+
// dialog just deleted, so this is deliberately best effort.
|
|
89
|
+
previous?.focus?.();
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single source of truth for how a floating panel looks.
|
|
3
|
+
*
|
|
4
|
+
* field.ts states the resting control and nothing stated the surface that
|
|
5
|
+
* opens above it, so each of the four popovers grew its own:
|
|
6
|
+
*
|
|
7
|
+
* MultiSelect absolute z-50 mt-1 w-full rounded-xl border border-line
|
|
8
|
+
* bg-surface shadow-2xl overflow-hidden
|
|
9
|
+
* and, on an inner region, max-h-60 overflow-y-auto py-1
|
|
10
|
+
* Autocomplete absolute z-50 mt-1 w-full max-h-60 overflow-y-auto
|
|
11
|
+
* rounded-xl border border-line bg-surface shadow-2xl py-1
|
|
12
|
+
* DatePicker absolute z-50 mt-1 w-[17rem] rounded-xl border border-line
|
|
13
|
+
* bg-surface shadow-2xl p-3
|
|
14
|
+
* Dropdown absolute z-50 mt-1 py-1 min-w-36 rounded-xl border
|
|
15
|
+
* border-line bg-surface shadow-2xl
|
|
16
|
+
*
|
|
17
|
+
* Three of those set a width the consumer cannot influence, and DatePicker
|
|
18
|
+
* sets it as w-[17rem], a length no token governs. The scroll lives on an
|
|
19
|
+
* inner region in one, on the surface itself in another and nowhere in the
|
|
20
|
+
* other two, so a Dropdown of eighty items runs past the bottom of the window
|
|
21
|
+
* and the last item cannot be reached. Two panels cap their height at max-h-60
|
|
22
|
+
* and two never cap it. All four draw the boundary with border-line, which
|
|
23
|
+
* reads 1.25:1 and is the only thing separating the panel from whatever it
|
|
24
|
+
* happens to float over. The shadow is the one thing they agreed on.
|
|
25
|
+
*
|
|
26
|
+
* Width stays at the call site: a menu sized to its trigger and a calendar
|
|
27
|
+
* sized to seven columns are different requirements. Everything else is here.
|
|
28
|
+
*
|
|
29
|
+
* One rule holds these together. No row carries two utilities for the same
|
|
30
|
+
* property. Colour and background sit on the surface, and a row states only
|
|
31
|
+
* the override its state earns, because two utilities for one property resolve
|
|
32
|
+
* in the order Tailwind emits them and not in the order they were written.
|
|
33
|
+
*
|
|
34
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* The floating surface every popover paints.
|
|
38
|
+
*
|
|
39
|
+
* border-line-strong, not border-line: a panel floating over arbitrary content
|
|
40
|
+
* needs a boundary that clears 3:1, which line does not. It carries the
|
|
41
|
+
* resting text colour so a row can override it with a single utility.
|
|
42
|
+
*/
|
|
43
|
+
export declare const PANEL_SURFACE = "absolute z-50 mt-1 rounded-xl border border-line-strong bg-surface text-fg shadow-2xl";
|
|
44
|
+
/**
|
|
45
|
+
* The scrolling region inside it.
|
|
46
|
+
*
|
|
47
|
+
* The cap is a token, not max-h-60, so a fifth panel cannot pick a different
|
|
48
|
+
* one. overscroll-contain stops a wheel that has reached the end of the list
|
|
49
|
+
* from carrying on into the page behind the open panel.
|
|
50
|
+
*/
|
|
51
|
+
export declare const PANEL_LIST = "max-h-panel-max overflow-y-auto overscroll-contain py-1";
|
|
52
|
+
/**
|
|
53
|
+
* One row at rest.
|
|
54
|
+
*
|
|
55
|
+
* No background and no text colour of its own: it inherits both from the
|
|
56
|
+
* surface, which leaves each state below a single utility to override.
|
|
57
|
+
*/
|
|
58
|
+
export declare const PANEL_OPTION: string;
|
|
59
|
+
/**
|
|
60
|
+
* The active descendant.
|
|
61
|
+
*
|
|
62
|
+
* A tint alone reads 1.09:1 against the panel, which is not a visible state,
|
|
63
|
+
* so the active row also carries an inset brand ring. Inset because the row
|
|
64
|
+
* spans the full width of the panel and an outset ring is clipped by it.
|
|
65
|
+
*/
|
|
66
|
+
export declare const PANEL_OPTION_ACTIVE = "bg-surface-2 ring-1 ring-inset ring-brand";
|
|
67
|
+
/**
|
|
68
|
+
* A row whose value is selected, which is orthogonal to being active.
|
|
69
|
+
*
|
|
70
|
+
* The keyboard sits on one row while any number of rows are chosen, so this
|
|
71
|
+
* changes the text and never the background the active row is using.
|
|
72
|
+
*/
|
|
73
|
+
export declare const PANEL_OPTION_SELECTED = "font-medium text-brand";
|
|
74
|
+
/**
|
|
75
|
+
* A row that cannot be chosen.
|
|
76
|
+
*
|
|
77
|
+
* pointer-events-none rather than a hover override: :hover still matches a
|
|
78
|
+
* disabled button, and a second hover background on the same row would resolve
|
|
79
|
+
* by emitted order, so the row could take the tint and read as choosable.
|
|
80
|
+
*/
|
|
81
|
+
export declare const PANEL_OPTION_DISABLED = "pointer-events-none opacity-40";
|
|
82
|
+
/** The line shown when a filter matched nothing. */
|
|
83
|
+
export declare const PANEL_EMPTY = "px-3 py-2 text-sm text-faint";
|
|
84
|
+
/** A group heading inside the list. Not focusable, so it takes no row classes. */
|
|
85
|
+
export declare const PANEL_GROUP_LABEL = "px-3 pb-1 pt-3 text-xs font-medium uppercase text-faint";
|
|
86
|
+
/**
|
|
87
|
+
* Composes the option classes for a row's state.
|
|
88
|
+
*
|
|
89
|
+
* Exists as a function because three booleans spelled inline at each call site
|
|
90
|
+
* is how the four panels drifted. A disabled row takes the disabled treatment
|
|
91
|
+
* and never the active one, even while the keyboard is resting on it: painting
|
|
92
|
+
* it as the active descendant says Enter will choose it, and Enter will not.
|
|
93
|
+
* Selection survives both, because a chosen row that has since been disabled
|
|
94
|
+
* is still chosen.
|
|
95
|
+
*/
|
|
96
|
+
export declare function panelOption(state: {
|
|
97
|
+
active: boolean;
|
|
98
|
+
selected: boolean;
|
|
99
|
+
disabled: boolean;
|
|
100
|
+
}): string;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single source of truth for how a floating panel looks.
|
|
3
|
+
*
|
|
4
|
+
* field.ts states the resting control and nothing stated the surface that
|
|
5
|
+
* opens above it, so each of the four popovers grew its own:
|
|
6
|
+
*
|
|
7
|
+
* MultiSelect absolute z-50 mt-1 w-full rounded-xl border border-line
|
|
8
|
+
* bg-surface shadow-2xl overflow-hidden
|
|
9
|
+
* and, on an inner region, max-h-60 overflow-y-auto py-1
|
|
10
|
+
* Autocomplete absolute z-50 mt-1 w-full max-h-60 overflow-y-auto
|
|
11
|
+
* rounded-xl border border-line bg-surface shadow-2xl py-1
|
|
12
|
+
* DatePicker absolute z-50 mt-1 w-[17rem] rounded-xl border border-line
|
|
13
|
+
* bg-surface shadow-2xl p-3
|
|
14
|
+
* Dropdown absolute z-50 mt-1 py-1 min-w-36 rounded-xl border
|
|
15
|
+
* border-line bg-surface shadow-2xl
|
|
16
|
+
*
|
|
17
|
+
* Three of those set a width the consumer cannot influence, and DatePicker
|
|
18
|
+
* sets it as w-[17rem], a length no token governs. The scroll lives on an
|
|
19
|
+
* inner region in one, on the surface itself in another and nowhere in the
|
|
20
|
+
* other two, so a Dropdown of eighty items runs past the bottom of the window
|
|
21
|
+
* and the last item cannot be reached. Two panels cap their height at max-h-60
|
|
22
|
+
* and two never cap it. All four draw the boundary with border-line, which
|
|
23
|
+
* reads 1.25:1 and is the only thing separating the panel from whatever it
|
|
24
|
+
* happens to float over. The shadow is the one thing they agreed on.
|
|
25
|
+
*
|
|
26
|
+
* Width stays at the call site: a menu sized to its trigger and a calendar
|
|
27
|
+
* sized to seven columns are different requirements. Everything else is here.
|
|
28
|
+
*
|
|
29
|
+
* One rule holds these together. No row carries two utilities for the same
|
|
30
|
+
* property. Colour and background sit on the surface, and a row states only
|
|
31
|
+
* the override its state earns, because two utilities for one property resolve
|
|
32
|
+
* in the order Tailwind emits them and not in the order they were written.
|
|
33
|
+
*
|
|
34
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* The floating surface every popover paints.
|
|
38
|
+
*
|
|
39
|
+
* border-line-strong, not border-line: a panel floating over arbitrary content
|
|
40
|
+
* needs a boundary that clears 3:1, which line does not. It carries the
|
|
41
|
+
* resting text colour so a row can override it with a single utility.
|
|
42
|
+
*/
|
|
43
|
+
export const PANEL_SURFACE = 'absolute z-50 mt-1 rounded-xl border border-line-strong bg-surface text-fg shadow-2xl';
|
|
44
|
+
/**
|
|
45
|
+
* The scrolling region inside it.
|
|
46
|
+
*
|
|
47
|
+
* The cap is a token, not max-h-60, so a fifth panel cannot pick a different
|
|
48
|
+
* one. overscroll-contain stops a wheel that has reached the end of the list
|
|
49
|
+
* from carrying on into the page behind the open panel.
|
|
50
|
+
*/
|
|
51
|
+
export const PANEL_LIST = 'max-h-panel-max overflow-y-auto overscroll-contain py-1';
|
|
52
|
+
/**
|
|
53
|
+
* One row at rest.
|
|
54
|
+
*
|
|
55
|
+
* No background and no text colour of its own: it inherits both from the
|
|
56
|
+
* surface, which leaves each state below a single utility to override.
|
|
57
|
+
*/
|
|
58
|
+
export const PANEL_OPTION = 'flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm ' +
|
|
59
|
+
'transition-colors duration-150 outline-none hover:bg-surface-2 ' +
|
|
60
|
+
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand';
|
|
61
|
+
/**
|
|
62
|
+
* The active descendant.
|
|
63
|
+
*
|
|
64
|
+
* A tint alone reads 1.09:1 against the panel, which is not a visible state,
|
|
65
|
+
* so the active row also carries an inset brand ring. Inset because the row
|
|
66
|
+
* spans the full width of the panel and an outset ring is clipped by it.
|
|
67
|
+
*/
|
|
68
|
+
export const PANEL_OPTION_ACTIVE = 'bg-surface-2 ring-1 ring-inset ring-brand';
|
|
69
|
+
/**
|
|
70
|
+
* A row whose value is selected, which is orthogonal to being active.
|
|
71
|
+
*
|
|
72
|
+
* The keyboard sits on one row while any number of rows are chosen, so this
|
|
73
|
+
* changes the text and never the background the active row is using.
|
|
74
|
+
*/
|
|
75
|
+
export const PANEL_OPTION_SELECTED = 'font-medium text-brand';
|
|
76
|
+
/**
|
|
77
|
+
* A row that cannot be chosen.
|
|
78
|
+
*
|
|
79
|
+
* pointer-events-none rather than a hover override: :hover still matches a
|
|
80
|
+
* disabled button, and a second hover background on the same row would resolve
|
|
81
|
+
* by emitted order, so the row could take the tint and read as choosable.
|
|
82
|
+
*/
|
|
83
|
+
export const PANEL_OPTION_DISABLED = 'pointer-events-none opacity-40';
|
|
84
|
+
/** The line shown when a filter matched nothing. */
|
|
85
|
+
export const PANEL_EMPTY = 'px-3 py-2 text-sm text-faint';
|
|
86
|
+
/** A group heading inside the list. Not focusable, so it takes no row classes. */
|
|
87
|
+
export const PANEL_GROUP_LABEL = 'px-3 pb-1 pt-3 text-xs font-medium uppercase text-faint';
|
|
88
|
+
/**
|
|
89
|
+
* Composes the option classes for a row's state.
|
|
90
|
+
*
|
|
91
|
+
* Exists as a function because three booleans spelled inline at each call site
|
|
92
|
+
* is how the four panels drifted. A disabled row takes the disabled treatment
|
|
93
|
+
* and never the active one, even while the keyboard is resting on it: painting
|
|
94
|
+
* it as the active descendant says Enter will choose it, and Enter will not.
|
|
95
|
+
* Selection survives both, because a chosen row that has since been disabled
|
|
96
|
+
* is still chosen.
|
|
97
|
+
*/
|
|
98
|
+
export function panelOption(state) {
|
|
99
|
+
const parts = [PANEL_OPTION];
|
|
100
|
+
if (state.selected)
|
|
101
|
+
parts.push(PANEL_OPTION_SELECTED);
|
|
102
|
+
if (state.disabled) {
|
|
103
|
+
parts.push(PANEL_OPTION_DISABLED);
|
|
104
|
+
}
|
|
105
|
+
else if (state.active) {
|
|
106
|
+
parts.push(PANEL_OPTION_ACTIVE);
|
|
107
|
+
}
|
|
108
|
+
return parts.join(' ');
|
|
109
|
+
}
|