@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.
Files changed (101) hide show
  1. package/README.md +1 -1
  2. package/dist/components/AccordionItem.svelte +1 -1
  3. package/dist/components/Autocomplete.svelte +191 -125
  4. package/dist/components/Autocomplete.svelte.d.ts +29 -8
  5. package/dist/components/Button.svelte +26 -4
  6. package/dist/components/Card.svelte +61 -3
  7. package/dist/components/Card.svelte.d.ts +24 -2
  8. package/dist/components/Checkbox.svelte +174 -59
  9. package/dist/components/Checkbox.svelte.d.ts +20 -3
  10. package/dist/components/CheckboxGroup.svelte +162 -0
  11. package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
  12. package/dist/components/Collapsible.svelte +142 -0
  13. package/dist/components/Collapsible.svelte.d.ts +32 -0
  14. package/dist/components/CopyButton.svelte +126 -0
  15. package/dist/components/CopyButton.svelte.d.ts +14 -0
  16. package/dist/components/DatePicker.svelte +48 -6
  17. package/dist/components/DateTimePicker.svelte +337 -0
  18. package/dist/components/DateTimePicker.svelte.d.ts +26 -0
  19. package/dist/components/DescriptionList.svelte +78 -0
  20. package/dist/components/DescriptionList.svelte.d.ts +34 -0
  21. package/dist/components/Drawer.svelte +15 -4
  22. package/dist/components/Field.svelte +104 -0
  23. package/dist/components/Field.svelte.d.ts +46 -0
  24. package/dist/components/FileInput.svelte +5 -2
  25. package/dist/components/FormMessage.svelte +85 -0
  26. package/dist/components/FormMessage.svelte.d.ts +11 -0
  27. package/dist/components/Input.svelte +1 -1
  28. package/dist/components/Label.svelte +7 -1
  29. package/dist/components/Label.svelte.d.ts +6 -0
  30. package/dist/components/Modal.svelte +25 -8
  31. package/dist/components/MultiSelect.svelte +199 -109
  32. package/dist/components/MultiSelect.svelte.d.ts +22 -9
  33. package/dist/components/NumberInput.svelte +8 -4
  34. package/dist/components/PageHeader.svelte +37 -4
  35. package/dist/components/PageHeader.svelte.d.ts +15 -0
  36. package/dist/components/PageShell.svelte +85 -0
  37. package/dist/components/PageShell.svelte.d.ts +38 -0
  38. package/dist/components/Pagination.svelte +58 -17
  39. package/dist/components/Panel.svelte +101 -0
  40. package/dist/components/Panel.svelte.d.ts +39 -0
  41. package/dist/components/PasswordInput.svelte +139 -0
  42. package/dist/components/PasswordInput.svelte.d.ts +29 -0
  43. package/dist/components/Radio.svelte +152 -32
  44. package/dist/components/Radio.svelte.d.ts +16 -1
  45. package/dist/components/RadioGroup.svelte +118 -71
  46. package/dist/components/RadioGroup.svelte.d.ts +39 -9
  47. package/dist/components/SectionHeading.svelte +39 -0
  48. package/dist/components/SectionHeading.svelte.d.ts +21 -0
  49. package/dist/components/SegmentedControl.svelte +194 -0
  50. package/dist/components/SegmentedControl.svelte.d.ts +55 -0
  51. package/dist/components/Select.svelte +471 -46
  52. package/dist/components/Select.svelte.d.ts +95 -6
  53. package/dist/components/SidebarNav.svelte +259 -0
  54. package/dist/components/SidebarNav.svelte.d.ts +17 -0
  55. package/dist/components/Stat.svelte +53 -2
  56. package/dist/components/Stat.svelte.d.ts +31 -0
  57. package/dist/components/Textarea.svelte +1 -1
  58. package/dist/components/TimePicker.svelte +480 -0
  59. package/dist/components/TimePicker.svelte.d.ts +23 -0
  60. package/dist/components/Toaster.svelte +9 -2
  61. package/dist/components/Toggle.svelte +5 -1
  62. package/dist/components/Toggle.svelte.d.ts +2 -0
  63. package/dist/components/Toolbar.svelte +39 -0
  64. package/dist/components/Toolbar.svelte.d.ts +26 -0
  65. package/dist/components/Tooltip.svelte +48 -12
  66. package/dist/components/TreeView.svelte +339 -0
  67. package/dist/components/TreeView.svelte.d.ts +37 -0
  68. package/dist/components/dialog/Dialog.svelte +15 -58
  69. package/dist/components/dialog/dialog-manager.svelte.d.ts +2 -2
  70. package/dist/components/dialog/dialog-manager.svelte.js +21 -21
  71. package/dist/index.d.ts +25 -1
  72. package/dist/index.js +20 -1
  73. package/dist/internal/calendar.d.ts +119 -0
  74. package/dist/internal/calendar.js +225 -0
  75. package/dist/internal/choice.d.ts +136 -0
  76. package/dist/internal/choice.js +179 -0
  77. package/dist/internal/field.d.ts +31 -0
  78. package/dist/internal/field.js +42 -1
  79. package/dist/internal/filter.d.ts +80 -0
  80. package/dist/internal/filter.js +80 -0
  81. package/dist/internal/layout.d.ts +119 -0
  82. package/dist/internal/layout.js +132 -0
  83. package/dist/internal/listbox.svelte.d.ts +77 -0
  84. package/dist/internal/listbox.svelte.js +438 -0
  85. package/dist/internal/nav-expansion.svelte.d.ts +36 -0
  86. package/dist/internal/nav-expansion.svelte.js +144 -0
  87. package/dist/internal/nav-tree.d.ts +68 -0
  88. package/dist/internal/nav-tree.js +102 -0
  89. package/dist/internal/overlay.d.ts +25 -0
  90. package/dist/internal/overlay.js +92 -0
  91. package/dist/internal/panel.d.ts +100 -0
  92. package/dist/internal/panel.js +109 -0
  93. package/dist/internal/rollup.d.ts +52 -0
  94. package/dist/internal/rollup.js +67 -0
  95. package/dist/internal/time.d.ts +103 -0
  96. package/dist/internal/time.js +166 -0
  97. package/dist/internal/tree.d.ts +86 -0
  98. package/dist/internal/tree.js +111 -0
  99. package/dist/styles/theme.css +66 -25
  100. package/package.json +4 -2
  101. 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
+ }