@lyeve-labs/ui-kit 0.12.1 → 0.13.1

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 (87) hide show
  1. package/dist/components/Autocomplete.svelte +191 -125
  2. package/dist/components/Autocomplete.svelte.d.ts +29 -8
  3. package/dist/components/Card.svelte +43 -2
  4. package/dist/components/Card.svelte.d.ts +24 -2
  5. package/dist/components/Checkbox.svelte +174 -63
  6. package/dist/components/Checkbox.svelte.d.ts +20 -3
  7. package/dist/components/CheckboxGroup.svelte +162 -0
  8. package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
  9. package/dist/components/Collapsible.svelte +142 -0
  10. package/dist/components/Collapsible.svelte.d.ts +32 -0
  11. package/dist/components/CopyButton.svelte +126 -0
  12. package/dist/components/CopyButton.svelte.d.ts +14 -0
  13. package/dist/components/DatePicker.svelte +48 -6
  14. package/dist/components/DateTimePicker.svelte +337 -0
  15. package/dist/components/DateTimePicker.svelte.d.ts +26 -0
  16. package/dist/components/DescriptionList.svelte +78 -0
  17. package/dist/components/DescriptionList.svelte.d.ts +34 -0
  18. package/dist/components/Field.svelte +104 -0
  19. package/dist/components/Field.svelte.d.ts +46 -0
  20. package/dist/components/FileInput.svelte +5 -2
  21. package/dist/components/FormMessage.svelte +85 -0
  22. package/dist/components/FormMessage.svelte.d.ts +11 -0
  23. package/dist/components/Input.svelte +1 -1
  24. package/dist/components/Label.svelte +7 -1
  25. package/dist/components/Label.svelte.d.ts +6 -0
  26. package/dist/components/MultiSelect.svelte +199 -109
  27. package/dist/components/MultiSelect.svelte.d.ts +22 -9
  28. package/dist/components/NumberInput.svelte +8 -4
  29. package/dist/components/PageHeader.svelte +37 -4
  30. package/dist/components/PageHeader.svelte.d.ts +15 -0
  31. package/dist/components/PageShell.svelte +85 -0
  32. package/dist/components/PageShell.svelte.d.ts +38 -0
  33. package/dist/components/Pagination.svelte +43 -7
  34. package/dist/components/Panel.svelte +101 -0
  35. package/dist/components/Panel.svelte.d.ts +39 -0
  36. package/dist/components/PasswordInput.svelte +139 -0
  37. package/dist/components/PasswordInput.svelte.d.ts +29 -0
  38. package/dist/components/Radio.svelte +152 -35
  39. package/dist/components/Radio.svelte.d.ts +16 -1
  40. package/dist/components/RadioGroup.svelte +118 -71
  41. package/dist/components/RadioGroup.svelte.d.ts +39 -9
  42. package/dist/components/SectionHeading.svelte +39 -0
  43. package/dist/components/SectionHeading.svelte.d.ts +21 -0
  44. package/dist/components/SegmentedControl.svelte +194 -0
  45. package/dist/components/SegmentedControl.svelte.d.ts +55 -0
  46. package/dist/components/Select.svelte +471 -46
  47. package/dist/components/Select.svelte.d.ts +95 -6
  48. package/dist/components/SidebarNav.svelte +264 -0
  49. package/dist/components/SidebarNav.svelte.d.ts +17 -0
  50. package/dist/components/Stat.svelte +53 -2
  51. package/dist/components/Stat.svelte.d.ts +31 -0
  52. package/dist/components/Textarea.svelte +1 -1
  53. package/dist/components/TimePicker.svelte +480 -0
  54. package/dist/components/TimePicker.svelte.d.ts +23 -0
  55. package/dist/components/Toolbar.svelte +39 -0
  56. package/dist/components/Toolbar.svelte.d.ts +26 -0
  57. package/dist/components/TreeView.svelte +339 -0
  58. package/dist/components/TreeView.svelte.d.ts +37 -0
  59. package/dist/index.d.ts +25 -1
  60. package/dist/index.js +20 -1
  61. package/dist/internal/calendar.d.ts +119 -0
  62. package/dist/internal/calendar.js +225 -0
  63. package/dist/internal/choice.d.ts +136 -0
  64. package/dist/internal/choice.js +179 -0
  65. package/dist/internal/field.d.ts +39 -0
  66. package/dist/internal/field.js +48 -0
  67. package/dist/internal/filter.d.ts +80 -0
  68. package/dist/internal/filter.js +80 -0
  69. package/dist/internal/layout.d.ts +119 -0
  70. package/dist/internal/layout.js +132 -0
  71. package/dist/internal/listbox.svelte.d.ts +77 -0
  72. package/dist/internal/listbox.svelte.js +438 -0
  73. package/dist/internal/nav-expansion.svelte.d.ts +36 -0
  74. package/dist/internal/nav-expansion.svelte.js +144 -0
  75. package/dist/internal/nav-tree.d.ts +68 -0
  76. package/dist/internal/nav-tree.js +102 -0
  77. package/dist/internal/panel.d.ts +100 -0
  78. package/dist/internal/panel.js +109 -0
  79. package/dist/internal/rollup.d.ts +52 -0
  80. package/dist/internal/rollup.js +67 -0
  81. package/dist/internal/time.d.ts +103 -0
  82. package/dist/internal/time.js +166 -0
  83. package/dist/internal/tree.d.ts +86 -0
  84. package/dist/internal/tree.js +111 -0
  85. package/dist/styles/theme.css +18 -10
  86. package/package.json +4 -2
  87. package/src/lib/styles/theme.css +18 -10
@@ -0,0 +1,264 @@
1
+ <script lang="ts">
2
+ import { activeTrail, type NavNode, type NavTree } from '../internal/nav-tree.js';
3
+ import { createNavExpansion } from '../internal/nav-expansion.svelte.js';
4
+ import type { AccentTone } from '../internal/tone.js';
5
+
6
+ interface Props {
7
+ items: NavTree;
8
+ /** Pathname only, no query, no hash. Passed in rather than read, because the kit cannot import an app router. */
9
+ activePath: string;
10
+ /** The nav landmark's accessible name. */
11
+ label?: string;
12
+ /** Icon-only rail. */
13
+ collapsed?: boolean;
14
+ expandActive?: boolean;
15
+ exclusive?: boolean;
16
+ storageKey?: string;
17
+ class?: string;
18
+ }
19
+
20
+ let {
21
+ items,
22
+ activePath,
23
+ label = 'Primary',
24
+ collapsed = false,
25
+ expandActive = true,
26
+ exclusive = false,
27
+ storageKey = undefined,
28
+ class: klass = '',
29
+ }: Props = $props();
30
+
31
+ // $props.id() and not a random string: a random id differs between the server
32
+ // render and hydration, so every aria-controls built from it points at an
33
+ // element that no longer carries that id once the client takes over.
34
+ const uid = $props.id();
35
+
36
+ const expansion = createNavExpansion({
37
+ items: () => items,
38
+ activePath: () => activePath,
39
+ expandActive: () => expandActive,
40
+ exclusive: () => exclusive,
41
+ storageKey: () => storageKey,
42
+ });
43
+
44
+ const trail = $derived(activeTrail(items, activePath));
45
+ const onTrail = $derived(new Set(trail));
46
+ /** The tail of the trail is the page itself. Everything above it is an ancestor. */
47
+ const currentId = $derived(trail.length > 0 ? trail[trail.length - 1] : undefined);
48
+
49
+ function listId(id: string): string {
50
+ return `${uid}-${id}-group`;
51
+ }
52
+
53
+ /**
54
+ * The row, without the colours any one state paints.
55
+ *
56
+ * The rail is a start border on every row, not one added when the branch is
57
+ * active, so marking a branch cannot shift its rows two pixels sideways. Its
58
+ * colour is a ternary rather than a second border utility: two colour
59
+ * utilities on one element resolve in the order Tailwind emits them, not the
60
+ * order they are written.
61
+ *
62
+ * The focus ring is inset and stated once, outside every state branch. A
63
+ * sidebar scrolls, and a scrolling box crops the 2px the global outline sits
64
+ * outside its element, so an outer ring on a row near the edge shows as a
65
+ * stray line or as nothing at all.
66
+ */
67
+ const ROW =
68
+ 'flex w-full items-center gap-2.5 rounded-lg border-s-2 py-2 pe-2 text-sm text-start ' +
69
+ 'transition-colors duration-150 outline-none ' +
70
+ 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand';
71
+
72
+ /** Exactly one border colour, one background and one text colour per state. */
73
+ function rowPaint(state: 'current' | 'ancestor' | 'rest'): string {
74
+ if (state === 'current') return 'border-brand bg-surface-2 font-medium text-brand';
75
+ if (state === 'ancestor') return 'border-brand text-fg hover:bg-surface-2';
76
+ return 'border-transparent text-muted hover:bg-surface-2 hover:text-fg';
77
+ }
78
+
79
+ const ROW_DISABLED = 'cursor-not-allowed border-transparent text-muted opacity-50';
80
+
81
+ /**
82
+ * The disclosure beside a link, for a node that is both a destination and a
83
+ * section. It is a separate control because one control cannot know whether
84
+ * a click meant "go there" or "show me what is under it", and guessing gets
85
+ * it wrong half the time.
86
+ */
87
+ const DISCLOSURE =
88
+ 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted ' +
89
+ 'transition-colors duration-150 outline-none hover:bg-surface-2 hover:text-fg ' +
90
+ 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand';
91
+
92
+ const BADGE_TONE: Record<AccentTone, string> = {
93
+ neutral: 'bg-surface-2 text-faint',
94
+ brand: 'bg-brand/15 text-brand',
95
+ success: 'bg-success/15 text-success',
96
+ warn: 'bg-warn/15 text-warn',
97
+ danger: 'bg-danger/15 text-danger',
98
+ violet: 'bg-violet/15 text-violet',
99
+ };
100
+
101
+ const BADGE = 'ms-auto shrink-0 rounded-full px-1.5 py-0.5 text-xs font-medium';
102
+ </script>
103
+
104
+ <!--
105
+ A nav and nothing else. The app shell already owns the aside around this, and
106
+ a second landmark of any kind changes what a landmark query answers with, so
107
+ a test looking for the one navigation region finds two and a screen reader
108
+ user hears the sidebar announced twice.
109
+ -->
110
+ <nav
111
+ aria-label={label}
112
+ class="{collapsed
113
+ ? 'w-nav-rail'
114
+ : 'w-sidebar'} shrink-0 overflow-y-auto overscroll-contain py-2 {klass}"
115
+ >
116
+ <ul class="flex flex-col gap-0.5">
117
+ {#each items as node (node.id)}
118
+ {@render row(node, 0)}
119
+ {/each}
120
+ </ul>
121
+ </nav>
122
+
123
+ {#snippet row(node: NavNode, depth: number)}
124
+ {@const children = node.children ?? []}
125
+ {@const branch = children.length > 0}
126
+ {@const current = node.id === currentId}
127
+ {@const ancestor = onTrail.has(node.id) && !current}
128
+ {@const paint = rowPaint(current ? 'current' : ancestor ? 'ancestor' : 'rest')}
129
+ {@const open = expansion.isExpanded(node.id)}
130
+ <!--
131
+ Indentation reads a token through a style attribute. A class built from the
132
+ depth, pl-{depth * 4}, matches no candidate in Tailwind's scan of the
133
+ source, so no rule is generated and every level renders flush against the
134
+ edge. A collapsed rail has no room to indent, so its levels all start at
135
+ the same inset.
136
+ -->
137
+ {@const level = collapsed ? 0 : depth}
138
+ <li>
139
+ {#if node.disabled}
140
+ <!--
141
+ Not a link. An anchor without an href is not focusable and an anchor
142
+ with one still navigates, so a disabled entry that keeps its anchor is
143
+ either invisible to the keyboard or not disabled at all.
144
+ -->
145
+ <span
146
+ class="{ROW} {ROW_DISABLED}"
147
+ style="padding-inline-start: calc(var(--spacing-nav-indent) * {level} + var(--spacing-inline))"
148
+ aria-disabled="true"
149
+ >
150
+ {@render body(node)}
151
+ </span>
152
+ {:else if node.href && branch}
153
+ <div class="flex items-center gap-1">
154
+ <a
155
+ href={node.href}
156
+ aria-current={current ? 'page' : undefined}
157
+ class="{ROW} {paint} min-w-0 flex-1"
158
+ style="padding-inline-start: calc(var(--spacing-nav-indent) * {level} + var(--spacing-inline))"
159
+ >
160
+ {@render body(node)}
161
+ </a>
162
+ <button
163
+ type="button"
164
+ class={DISCLOSURE}
165
+ aria-expanded={open}
166
+ aria-controls={listId(node.id)}
167
+ aria-current={ancestor ? 'true' : undefined}
168
+ aria-label="{open ? 'Collapse' : 'Expand'} {node.label}"
169
+ onclick={() => expansion.toggle(node.id)}
170
+ >
171
+ {@render chevron(open)}
172
+ </button>
173
+ </div>
174
+ {:else if branch}
175
+ <button
176
+ type="button"
177
+ class="{ROW} {paint}"
178
+ style="padding-inline-start: calc(var(--spacing-nav-indent) * {level} + var(--spacing-inline))"
179
+ aria-expanded={open}
180
+ aria-controls={listId(node.id)}
181
+ aria-current={ancestor ? 'true' : undefined}
182
+ onclick={() => expansion.toggle(node.id)}
183
+ >
184
+ {@render body(node)}
185
+ {@render chevron(open)}
186
+ </button>
187
+ {:else if node.href}
188
+ <a
189
+ href={node.href}
190
+ aria-current={current ? 'page' : undefined}
191
+ class="{ROW} {paint}"
192
+ style="padding-inline-start: calc(var(--spacing-nav-indent) * {level} + var(--spacing-inline))"
193
+ >
194
+ {@render body(node)}
195
+ </a>
196
+ {:else}
197
+ <span
198
+ class="{ROW} {paint}"
199
+ style="padding-inline-start: calc(var(--spacing-nav-indent) * {level} + var(--spacing-inline))"
200
+ >
201
+ {@render body(node)}
202
+ </span>
203
+ {/if}
204
+
205
+ {#if branch}
206
+ <!--
207
+ Rendered whether or not it is open, and hidden with a display utility.
208
+ aria-controls has to name an element that exists; pointing it at markup
209
+ that appears only once the group is open leaves the reference dangling
210
+ in the one state where a reader needs it to tell them what the button
211
+ will reveal.
212
+
213
+ It keeps the implicit list role. role="group" here overrode it, and a
214
+ listitem whose parent is not a list is an ARIA context error, so every
215
+ row of an open section came back as a serious axe violation. Grouping
216
+ semantics belong to a tree, where the parent is a treeitem; this is a
217
+ nav of plain links and the disclosure already says what it controls.
218
+ -->
219
+ <ul
220
+ id={listId(node.id)}
221
+ class="{open && !node.disabled ? 'flex' : 'hidden'} mt-0.5 flex-col gap-0.5"
222
+ >
223
+ {#each children as child (child.id)}
224
+ {@render row(child, depth + 1)}
225
+ {/each}
226
+ </ul>
227
+ {/if}
228
+ </li>
229
+ {/snippet}
230
+
231
+ {#snippet body(node: NavNode)}
232
+ {#if node.icon}
233
+ {@const Icon = node.icon}
234
+ <Icon size={16} class="shrink-0" />
235
+ {/if}
236
+ <!--
237
+ The label stays in the accessible name on a collapsed rail. Dropping it
238
+ leaves a row whose only content is an icon, which a screen reader announces
239
+ as an empty link.
240
+ -->
241
+ <span class={collapsed ? 'sr-only' : 'min-w-0 flex-1 truncate'}>{node.label}</span>
242
+ {#if node.badge !== undefined && !collapsed}
243
+ <span class="{BADGE} {BADGE_TONE[node.badgeTone ?? 'neutral']}">{node.badge}</span>
244
+ {/if}
245
+ {/snippet}
246
+
247
+ {#snippet chevron(open: boolean)}
248
+ <!-- Stroked SVG, not a Unicode arrow: a glyph lands at whatever weight the
249
+ reader's font gives it, visibly apart from every other icon here. -->
250
+ <svg
251
+ width="14"
252
+ height="14"
253
+ viewBox="0 0 24 24"
254
+ fill="none"
255
+ stroke="currentColor"
256
+ stroke-width="2"
257
+ stroke-linecap="round"
258
+ stroke-linejoin="round"
259
+ aria-hidden="true"
260
+ class="shrink-0 transition-transform duration-150 {open ? 'rotate-90' : ''}"
261
+ >
262
+ <path d="M9 18l6-6-6-6" />
263
+ </svg>
264
+ {/snippet}
@@ -0,0 +1,17 @@
1
+ import { type NavTree } from '../internal/nav-tree.js';
2
+ interface Props {
3
+ items: NavTree;
4
+ /** Pathname only, no query, no hash. Passed in rather than read, because the kit cannot import an app router. */
5
+ activePath: string;
6
+ /** The nav landmark's accessible name. */
7
+ label?: string;
8
+ /** Icon-only rail. */
9
+ collapsed?: boolean;
10
+ expandActive?: boolean;
11
+ exclusive?: boolean;
12
+ storageKey?: string;
13
+ class?: string;
14
+ }
15
+ declare const SidebarNav: import("svelte").Component<Props, {}, "">;
16
+ type SidebarNav = ReturnType<typeof SidebarNav>;
17
+ export default SidebarNav;
@@ -1,6 +1,17 @@
1
1
  <script lang="ts">
2
+ /**
3
+ * One measured number with its label.
4
+ *
5
+ * Two apps each declared a local Stat snippet for the same job and the two
6
+ * were visually unrelated: one a bordered card with a mono value, the other a
7
+ * bare div at body size. `size`, `tone` and `mono` are what let both of them
8
+ * use this one instead.
9
+ */
10
+ import type { StatusTone } from '../internal/tone.js';
11
+
2
12
  type Trend = 'up' | 'down' | 'flat';
3
13
  type Accent = 'brand' | 'violet' | 'success' | 'neutral';
14
+ type Size = 'sm' | 'md';
4
15
 
5
16
  interface Props {
6
17
  label: string;
@@ -9,6 +20,27 @@
9
20
  trend?: Trend;
10
21
  change?: string;
11
22
  accent?: Accent;
23
+ /** `sm` for a dense row of figures, `md` for a card of its own. */
24
+ size?: Size;
25
+ /**
26
+ * The status the figure reports, which colours the value.
27
+ *
28
+ * Wins over `accent` when it is set. `accent` is the decorative vocabulary
29
+ * and has no warn or danger, so a number that is over its quota could only
30
+ * be stated in prose beside it. Only the canonical spellings are accepted:
31
+ * the tone vocabulary keeps `info` as an alias for the components that
32
+ * already shipped it, and this prop never shipped anything to keep working.
33
+ */
34
+ tone?: StatusTone;
35
+ /**
36
+ * Tabular figures for a value that changes.
37
+ *
38
+ * A proportional 1 is narrower than a proportional 0, so a counter that
39
+ * ticks changes width on nearly every update and shoves whatever sits
40
+ * beside it sideways. The mono family and tabular figures together hold
41
+ * every digit on the same advance.
42
+ */
43
+ mono?: boolean;
12
44
  class?: string;
13
45
  }
14
46
 
@@ -19,6 +51,9 @@
19
51
  trend = undefined,
20
52
  change = undefined,
21
53
  accent = 'brand',
54
+ size = 'md',
55
+ tone = undefined,
56
+ mono = false,
22
57
  class: klass = '',
23
58
  }: Props = $props();
24
59
 
@@ -29,13 +64,29 @@
29
64
  neutral: 'text-fg',
30
65
  } as const;
31
66
 
67
+ const tones: Record<StatusTone, string> = {
68
+ neutral: 'text-fg',
69
+ brand: 'text-brand',
70
+ success: 'text-success',
71
+ warn: 'text-warn',
72
+ danger: 'text-danger',
73
+ };
74
+
75
+ const pads: Record<Size, string> = { sm: 'p-4', md: 'p-5' };
76
+ const values: Record<Size, string> = {
77
+ sm: 'mt-1 text-lg font-semibold',
78
+ md: 'mt-2 text-2xl font-bold',
79
+ };
80
+
81
+ const valueColor = $derived(tone ? tones[tone] : accents[accent]);
82
+
32
83
  const trendColor = { up: 'text-success', down: 'text-danger', flat: 'text-muted' } as const;
33
84
  const trendMark = { up: '↑', down: '↓', flat: '→' } as const;
34
85
  </script>
35
86
 
36
- <div class="rounded-xl border border-line bg-surface p-5 {klass}">
87
+ <div class="rounded-xl border border-line bg-surface {pads[size]} {klass}">
37
88
  <p class="text-xs font-medium uppercase tracking-wide text-faint">{label}</p>
38
- <p class="mt-2 text-2xl font-bold {accents[accent]}">{value}</p>
89
+ <p class="{values[size]} {valueColor} {mono ? 'font-mono tabular-nums' : ''}">{value}</p>
39
90
  <div class="mt-1 flex items-center gap-2">
40
91
  {#if trend && change}
41
92
  <span class="text-xs font-medium {trendColor[trend]}">{trendMark[trend]} {change}</span>
@@ -1,5 +1,15 @@
1
+ /**
2
+ * One measured number with its label.
3
+ *
4
+ * Two apps each declared a local Stat snippet for the same job and the two
5
+ * were visually unrelated: one a bordered card with a mono value, the other a
6
+ * bare div at body size. `size`, `tone` and `mono` are what let both of them
7
+ * use this one instead.
8
+ */
9
+ import type { StatusTone } from '../internal/tone.js';
1
10
  type Trend = 'up' | 'down' | 'flat';
2
11
  type Accent = 'brand' | 'violet' | 'success' | 'neutral';
12
+ type Size = 'sm' | 'md';
3
13
  interface Props {
4
14
  label: string;
5
15
  value: string | number;
@@ -7,6 +17,27 @@ interface Props {
7
17
  trend?: Trend;
8
18
  change?: string;
9
19
  accent?: Accent;
20
+ /** `sm` for a dense row of figures, `md` for a card of its own. */
21
+ size?: Size;
22
+ /**
23
+ * The status the figure reports, which colours the value.
24
+ *
25
+ * Wins over `accent` when it is set. `accent` is the decorative vocabulary
26
+ * and has no warn or danger, so a number that is over its quota could only
27
+ * be stated in prose beside it. Only the canonical spellings are accepted:
28
+ * the tone vocabulary keeps `info` as an alias for the components that
29
+ * already shipped it, and this prop never shipped anything to keep working.
30
+ */
31
+ tone?: StatusTone;
32
+ /**
33
+ * Tabular figures for a value that changes.
34
+ *
35
+ * A proportional 1 is narrower than a proportional 0, so a counter that
36
+ * ticks changes width on nearly every update and shoves whatever sits
37
+ * beside it sideways. The mono family and tabular figures together hold
38
+ * every digit on the same advance.
39
+ */
40
+ mono?: boolean;
10
41
  class?: string;
11
42
  }
12
43
  declare const Stat: import("svelte").Component<Props, {}, "">;
@@ -54,7 +54,7 @@
54
54
  <div class="{FIELD_WRAP} {cls}">
55
55
  {#if label}
56
56
  <label for={fieldId} class={FIELD_LABEL}>
57
- {label}{#if required}<span class="text-danger ml-0.5" aria-label="required">*</span>{/if}
57
+ {label}{#if required}<span class="text-danger ml-0.5" aria-hidden="true">*</span>{/if}
58
58
  </label>
59
59
  {/if}
60
60