@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.
- package/README.md +6 -1
- package/dist/commands.svelte.d.ts +41 -0
- package/dist/commands.svelte.js +59 -0
- package/dist/components/Accordion.svelte +1 -1
- package/dist/components/Button.svelte +65 -8
- package/dist/components/Button.svelte.d.ts +2 -0
- package/dist/components/Calendar.svelte +2 -2
- package/dist/components/Card.svelte +1 -0
- package/dist/components/Card.svelte.d.ts +1 -0
- package/dist/components/Checkbox.svelte +1 -1
- package/dist/components/CommandPalette.svelte +207 -0
- package/dist/components/CommandPalette.svelte.d.ts +39 -0
- package/dist/components/DataTable.svelte +201 -27
- package/dist/components/DataTable.svelte.d.ts +50 -4
- package/dist/components/Dialog.svelte +2 -1
- package/dist/components/Drawer.svelte +1 -1
- package/dist/components/EditableText.svelte +2 -2
- package/dist/components/Eyebrow.svelte +1 -1
- package/dist/components/FileUpload.svelte +1 -1
- package/dist/components/Footer.svelte +29 -2
- package/dist/components/Footer.svelte.d.ts +8 -0
- package/dist/components/GridLayout.svelte +242 -0
- package/dist/components/GridLayout.svelte.d.ts +30 -0
- package/dist/components/HoverCard.svelte +158 -0
- package/dist/components/HoverCard.svelte.d.ts +46 -0
- package/dist/components/Kbd.svelte +36 -0
- package/dist/components/Kbd.svelte.d.ts +8 -0
- package/dist/components/Link.svelte +1 -1
- package/dist/components/List.svelte +113 -0
- package/dist/components/List.svelte.d.ts +17 -0
- package/dist/components/ListItem.svelte +13 -0
- package/dist/components/ListItem.svelte.d.ts +9 -0
- package/dist/components/Menu.svelte +4 -3
- package/dist/components/MultiSelect.svelte +2 -2
- package/dist/components/Navbar.svelte +13 -3
- package/dist/components/Navbar.svelte.d.ts +5 -1
- package/dist/components/Panel.svelte +2 -2
- package/dist/components/Popover.svelte +22 -1
- package/dist/components/Popover.svelte.d.ts +2 -0
- package/dist/components/RadioGroup.svelte +1 -1
- package/dist/components/Select.svelte +1 -1
- package/dist/components/Shell.svelte +12 -1
- package/dist/components/Shell.svelte.d.ts +2 -0
- package/dist/components/ShortcutSheet.svelte +62 -0
- package/dist/components/ShortcutSheet.svelte.d.ts +9 -0
- package/dist/components/Sidebar.svelte +2 -2
- package/dist/components/Skeleton.svelte +80 -6
- package/dist/components/Skeleton.svelte.d.ts +8 -1
- package/dist/components/Slider.svelte +1 -1
- package/dist/components/Switch.svelte +1 -1
- package/dist/components/Tabs.svelte +252 -32
- package/dist/components/Tabs.svelte.d.ts +21 -9
- package/dist/components/Tag.svelte +7 -7
- package/dist/components/TagInput.svelte +1 -1
- package/dist/components/Text.svelte +1 -1
- package/dist/components/Toaster.svelte +33 -2
- package/dist/components/ToggleChip.svelte +1 -1
- package/dist/components/Toolbar.svelte +27 -0
- package/dist/components/Toolbar.svelte.d.ts +10 -0
- package/dist/components/ToolbarSeparator.svelte +15 -0
- package/dist/components/ToolbarSeparator.svelte.d.ts +3 -0
- package/dist/components/Tooltip.svelte +19 -28
- package/dist/components/Tooltip.svelte.d.ts +6 -1
- package/dist/components/TooltipTip.svelte +59 -0
- package/dist/components/TooltipTip.svelte.d.ts +12 -0
- package/dist/components/Tree.svelte +343 -44
- package/dist/components/Tree.svelte.d.ts +29 -2
- package/dist/grid-layout.d.ts +42 -0
- package/dist/grid-layout.js +100 -0
- package/dist/hotkeys.svelte.d.ts +45 -0
- package/dist/hotkeys.svelte.js +108 -0
- package/dist/index.d.ts +21 -2
- package/dist/index.js +13 -0
- package/dist/keys.d.ts +25 -0
- package/dist/keys.js +88 -0
- package/dist/roving.d.ts +25 -0
- package/dist/roving.js +129 -0
- package/dist/styles/base.css +2 -0
- package/dist/styles/tokens.css +49 -0
- package/dist/table-export.d.ts +24 -0
- package/dist/table-export.js +130 -0
- package/dist/toast.svelte.d.ts +25 -1
- package/dist/toast.svelte.js +42 -4
- package/dist/types.d.ts +6 -0
- package/package.json +7 -3
|
@@ -1,29 +1,54 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type
|
|
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
|
-
|
|
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
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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']
|
|
72
|
-
[data-variant='pill']
|
|
73
|
-
[data-variant='pill']
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
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:
|
|
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(--
|
|
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); }
|
|
@@ -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
|
|
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:
|
|
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>
|
|
@@ -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
|
|
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
|
|
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;
|
|
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
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
<
|
|
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
|
|
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;
|