@ruckc/truss-ui 1.2.0 → 1.4.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 (85) hide show
  1. package/README.md +6 -1
  2. package/dist/commands.svelte.d.ts +41 -0
  3. package/dist/commands.svelte.js +59 -0
  4. package/dist/components/Accordion.svelte +1 -1
  5. package/dist/components/Button.svelte +65 -8
  6. package/dist/components/Button.svelte.d.ts +2 -0
  7. package/dist/components/Calendar.svelte +2 -2
  8. package/dist/components/Card.svelte +1 -0
  9. package/dist/components/Card.svelte.d.ts +1 -0
  10. package/dist/components/Checkbox.svelte +1 -1
  11. package/dist/components/CommandPalette.svelte +207 -0
  12. package/dist/components/CommandPalette.svelte.d.ts +39 -0
  13. package/dist/components/DataTable.svelte +201 -27
  14. package/dist/components/DataTable.svelte.d.ts +50 -4
  15. package/dist/components/Dialog.svelte +2 -1
  16. package/dist/components/Drawer.svelte +1 -1
  17. package/dist/components/EditableText.svelte +2 -2
  18. package/dist/components/Eyebrow.svelte +1 -1
  19. package/dist/components/FileUpload.svelte +1 -1
  20. package/dist/components/Footer.svelte +29 -2
  21. package/dist/components/Footer.svelte.d.ts +8 -0
  22. package/dist/components/GridLayout.svelte +242 -0
  23. package/dist/components/GridLayout.svelte.d.ts +30 -0
  24. package/dist/components/HoverCard.svelte +158 -0
  25. package/dist/components/HoverCard.svelte.d.ts +46 -0
  26. package/dist/components/Kbd.svelte +36 -0
  27. package/dist/components/Kbd.svelte.d.ts +8 -0
  28. package/dist/components/Link.svelte +1 -1
  29. package/dist/components/List.svelte +113 -0
  30. package/dist/components/List.svelte.d.ts +17 -0
  31. package/dist/components/ListItem.svelte +13 -0
  32. package/dist/components/ListItem.svelte.d.ts +9 -0
  33. package/dist/components/Menu.svelte +4 -3
  34. package/dist/components/MultiSelect.svelte +2 -2
  35. package/dist/components/Navbar.svelte +13 -3
  36. package/dist/components/Navbar.svelte.d.ts +5 -1
  37. package/dist/components/Panel.svelte +2 -2
  38. package/dist/components/Popover.svelte +22 -1
  39. package/dist/components/Popover.svelte.d.ts +2 -0
  40. package/dist/components/RadioGroup.svelte +1 -1
  41. package/dist/components/Select.svelte +1 -1
  42. package/dist/components/Shell.svelte +12 -1
  43. package/dist/components/Shell.svelte.d.ts +2 -0
  44. package/dist/components/ShortcutSheet.svelte +62 -0
  45. package/dist/components/ShortcutSheet.svelte.d.ts +9 -0
  46. package/dist/components/Sidebar.svelte +2 -2
  47. package/dist/components/Skeleton.svelte +80 -6
  48. package/dist/components/Skeleton.svelte.d.ts +8 -1
  49. package/dist/components/Slider.svelte +1 -1
  50. package/dist/components/Switch.svelte +1 -1
  51. package/dist/components/Tabs.svelte +252 -32
  52. package/dist/components/Tabs.svelte.d.ts +21 -9
  53. package/dist/components/Tag.svelte +7 -7
  54. package/dist/components/TagInput.svelte +1 -1
  55. package/dist/components/Text.svelte +1 -1
  56. package/dist/components/Toaster.svelte +33 -2
  57. package/dist/components/ToggleChip.svelte +1 -1
  58. package/dist/components/Toolbar.svelte +27 -0
  59. package/dist/components/Toolbar.svelte.d.ts +10 -0
  60. package/dist/components/ToolbarSeparator.svelte +15 -0
  61. package/dist/components/ToolbarSeparator.svelte.d.ts +3 -0
  62. package/dist/components/Tooltip.svelte +19 -28
  63. package/dist/components/Tooltip.svelte.d.ts +6 -1
  64. package/dist/components/TooltipTip.svelte +59 -0
  65. package/dist/components/TooltipTip.svelte.d.ts +12 -0
  66. package/dist/components/Tree.svelte +343 -44
  67. package/dist/components/Tree.svelte.d.ts +29 -2
  68. package/dist/grid-layout.d.ts +42 -0
  69. package/dist/grid-layout.js +100 -0
  70. package/dist/hotkeys.svelte.d.ts +45 -0
  71. package/dist/hotkeys.svelte.js +108 -0
  72. package/dist/index.d.ts +21 -2
  73. package/dist/index.js +13 -0
  74. package/dist/keys.d.ts +25 -0
  75. package/dist/keys.js +88 -0
  76. package/dist/roving.d.ts +25 -0
  77. package/dist/roving.js +129 -0
  78. package/dist/styles/base.css +2 -0
  79. package/dist/styles/tokens.css +49 -0
  80. package/dist/table-export.d.ts +24 -0
  81. package/dist/table-export.js +130 -0
  82. package/dist/toast.svelte.d.ts +25 -1
  83. package/dist/toast.svelte.js +42 -4
  84. package/dist/types.d.ts +6 -0
  85. package/package.json +7 -3
@@ -1,29 +1,54 @@
1
1
  <script lang="ts">
2
- import type { Snippet } from 'svelte';
2
+ import { tick, type Snippet } from 'svelte';
3
3
  import Icon from '../icons/Icon.svelte';
4
4
  import type { IconName } from '../icons/paths.js';
5
+ import ContextMenu from './ContextMenu.svelte';
6
+ import MenuButton from './MenuButton.svelte';
7
+ import { rovingTarget } from '../roving.js';
8
+ import type { MenuEntry } from '../types.js';
9
+
10
+ type Tab = {
11
+ id: string;
12
+ label: string;
13
+ icon?: IconName;
14
+ disabled?: boolean;
15
+ /** Document variant: shows a close button (and enables Delete / Ctrl+W, middle-click and the context-menu entries). */
16
+ closable?: boolean;
17
+ /** Document variant: unsaved changes (a dot, plus "unsaved changes" in the accessible name). */
18
+ dirty?: boolean;
19
+ /** Document variant: renders the tab as a real link (client routing, open in new tab). */
20
+ href?: string;
21
+ };
5
22
 
6
23
  let {
7
- tabs,
24
+ tabs = $bindable(),
8
25
  value = $bindable(),
9
26
  panel,
10
27
  label = 'Sections',
11
- variant = 'line'
28
+ variant = 'line',
29
+ onclose,
30
+ onreorder
12
31
  }: {
13
- tabs: { id: string; label: string; icon?: IconName; disabled?: boolean }[];
32
+ /** In the document variant the list is reordered in place (and `onreorder` fires), so bind it or apply `onreorder` yourself. */
33
+ tabs: Tab[];
14
34
  value?: string;
15
35
  /** Rendered for the active tab only (lazy). */
16
36
  panel: Snippet<[id: string]>;
17
37
  label?: string;
18
- variant?: 'line' | 'pill';
38
+ variant?: 'line' | 'pill' | 'document';
39
+ /** Document variant: a closable tab was asked to close (button, middle-click, Delete / Ctrl+W, context menu). Remove it from `tabs` in response. */
40
+ onclose?: (id: string) => void;
41
+ /** Document variant: the new order of tab ids after a drag or Ctrl+Shift+Arrow. */
42
+ onreorder?: (ids: string[]) => void;
19
43
  } = $props();
20
44
 
21
45
  const uid = $props.id();
22
- const current = $derived(value ?? tabs.find((t) => !t.disabled)?.id);
23
- let list: HTMLDivElement;
46
+ const doc = $derived(variant === 'document');
47
+ const current = $derived(doc ? (tabs.some((t) => t.id === value) ? value : tabs.find((t) => !t.disabled)?.id) : (value ?? tabs.find((t) => !t.disabled)?.id));
48
+ let list = $state<HTMLDivElement>();
24
49
 
25
50
  function onKey(e: KeyboardEvent) {
26
- const btns = [...list.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)')];
51
+ const btns = [...list!.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)')];
27
52
  const i = btns.indexOf(document.activeElement as HTMLButtonElement);
28
53
  const to = e.key === 'ArrowRight' ? (i + 1) % btns.length : e.key === 'ArrowLeft' ? (i - 1 + btns.length) % btns.length : e.key === 'Home' ? 0 : e.key === 'End' ? btns.length - 1 : -1;
29
54
  if (to < 0) return;
@@ -31,44 +56,239 @@
31
56
  btns[to].focus();
32
57
  value = btns[to].dataset.id; // automatic activation
33
58
  }
59
+
60
+ // ───────────── document variant
61
+ let announcement = $state('');
62
+ let overflowing = $state(false);
63
+ let canLeft = $state(false);
64
+ let canRight = $state(false);
65
+ let drag = $state<{ id: string; ins: number } | null>(null);
66
+ let press: { id: string; x: number; started: boolean } | null = null;
67
+
68
+ const tabEl = (id: string) => list?.querySelector<HTMLElement>(`[role="tab"][data-id="${CSS.escape(id)}"]`);
69
+
70
+ function measure() {
71
+ if (!list) return;
72
+ overflowing = list.scrollWidth > list.clientWidth + 1;
73
+ canLeft = list.scrollLeft > 0;
74
+ canRight = list.scrollLeft + list.clientWidth < list.scrollWidth - 1;
75
+ }
76
+ $effect(() => {
77
+ if (!doc || !list) return;
78
+ void tabs.map((t) => t.label + (t.dirty ? '*' : '') + (t.closable ? 'x' : '')).join();
79
+ measure();
80
+ const ro = new ResizeObserver(measure);
81
+ ro.observe(list);
82
+ return () => ro.disconnect();
83
+ });
84
+ // keep the active tab in view
85
+ $effect(() => {
86
+ if (!doc || !current) return;
87
+ const id = current;
88
+ tick().then(() => tabEl(id)?.scrollIntoView({ block: 'nearest', inline: 'nearest' }));
89
+ });
90
+ const scrollBy = (dir: 1 | -1) => list?.scrollBy({ left: dir * Math.max(120, list.clientWidth * 0.6), behavior: 'smooth' });
91
+
92
+ function close(id: string) {
93
+ const i = tabs.findIndex((t) => t.id === id);
94
+ if (i < 0 || !tabs[i].closable) return;
95
+ const focused = tabEl(id)?.matches(':focus');
96
+ const neighbour = (tabs[i + 1] ?? tabs[i - 1])?.id;
97
+ onclose?.(id);
98
+ if (focused && neighbour) tick().then(() => !tabs.some((t) => t.id === id) && tabEl(neighbour)?.focus());
99
+ }
100
+ const closeAll = (ids: string[]) => ids.forEach(close);
101
+
102
+ function move(id: string, to: number) {
103
+ const from = tabs.findIndex((t) => t.id === id);
104
+ to = Math.max(0, Math.min(tabs.length - 1, to));
105
+ if (from < 0 || from === to) return;
106
+ const refocus = tabEl(id)?.matches(':focus');
107
+ const next = [...tabs];
108
+ next.splice(to, 0, ...next.splice(from, 1));
109
+ tabs = next;
110
+ announcement = `${next[to].label}, moved to position ${to + 1} of ${next.length}`;
111
+ onreorder?.(next.map((t) => t.id));
112
+ if (refocus) tick().then(() => tabEl(id)?.focus());
113
+ }
114
+
115
+ function onDocKey(e: KeyboardEvent) {
116
+ const el = (e.target as HTMLElement).closest<HTMLElement>('[role="tab"]');
117
+ if (!el) return;
118
+ const id = el.dataset.id!;
119
+ const mod = e.ctrlKey || e.metaKey;
120
+ const i = tabs.findIndex((t) => t.id === id);
121
+ if (mod && e.shiftKey && ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
122
+ e.preventDefault();
123
+ move(id, e.key === 'ArrowLeft' ? i - 1 : e.key === 'ArrowRight' ? i + 1 : e.key === 'Home' ? 0 : tabs.length - 1);
124
+ } else if (e.key === 'Delete' || (mod && !e.shiftKey && e.key.toLowerCase() === 'w')) {
125
+ if (!tabs[i]?.closable) return;
126
+ e.preventDefault();
127
+ close(id);
128
+ } else {
129
+ const to = rovingTarget(e, list!, { selector: '[role="tab"]' });
130
+ if (!to) return;
131
+ e.preventDefault();
132
+ to.focus();
133
+ if (to.tagName !== 'A') value = to.dataset.id; // link tabs navigate on Enter instead
134
+ }
135
+ }
136
+
137
+ function onPointerDown(e: PointerEvent, id: string) {
138
+ if (e.button !== 0 || (e.target as HTMLElement).closest('.close')) return;
139
+ press = { id, x: e.clientX, started: false };
140
+ }
141
+ function onPointerMove(e: PointerEvent) {
142
+ if (!press || !list) return;
143
+ if (!press.started) {
144
+ if (Math.abs(e.clientX - press.x) < 5) return;
145
+ press.started = true;
146
+ list.setPointerCapture(e.pointerId);
147
+ }
148
+ const mids = [...list.querySelectorAll<HTMLElement>('.tab')].map((n) => { const r = n.getBoundingClientRect(); return r.left + r.width / 2; });
149
+ drag = { id: press.id, ins: mids.filter((m) => m < e.clientX).length };
150
+ }
151
+ function endPointer() {
152
+ if (press?.started && drag) {
153
+ const from = tabs.findIndex((t) => t.id === drag!.id);
154
+ move(drag.id, from < drag.ins ? drag.ins - 1 : drag.ins);
155
+ }
156
+ press = null;
157
+ drag = null;
158
+ }
159
+
160
+ const menuFor = (id: string): MenuEntry[] => {
161
+ const i = tabs.findIndex((t) => t.id === id);
162
+ const closable = (xs: Tab[]) => xs.filter((t) => t.closable).map((t) => t.id);
163
+ const others = closable(tabs.filter((t) => t.id !== id));
164
+ const right = closable(tabs.slice(i + 1));
165
+ return [
166
+ { label: 'Close', disabled: !tabs[i]?.closable, onselect: () => close(id) },
167
+ { label: 'Close others', disabled: !others.length, onselect: () => closeAll(others) },
168
+ { label: 'Close to the right', disabled: !right.length, onselect: () => closeAll(right) }
169
+ ];
170
+ };
171
+ const allTabs = $derived<MenuEntry[]>(
172
+ tabs.map((t) => ({ label: t.dirty ? `${t.label} (unsaved)` : t.label, icon: t.icon, disabled: t.disabled, href: t.href, onselect: t.href ? undefined : () => { value = t.id; tick().then(() => tabEl(t.id)?.focus()); } }))
173
+ );
34
174
  </script>
35
175
 
36
- <div class="tabs" data-variant={variant}>
37
- <!-- svelte-ignore a11y_interactive_supports_focus -->
38
- <div class="list" role="tablist" aria-label={label} bind:this={list} onkeydown={onKey}>
39
- {#each tabs as t (t.id)}
40
- <button
41
- type="button" role="tab" id="{uid}-tab-{t.id}" data-id={t.id} disabled={t.disabled}
42
- aria-selected={t.id === current} aria-controls="{uid}-panel-{t.id}" tabindex={t.id === current ? 0 : -1}
43
- onclick={() => (value = t.id)}
44
- >
45
- {#if t.icon}<Icon name={t.icon} />{/if}{t.label}
46
- </button>
47
- {/each}
48
- </div>
176
+ {#snippet tabBody(t: Tab)}
177
+ {#if t.icon}<Icon name={t.icon} />{/if}{t.label}{#if t.dirty}<span class="dot" aria-hidden="true"></span><span class="tr-visually-hidden">, unsaved changes</span>{/if}
178
+ {/snippet}
179
+
180
+ {#snippet panelView()}
49
181
  {#if current}
50
182
  <div class="panel" role="tabpanel" id="{uid}-panel-{current}" aria-labelledby="{uid}-tab-{current}" tabindex="0">
51
183
  {@render panel(current)}
52
184
  </div>
53
185
  {/if}
54
- </div>
186
+ {/snippet}
187
+
188
+ {#if doc}
189
+ <div class="tabs" data-variant="document">
190
+ <div class="strip">
191
+ {#if overflowing}
192
+ <button type="button" class="scroll" aria-label="Scroll tabs left" tabindex="-1" disabled={!canLeft} onclick={() => scrollBy(-1)}><Icon name="chevron-left" /></button>
193
+ {/if}
194
+ <!-- svelte-ignore a11y_interactive_supports_focus -->
195
+ <div
196
+ class="list" role="tablist" aria-label={label} bind:this={list} onkeydown={onDocKey} onscroll={measure}
197
+ onpointermove={onPointerMove} onpointerup={endPointer} onpointercancel={endPointer}
198
+ >
199
+ {#each tabs as t, i (t.id)}
200
+ <ContextMenu items={menuFor(t.id)} label="{t.label} actions">
201
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
202
+ <div
203
+ class="tab" role="presentation" data-id={t.id} data-active={t.id === current} data-dragging={drag?.id === t.id}
204
+ data-drop={drag && drag.ins === i ? 'before' : drag && drag.ins === tabs.length && i === tabs.length - 1 ? 'after' : undefined}
205
+ onpointerdown={(e) => onPointerDown(e, t.id)}
206
+ onmousedown={(e) => { if (e.button === 1) e.preventDefault(); }}
207
+ onauxclick={(e) => { if (e.button === 1 && t.closable) { e.preventDefault(); close(t.id); } }}
208
+ >
209
+ <svelte:element
210
+ this={t.href && !t.disabled ? 'a' : 'button'} class="main" role="tab" id="{uid}-tab-{t.id}" data-id={t.id}
211
+ type={t.href && !t.disabled ? undefined : 'button'} href={t.href && !t.disabled ? t.href : undefined} disabled={t.disabled || undefined}
212
+ aria-selected={t.id === current} aria-controls="{uid}-panel-{t.id}" tabindex={t.id === current ? 0 : -1}
213
+ onclick={(e: MouseEvent) => { if (!(e.ctrlKey || e.metaKey || e.shiftKey)) value = t.id; }}
214
+ >{@render tabBody(t)}</svelte:element>
215
+ {#if t.closable}
216
+ <button type="button" class="close" tabindex="-1" aria-label="Close {t.label}" onclick={() => close(t.id)}><Icon name="cross" /></button>
217
+ {/if}
218
+ </div>
219
+ </ContextMenu>
220
+ {/each}
221
+ </div>
222
+ {#if overflowing}
223
+ <button type="button" class="scroll" aria-label="Scroll tabs right" tabindex="-1" disabled={!canRight} onclick={() => scrollBy(1)}><Icon name="chevron-right" /></button>
224
+ <MenuButton label="All tabs" icon="chevron-down" iconOnly variant="minimal" size="sm" align="end" items={allTabs} />
225
+ {/if}
226
+ </div>
227
+ <div class="tr-visually-hidden" role="status" aria-live="polite">{announcement}</div>
228
+ {@render panelView()}
229
+ </div>
230
+ {:else}
231
+ <div class="tabs" data-variant={variant}>
232
+ <!-- svelte-ignore a11y_interactive_supports_focus -->
233
+ <div class="list" role="tablist" aria-label={label} bind:this={list} onkeydown={onKey}>
234
+ {#each tabs as t (t.id)}
235
+ <button
236
+ type="button" role="tab" id="{uid}-tab-{t.id}" data-id={t.id} disabled={t.disabled}
237
+ aria-selected={t.id === current} aria-controls="{uid}-panel-{t.id}" tabindex={t.id === current ? 0 : -1}
238
+ onclick={() => (value = t.id)}
239
+ >
240
+ {#if t.icon}<Icon name={t.icon} />{/if}{t.label}
241
+ </button>
242
+ {/each}
243
+ </div>
244
+ {@render panelView()}
245
+ </div>
246
+ {/if}
55
247
 
56
248
  <style>
57
249
  .list { display: flex; gap: 2px; border-bottom: 1px solid var(--tr-outline-variant); overflow-x: auto; }
58
- button {
250
+ [role='tab'] {
59
251
  position: relative; display: inline-flex; align-items: center; gap: 6px; height: 36px; padding: 0 14px; border: 0; background: none;
60
- color: var(--tr-on-surface-variant); font: 600 var(--tr-text-md) var(--tr-font-sans); white-space: nowrap; cursor: pointer;
252
+ color: var(--tr-on-surface-variant); font: 600 var(--tr-text-md) var(--tr-font-sans); white-space: nowrap; cursor: pointer; text-decoration: none;
61
253
  border-radius: var(--tr-radius-sm) var(--tr-radius-sm) 0 0; transition: background-color var(--tr-dur) var(--tr-ease), color var(--tr-dur) var(--tr-ease);
62
254
  }
63
- button:hover:not(:disabled) { background: var(--tr-surface-container-high); color: var(--tr-on-surface); }
64
- button:disabled { opacity: 0.45; cursor: not-allowed; }
65
- button:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--tr-primary); }
66
- button[aria-selected='true'] { color: var(--tr-primary); }
67
- button[aria-selected='true']::after { content: ''; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 3px; border-radius: 3px 3px 0 0; background: var(--tr-primary); }
255
+ [role='tab']:hover:not(:disabled) { background: var(--tr-surface-container-high); color: var(--tr-on-surface); }
256
+ [role='tab']:disabled { opacity: 0.45; cursor: not-allowed; }
257
+ [role='tab']:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--tr-primary); }
258
+ [role='tab'][aria-selected='true'] { color: var(--tr-primary-text, var(--tr-primary)); }
259
+ [role='tab'][aria-selected='true']::after { content: ''; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 3px; border-radius: 3px 3px 0 0; background: var(--tr-primary); }
68
260
  .panel { padding: 16px 4px; outline: none; }
69
261
  .panel:focus-visible { box-shadow: var(--tr-focus-ring); border-radius: var(--tr-radius-sm); }
70
262
  [data-variant='pill'] .list { border: 0; gap: 4px; }
71
- [data-variant='pill'] button { height: var(--tr-control-h); border-radius: var(--tr-radius-pill); }
72
- [data-variant='pill'] button[aria-selected='true'] { background: var(--tr-primary-container); color: var(--tr-on-primary-container); }
73
- [data-variant='pill'] button[aria-selected='true']::after { display: none; }
263
+ [data-variant='pill'] [role='tab'] { height: var(--tr-control-h); border-radius: var(--tr-radius-pill); }
264
+ [data-variant='pill'] [role='tab'][aria-selected='true'] { background: var(--tr-primary-container); color: var(--tr-on-primary-container); }
265
+ [data-variant='pill'] [role='tab'][aria-selected='true']::after { display: none; }
266
+
267
+ /* document variant */
268
+ .strip { display: flex; align-items: stretch; gap: 2px; border-bottom: 1px solid var(--tr-outline-variant); }
269
+ [data-variant='document'] .list { flex: 1; min-width: 0; border: 0; gap: 0; scrollbar-width: none; user-select: none; }
270
+ [data-variant='document'] .list::-webkit-scrollbar { display: none; }
271
+ .tab { position: relative; display: inline-flex; flex: none; align-items: center; border: 1px solid transparent; border-bottom: 0; border-radius: var(--tr-radius-sm) var(--tr-radius-sm) 0 0; margin-bottom: -1px; }
272
+ .tab:hover { background: var(--tr-surface-container-high); }
273
+ .tab[data-active='true'] { background: var(--tr-surface); border-color: var(--tr-outline-variant); }
274
+ .tab[data-active='true']::before { content: ''; position: absolute; left: -1px; right: -1px; top: -1px; height: 2px; border-radius: 2px 2px 0 0; background: var(--tr-primary); }
275
+ .tab[data-dragging='true'] { opacity: 0.55; }
276
+ .tab[data-drop='before'] { box-shadow: inset 2px 0 0 var(--tr-primary); }
277
+ .tab[data-drop='after'] { box-shadow: inset -2px 0 0 var(--tr-primary); }
278
+ [data-variant='document'] .main { height: 34px; padding: 0 8px 0 12px; border-radius: 0; background: none; max-width: 240px; }
279
+ [data-variant='document'] .tab:not(:has(.close)) .main { padding-right: 12px; }
280
+ [data-variant='document'] .main:hover:not(:disabled) { background: none; }
281
+ [data-variant='document'] .main[aria-selected='true']::after { display: none; }
282
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--tr-warning); flex: none; }
283
+ .close, .scroll {
284
+ display: inline-flex; align-items: center; justify-content: center; flex: none; width: 20px; height: 20px; padding: 0; border: 0; border-radius: var(--tr-radius-sm);
285
+ background: none; color: var(--tr-on-surface-variant); cursor: pointer; font-size: 12px;
286
+ }
287
+ .close { margin-right: 6px; opacity: 0; }
288
+ .tab:hover .close, .tab[data-active='true'] .close, .close:focus-visible { opacity: 1; }
289
+ @media (hover: none) { .close { opacity: 1; } }
290
+ .close:hover, .scroll:hover:not(:disabled) { background: var(--tr-surface-container-highest); color: var(--tr-on-surface); }
291
+ .close:focus-visible, .scroll:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); }
292
+ .scroll { width: 28px; height: auto; align-self: center; min-height: 28px; }
293
+ .scroll:disabled { opacity: 0.35; cursor: default; }
74
294
  </style>
@@ -1,18 +1,30 @@
1
- import type { Snippet } from 'svelte';
1
+ import { type Snippet } from 'svelte';
2
2
  import type { IconName } from '../icons/paths.js';
3
+ type Tab = {
4
+ id: string;
5
+ label: string;
6
+ icon?: IconName;
7
+ disabled?: boolean;
8
+ /** Document variant: shows a close button (and enables Delete / Ctrl+W, middle-click and the context-menu entries). */
9
+ closable?: boolean;
10
+ /** Document variant: unsaved changes (a dot, plus "unsaved changes" in the accessible name). */
11
+ dirty?: boolean;
12
+ /** Document variant: renders the tab as a real link (client routing, open in new tab). */
13
+ href?: string;
14
+ };
3
15
  type $$ComponentProps = {
4
- tabs: {
5
- id: string;
6
- label: string;
7
- icon?: IconName;
8
- disabled?: boolean;
9
- }[];
16
+ /** In the document variant the list is reordered in place (and `onreorder` fires), so bind it or apply `onreorder` yourself. */
17
+ tabs: Tab[];
10
18
  value?: string;
11
19
  /** Rendered for the active tab only (lazy). */
12
20
  panel: Snippet<[id: string]>;
13
21
  label?: string;
14
- variant?: 'line' | 'pill';
22
+ variant?: 'line' | 'pill' | 'document';
23
+ /** Document variant: a closable tab was asked to close (button, middle-click, Delete / Ctrl+W, context menu). Remove it from `tabs` in response. */
24
+ onclose?: (id: string) => void;
25
+ /** Document variant: the new order of tab ids after a drag or Ctrl+Shift+Arrow. */
26
+ onreorder?: (ids: string[]) => void;
15
27
  };
16
- declare const Tabs: import("svelte").Component<$$ComponentProps, {}, "value">;
28
+ declare const Tabs: import("svelte").Component<$$ComponentProps, {}, "value" | "tabs">;
17
29
  type Tabs = ReturnType<typeof Tabs>;
18
30
  export default Tabs;
@@ -43,14 +43,14 @@
43
43
  </span>
44
44
 
45
45
  <style>
46
- .tag { --c: var(--tr-on-surface-variant); --cc: var(--tr-surface-container-high); --on: var(--tr-on-surface); display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 8px; border-radius: var(--tr-radius-sm); background: var(--cc); color: var(--on); font: 600 var(--tr-text-sm) var(--tr-font-display); letter-spacing: var(--tr-display-tracking); text-transform: var(--tr-display-transform); white-space: nowrap; }
46
+ .tag { --c: var(--tr-on-surface-variant); --ct: var(--c); --cc: var(--tr-surface-container-high); --on: var(--tr-on-surface); display: inline-flex; align-items: center; gap: 4px; height: var(--tr-control-h-sm); padding: 0 8px; border-radius: var(--tr-radius-sm); background: var(--cc); color: var(--on); font: 600 var(--tr-text-sm) var(--tr-font-display); letter-spacing: var(--tr-display-tracking); text-transform: var(--tr-display-transform); white-space: nowrap; }
47
47
  .tag[data-size='sm'] { height: 20px; padding: 0 6px; font-size: var(--tr-text-xs); }
48
- .tag[data-intent='primary'] { --c: var(--tr-primary); --cc: var(--tr-primary-container); --on: var(--tr-on-primary-container); }
49
- .tag[data-intent='secondary'] { --c: var(--tr-secondary); --cc: var(--tr-secondary-container); --on: var(--tr-on-secondary-container); }
50
- .tag[data-intent='success'] { --c: var(--tr-success); --cc: var(--tr-success-container); --on: var(--tr-on-success-container); }
51
- .tag[data-intent='warning'] { --c: var(--tr-warning); --cc: var(--tr-warning-container); --on: var(--tr-on-warning-container); }
52
- .tag[data-intent='danger'] { --c: var(--tr-danger); --cc: var(--tr-danger-container); --on: var(--tr-on-danger-container); }
53
- .tag[data-variant='outlined'] { background: transparent; box-shadow: inset 0 0 0 1px var(--c); color: var(--c); }
48
+ .tag[data-intent='primary'] { --c: var(--tr-primary); --ct: var(--tr-primary-text, var(--tr-primary)); --cc: var(--tr-primary-container); --on: var(--tr-on-primary-container); }
49
+ .tag[data-intent='secondary'] { --c: var(--tr-secondary); --ct: var(--tr-secondary-text, var(--tr-secondary)); --cc: var(--tr-secondary-container); --on: var(--tr-on-secondary-container); }
50
+ .tag[data-intent='success'] { --c: var(--tr-success); --ct: var(--tr-success); --cc: var(--tr-success-container); --on: var(--tr-on-success-container); }
51
+ .tag[data-intent='warning'] { --c: var(--tr-warning); --ct: var(--tr-warning); --cc: var(--tr-warning-container); --on: var(--tr-on-warning-container); }
52
+ .tag[data-intent='danger'] { --c: var(--tr-danger); --ct: var(--tr-danger); --cc: var(--tr-danger-container); --on: var(--tr-on-danger-container); }
53
+ .tag[data-variant='outlined'] { background: transparent; box-shadow: inset 0 0 0 1px var(--c); color: var(--ct); }
54
54
  .main { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; }
55
55
  .tag:has(.main:hover) { background: color-mix(in srgb, var(--on) 10%, var(--cc)); }
56
56
  .main:focus-visible, .x:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); border-radius: var(--tr-radius-sm); }
@@ -68,5 +68,5 @@
68
68
 
69
69
  <style>
70
70
  .ti { flex-wrap: wrap; padding: 3px 6px; gap: 4px; cursor: text; }
71
- .ti .tr-bare { flex: 1 1 80px; height: 24px; }
71
+ .ti .tr-bare { flex: 1 1 80px; height: var(--tr-control-h-sm); }
72
72
  </style>
@@ -27,7 +27,7 @@
27
27
  .tx[data-size='lg'] { font-size: var(--tr-text-lg); }
28
28
  .tx[data-size='xl'] { font-size: var(--tr-text-xl); }
29
29
  .tx[data-tone='muted'] { color: var(--tr-on-surface-variant); }
30
- .tx[data-tone='primary'] { color: var(--tr-primary); }
30
+ .tx[data-tone='primary'] { color: var(--tr-primary-text, var(--tr-primary)); }
31
31
  .tx[data-tone='success'] { color: var(--tr-success); }
32
32
  .tx[data-tone='warning'] { color: var(--tr-warning); }
33
33
  .tx[data-tone='danger'] { color: var(--tr-danger); }
@@ -1,17 +1,48 @@
1
1
  <script lang="ts">
2
2
  import { fly } from 'svelte/transition';
3
- import { toasts } from '../toast.svelte.js';
3
+ import { toasts, type Toast } from '../toast.svelte.js';
4
4
  import Icon from '../icons/Icon.svelte';
5
5
  import Button from './Button.svelte';
6
6
 
7
7
  const glyph = { primary: 'info', success: 'success', warning: 'warning', danger: 'error' } as const;
8
+
9
+ // The timer pauses while the pointer is over a toast or focus is inside it, and resumes once both have left.
10
+ function enter(id: number) {
11
+ toasts.pause(id);
12
+ }
13
+ function leave(e: { currentTarget: EventTarget | null }, id: number) {
14
+ const el = e.currentTarget as HTMLElement;
15
+ if (!el.contains(document.activeElement)) toasts.resume(id);
16
+ }
17
+ function focusOut(e: FocusEvent, id: number) {
18
+ const el = e.currentTarget as HTMLElement;
19
+ const next = e.relatedTarget as Node | null;
20
+ if (next && el.contains(next)) return;
21
+ if (!el.matches(':hover')) toasts.resume(id);
22
+ }
23
+ function act(t: Toast) {
24
+ t.action?.run();
25
+ toasts.dismiss(t.id);
26
+ }
8
27
  </script>
9
28
 
10
29
  <div class="tr-toaster" role="region" aria-label="Notifications">
11
30
  {#each toasts.items as t (t.id)}
12
- <div class="toast" data-intent={t.intent} role={t.intent === 'danger' ? 'alert' : 'status'} transition:fly={{ y: 16, duration: 160 }}>
31
+ <div
32
+ class="toast"
33
+ data-intent={t.intent}
34
+ role={t.intent === 'danger' ? 'alert' : 'status'}
35
+ transition:fly={{ y: 16, duration: 160 }}
36
+ onpointerenter={() => enter(t.id)}
37
+ onpointerleave={(e) => leave(e, t.id)}
38
+ onfocusin={() => enter(t.id)}
39
+ onfocusout={(e) => focusOut(e, t.id)}
40
+ >
13
41
  <span class="glyph"><Icon name={glyph[t.intent]} size={20} /></span>
14
42
  <span class="msg">{t.message}</span>
43
+ {#if t.action}
44
+ <Button variant="minimal" intent="primary" size="sm" onclick={() => act(t)}>{t.action.label}</Button>
45
+ {/if}
15
46
  <Button variant="minimal" intent="neutral" size="sm" icon="cross" aria-label="Dismiss" onclick={() => toasts.dismiss(t.id)} />
16
47
  </div>
17
48
  {/each}
@@ -43,7 +43,7 @@
43
43
  <style>
44
44
  .chip {
45
45
  --cc: var(--tr-primary-container); --on: var(--tr-on-primary-container);
46
- display: inline-flex; align-items: center; gap: 4px; height: 28px; padding: 0 10px;
46
+ display: inline-flex; align-items: center; gap: 4px; height: calc(var(--tr-control-h) - 4px); padding: 0 10px;
47
47
  border: 1px solid var(--tr-outline); border-radius: var(--tr-radius-pill);
48
48
  background: transparent; color: var(--tr-on-surface-variant);
49
49
  font: 600 var(--tr-text-sm) var(--tr-font-display); letter-spacing: var(--tr-display-tracking); text-transform: var(--tr-display-transform);
@@ -0,0 +1,27 @@
1
+ <script lang="ts">
2
+ import { setContext, type Snippet } from 'svelte';
3
+ import { roving } from '../roving.js';
4
+
5
+ let {
6
+ label,
7
+ orientation = 'horizontal',
8
+ children
9
+ }: {
10
+ /** Accessible name (required: a page can hold several toolbars). */
11
+ label: string;
12
+ orientation?: 'horizontal' | 'vertical';
13
+ children: Snippet;
14
+ } = $props();
15
+
16
+ setContext('tr-toolbar', () => orientation);
17
+ </script>
18
+
19
+ <!-- One tab stop for the whole bar; arrow keys move between its controls (roving tabindex). -->
20
+ <div class="tb" role="toolbar" aria-label={label} aria-orientation={orientation} data-orientation={orientation} use:roving={() => ({ orientation })}>
21
+ {@render children()}
22
+ </div>
23
+
24
+ <style>
25
+ .tb { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
26
+ .tb[data-orientation='vertical'] { display: inline-flex; flex-direction: column; flex-wrap: nowrap; align-items: stretch; }
27
+ </style>
@@ -0,0 +1,10 @@
1
+ import { type Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ /** Accessible name (required: a page can hold several toolbars). */
4
+ label: string;
5
+ orientation?: 'horizontal' | 'vertical';
6
+ children: Snippet;
7
+ };
8
+ declare const Toolbar: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type Toolbar = ReturnType<typeof Toolbar>;
10
+ export default Toolbar;
@@ -0,0 +1,15 @@
1
+ <script lang="ts">
2
+ import { getContext } from 'svelte';
3
+
4
+ // The rule runs across the toolbar's main axis, so its own orientation is the opposite.
5
+ const toolbar = getContext<(() => 'horizontal' | 'vertical') | undefined>('tr-toolbar');
6
+ const own = $derived(toolbar?.() === 'vertical' ? 'horizontal' : 'vertical');
7
+ </script>
8
+
9
+ <div class="sep" role="separator" aria-orientation={own} data-orientation={own}></div>
10
+
11
+ <style>
12
+ .sep { flex: none; background: var(--tr-outline-variant); }
13
+ .sep[data-orientation='vertical'] { align-self: stretch; width: 1px; min-height: 1.25em; margin: 0 4px; }
14
+ .sep[data-orientation='horizontal'] { height: 1px; margin: 4px 0; }
15
+ </style>
@@ -0,0 +1,3 @@
1
+ declare const ToolbarSeparator: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type ToolbarSeparator = ReturnType<typeof ToolbarSeparator>;
3
+ export default ToolbarSeparator;
@@ -1,13 +1,22 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import TooltipTip from './TooltipTip.svelte';
4
+ import { ariaKeyShortcuts } from '../keys.js';
3
5
 
4
6
  let {
5
7
  text,
8
+ content,
9
+ shortcut,
6
10
  placement = 'top',
7
11
  delay = 350,
8
12
  children
9
13
  }: {
10
- text: string;
14
+ /** Plain-text hint. Optional when `content` or `shortcut` is given. */
15
+ text?: string;
16
+ /** Rich (non-interactive) content shown in the tip; the tip is `pointer-events: none`, so put no links or buttons in it. */
17
+ content?: Snippet;
18
+ /** A key combination (`mod+k`, `g h`) shown as keys inside the tip, so it is part of the accessible description. `mod` is ⌘ on Apple platforms and Ctrl elsewhere. */
19
+ shortcut?: string;
11
20
  placement?: 'top' | 'bottom' | 'left' | 'right';
12
21
  /** ms before showing on hover (focus shows immediately). */
13
22
  delay?: number;
@@ -16,31 +25,20 @@
16
25
 
17
26
  const id = $props.id();
18
27
  let open = $state(false);
19
- let ready = $state(false);
20
- let x = $state(0);
21
- let y = $state(0);
22
- let host: HTMLSpanElement;
23
- let tip = $state<HTMLDivElement>();
28
+ let host = $state<HTMLSpanElement>();
24
29
  let timer: ReturnType<typeof setTimeout>;
25
30
 
26
31
  const show = (now = false) => { clearTimeout(timer); timer = setTimeout(() => (open = true), now ? 0 : delay); };
27
- const hide = () => { clearTimeout(timer); open = false; ready = false; };
32
+ const hide = () => { clearTimeout(timer); open = false; };
28
33
  $effect(() => () => clearTimeout(timer));
29
34
 
35
+ // expose the shortcut to assistive tech on the wrapped control
30
36
  $effect(() => {
31
- if (!open || !tip) return;
32
- const r = host.getBoundingClientRect();
33
- const t = tip.getBoundingClientRect();
34
- let p = placement;
35
- if (p === 'top' && r.top < t.height + 10) p = 'bottom';
36
- else if (p === 'bottom' && r.bottom + t.height + 10 > innerHeight) p = 'top';
37
- else if (p === 'left' && r.left < t.width + 10) p = 'right';
38
- else if (p === 'right' && r.right + t.width + 10 > innerWidth) p = 'left';
39
- const cx = r.left + r.width / 2 - t.width / 2, cy = r.top + r.height / 2 - t.height / 2;
40
- const pos = { top: [cx, r.top - t.height - 6], bottom: [cx, r.bottom + 6], left: [r.left - t.width - 6, cy], right: [r.right + 6, cy] }[p];
41
- x = Math.min(Math.max(4, pos[0]), innerWidth - t.width - 4);
42
- y = Math.max(4, pos[1]);
43
- ready = true;
37
+ const el = host?.firstElementChild;
38
+ const v = shortcut ? ariaKeyShortcuts(shortcut) : undefined;
39
+ if (!el || !v || el.hasAttribute('aria-keyshortcuts')) return;
40
+ el.setAttribute('aria-keyshortcuts', v);
41
+ return () => el.removeAttribute('aria-keyshortcuts');
44
42
  });
45
43
  </script>
46
44
 
@@ -51,16 +49,9 @@
51
49
  {@render children()}
52
50
  </span>
53
51
  {#if open}
54
- <div class="tip" role="tooltip" {id} bind:this={tip} style:left="{x}px" style:top="{y}px" data-ready={ready}>{text}</div>
52
+ <TooltipTip anchor={host!} {id} {text} {content} {shortcut} {placement} />
55
53
  {/if}
56
54
 
57
55
  <style>
58
56
  .host { display: inline-flex; }
59
- .tip {
60
- position: fixed; z-index: 200; max-width: 260px; padding: 4px 8px; pointer-events: none; visibility: hidden;
61
- background: var(--tr-on-surface); color: var(--tr-surface); border-radius: var(--tr-radius-sm);
62
- font: 500 var(--tr-text-sm) / 1.35 var(--tr-font-sans); box-shadow: var(--tr-elevation-2);
63
- }
64
- .tip[data-ready='true'] { visibility: visible; animation: fade var(--tr-dur-fast) var(--tr-ease); }
65
- @keyframes fade { from { opacity: 0; } }
66
57
  </style>
@@ -1,6 +1,11 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  type $$ComponentProps = {
3
- text: string;
3
+ /** Plain-text hint. Optional when `content` or `shortcut` is given. */
4
+ text?: string;
5
+ /** Rich (non-interactive) content shown in the tip; the tip is `pointer-events: none`, so put no links or buttons in it. */
6
+ content?: Snippet;
7
+ /** A key combination (`mod+k`, `g h`) shown as keys inside the tip, so it is part of the accessible description. `mod` is ⌘ on Apple platforms and Ctrl elsewhere. */
8
+ shortcut?: string;
4
9
  placement?: 'top' | 'bottom' | 'left' | 'right';
5
10
  /** ms before showing on hover (focus shows immediately). */
6
11
  delay?: number;