@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
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ A **Svelte 5 / SvelteKit design system** that blends Material 3's tonal surfaces
|
|
|
7
7
|
|
|
8
8
|
**Docs and live demo: <https://truss.ruck.io>**
|
|
9
9
|
|
|
10
|
-
- **
|
|
10
|
+
- **90+ components**: forms and pickers (no native `<select>`, date or time inputs), data tables with virtual scrolling, trees, menus, dialogs, an application shell and more.
|
|
11
11
|
- **Zero runtime dependencies** in the core (peer: `svelte`). Fonts are bundled and served by *your* build.
|
|
12
12
|
- **Accessible by construction**: names, ARIA, focus and complete keyboard maps, audited for every combination of every component's options.
|
|
13
13
|
- **Themeable**: one set of `--tr-*` CSS tokens, light and dark, drives everything, including charts, graphs and maps.
|
|
@@ -71,8 +71,11 @@ npm run check # svelte-check
|
|
|
71
71
|
npm run package:all # build all four packages into their dist/ folders
|
|
72
72
|
npm run build # regenerate the API docs and prerender the site (adapter-static)
|
|
73
73
|
npm test # the full browser suite (starts its own dev server)
|
|
74
|
+
npm run test:coverage # the suite with V8 coverage; the report lands in coverage/report/index.html
|
|
74
75
|
```
|
|
75
76
|
|
|
77
|
+
Coverage is collected from the browser (V8, no instrumented build) and mapped back to the `.svelte`/`.ts` sources; `coverage/report/` also holds `lcov.info` and `coverage-summary.json`. To measure a subset, run `COVERAGE=1 npx playwright test tests/misc.spec.ts` and then `npm run coverage:report`. In CI the browser suite is split into 6 parallel shards; a final job merges their test reports and coverage into one HTML report and a summary on the run page (report only, no thresholds).
|
|
78
|
+
|
|
76
79
|
Repository layout:
|
|
77
80
|
|
|
78
81
|
```
|
|
@@ -99,6 +102,8 @@ Every push to `main` that passes CI is released automatically: the version is wo
|
|
|
99
102
|
|
|
100
103
|
Commits that don't follow the format (no `type:` prefix) don't trigger a release on their own. Publishing uses npm [trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC from the `ci.yml` workflow), so no npm token is stored anywhere. Each package's trusted publisher on npmjs.com must be for workflow `ci.yml` with the **`npm publish`** action allowed (the default is only `npm stage publish`, which npm rejects with `403 OIDC permission denied for this action`).
|
|
101
104
|
|
|
105
|
+
A new version can take several minutes to become installable after the release job publishes it, so an install that returns 404 right after a release should be retried rather than republished.
|
|
106
|
+
|
|
102
107
|
## Licence
|
|
103
108
|
|
|
104
109
|
MIT © Curtis Ruck. The bundled Inter and JetBrains Mono fonts are under the SIL Open Font License 1.1.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { IconSource } from './types.js';
|
|
2
|
+
export type Command = {
|
|
3
|
+
/** Unique id. Registering the same id again replaces the earlier command. */
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
/** Heading the command is listed under in the palette and the shortcut sheet. */
|
|
7
|
+
group?: string;
|
|
8
|
+
icon?: IconSource;
|
|
9
|
+
/** Extra words the palette's search matches. */
|
|
10
|
+
keywords?: string[];
|
|
11
|
+
/** Bound globally while registered: `mod+shift+n`, `g h`... */
|
|
12
|
+
shortcut?: string;
|
|
13
|
+
/** Return false to hide the command from the palette and pause its shortcut. */
|
|
14
|
+
when?: () => boolean;
|
|
15
|
+
/** Let the shortcut fire while the user is typing in a field. */
|
|
16
|
+
allowInInput?: boolean;
|
|
17
|
+
run: () => void | Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Registry behind `<CommandPalette>` and `<ShortcutSheet>`. Registration is reactive, so palettes update as screens
|
|
21
|
+
* register and unregister their own commands.
|
|
22
|
+
*/
|
|
23
|
+
declare class CommandStore {
|
|
24
|
+
#private;
|
|
25
|
+
/** Every registered command. */
|
|
26
|
+
get all(): Command[];
|
|
27
|
+
/** The commands whose `when` currently allows them. */
|
|
28
|
+
get available(): Command[];
|
|
29
|
+
register(cmd: Command): () => void;
|
|
30
|
+
/** Run a command by id (regardless of `when`). Returns false if there is none. */
|
|
31
|
+
run(id: string): Promise<boolean>;
|
|
32
|
+
}
|
|
33
|
+
export declare const commands: CommandStore;
|
|
34
|
+
/** Register a command; returns the function that unregisters it (call it from `onDestroy` / an effect cleanup). */
|
|
35
|
+
export declare const registerCommand: (cmd: Command) => (() => void);
|
|
36
|
+
/**
|
|
37
|
+
* Fuzzy subsequence score of `query` in `text`: -1 when the letters do not appear in order, otherwise higher is better
|
|
38
|
+
* (consecutive letters and word starts score more).
|
|
39
|
+
*/
|
|
40
|
+
export declare function fuzzyScore(query: string, text: string): number;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { untrack } from 'svelte';
|
|
2
|
+
import { hotkeys } from './hotkeys.svelte.js';
|
|
3
|
+
/**
|
|
4
|
+
* Registry behind `<CommandPalette>` and `<ShortcutSheet>`. Registration is reactive, so palettes update as screens
|
|
5
|
+
* register and unregister their own commands.
|
|
6
|
+
*/
|
|
7
|
+
class CommandStore {
|
|
8
|
+
#items = $state.raw([]);
|
|
9
|
+
#unbind = new Map();
|
|
10
|
+
/** Every registered command. */
|
|
11
|
+
get all() { return this.#items; }
|
|
12
|
+
/** The commands whose `when` currently allows them. */
|
|
13
|
+
get available() { return this.#items.filter((c) => !c.when || c.when()); }
|
|
14
|
+
register(cmd) {
|
|
15
|
+
return untrack(() => {
|
|
16
|
+
this.#remove(cmd.id);
|
|
17
|
+
this.#items = [...this.#items, cmd];
|
|
18
|
+
if (cmd.shortcut)
|
|
19
|
+
this.#unbind.set(cmd.id, hotkeys.register(cmd.shortcut, () => void cmd.run(), { description: cmd.label, group: cmd.group, when: cmd.when, allowInInput: cmd.allowInInput }));
|
|
20
|
+
return () => untrack(() => { if (this.#items.includes(cmd))
|
|
21
|
+
this.#remove(cmd.id); });
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
#remove(id) {
|
|
25
|
+
this.#unbind.get(id)?.();
|
|
26
|
+
this.#unbind.delete(id);
|
|
27
|
+
this.#items = this.#items.filter((c) => c.id !== id);
|
|
28
|
+
}
|
|
29
|
+
/** Run a command by id (regardless of `when`). Returns false if there is none. */
|
|
30
|
+
async run(id) {
|
|
31
|
+
const c = this.#items.find((x) => x.id === id);
|
|
32
|
+
if (!c)
|
|
33
|
+
return false;
|
|
34
|
+
await c.run();
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export const commands = new CommandStore();
|
|
39
|
+
/** Register a command; returns the function that unregisters it (call it from `onDestroy` / an effect cleanup). */
|
|
40
|
+
export const registerCommand = (cmd) => commands.register(cmd);
|
|
41
|
+
/**
|
|
42
|
+
* Fuzzy subsequence score of `query` in `text`: -1 when the letters do not appear in order, otherwise higher is better
|
|
43
|
+
* (consecutive letters and word starts score more).
|
|
44
|
+
*/
|
|
45
|
+
export function fuzzyScore(query, text) {
|
|
46
|
+
const q = query.toLowerCase().replace(/\s+/g, ''), t = text.toLowerCase();
|
|
47
|
+
if (!q)
|
|
48
|
+
return 0;
|
|
49
|
+
let ti = 0, score = 0, run = 0;
|
|
50
|
+
for (const ch of q) {
|
|
51
|
+
const at = t.indexOf(ch, ti);
|
|
52
|
+
if (at < 0)
|
|
53
|
+
return -1;
|
|
54
|
+
run = at === ti && ti > 0 ? run + 1 : 0;
|
|
55
|
+
score += 1 + run * 2 + (at === 0 || /[\s\-_/.]/.test(t[at - 1]) ? 3 : 0);
|
|
56
|
+
ti = at + 1;
|
|
57
|
+
}
|
|
58
|
+
return score - t.length * 0.01 + (t.startsWith(q) ? 5 : t.includes(q) ? 3 : 0);
|
|
59
|
+
}
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
.acc { border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); overflow: hidden; background: var(--tr-surface-container-low); }
|
|
58
58
|
.item + .item { border-top: 1px solid var(--tr-outline-variant); }
|
|
59
59
|
h3 { margin: 0; font-size: inherit; }
|
|
60
|
-
.hd { display: flex; align-items: center; gap: 8px; width: 100%; height:
|
|
60
|
+
.hd { display: flex; align-items: center; gap: 8px; width: 100%; height: calc(var(--tr-control-h) + 4px); padding: 0 var(--tr-space-3); border: 0; background: var(--tr-surface-container); color: var(--tr-on-surface); font: 600 var(--tr-text-md) var(--tr-font-sans); text-align: left; cursor: pointer; }
|
|
61
61
|
.hd:hover:not(:disabled) { background: var(--tr-surface-container-high); }
|
|
62
62
|
.hd:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
63
63
|
.hd:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--tr-primary); }
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
|
4
|
+
import { mount, unmount } from 'svelte';
|
|
4
5
|
import Icon from '../icons/Icon.svelte';
|
|
6
|
+
import TooltipTip from './TooltipTip.svelte';
|
|
7
|
+
import { ariaKeyShortcuts } from '../keys.js';
|
|
5
8
|
import type { Intent, IconSource } from '../types.js';
|
|
6
9
|
|
|
7
10
|
type Common = {
|
|
@@ -12,6 +15,8 @@
|
|
|
12
15
|
icon?: IconSource;
|
|
13
16
|
rightIcon?: IconSource;
|
|
14
17
|
loading?: boolean;
|
|
18
|
+
/** A key combination (`mod+s`) shown in a hover/focus tooltip beside the label, never inline, and exposed as `aria-keyshortcuts`. Display only: bind the key with `registerCommand` or `hotkeys`. */
|
|
19
|
+
shortcut?: string;
|
|
15
20
|
children?: Snippet;
|
|
16
21
|
};
|
|
17
22
|
/**
|
|
@@ -30,6 +35,7 @@
|
|
|
30
35
|
icon,
|
|
31
36
|
rightIcon,
|
|
32
37
|
loading = false,
|
|
38
|
+
shortcut,
|
|
33
39
|
href,
|
|
34
40
|
type = 'button',
|
|
35
41
|
disabled,
|
|
@@ -38,6 +44,54 @@
|
|
|
38
44
|
...rest
|
|
39
45
|
}: Props = $props();
|
|
40
46
|
|
|
47
|
+
const uid = $props.id();
|
|
48
|
+
let el = $state<HTMLElement>();
|
|
49
|
+
/**
|
|
50
|
+
* The shortcut tooltip is mounted into <body> (or the enclosing <dialog>, which sits in the top layer) instead of wrapping the
|
|
51
|
+
* button, so `.tr-btn` stays a direct child of ButtonGroup and InputGroup.
|
|
52
|
+
*/
|
|
53
|
+
$effect(() => {
|
|
54
|
+
const node = el;
|
|
55
|
+
if (!shortcut || !node) return;
|
|
56
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
57
|
+
let tip: ReturnType<typeof mount> | undefined;
|
|
58
|
+
let before: string | null = null;
|
|
59
|
+
const show = (now: boolean) => {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
timer = setTimeout(() => {
|
|
62
|
+
if (tip) return;
|
|
63
|
+
const text = node.getAttribute('aria-label') ?? node.textContent?.trim() ?? '';
|
|
64
|
+
before = node.getAttribute('aria-describedby');
|
|
65
|
+
node.setAttribute('aria-describedby', before ? `${before} ${uid}-tip` : `${uid}-tip`);
|
|
66
|
+
tip = mount(TooltipTip, { target: node.closest('dialog') ?? document.body, props: { anchor: node, id: `${uid}-tip`, text, shortcut } });
|
|
67
|
+
}, now ? 0 : 350);
|
|
68
|
+
};
|
|
69
|
+
const hide = () => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
if (!tip) return;
|
|
72
|
+
unmount(tip);
|
|
73
|
+
tip = undefined;
|
|
74
|
+
if (before) node.setAttribute('aria-describedby', before); else node.removeAttribute('aria-describedby');
|
|
75
|
+
};
|
|
76
|
+
const esc = (e: KeyboardEvent) => e.key === 'Escape' && hide();
|
|
77
|
+
const enter = () => show(false), focus = () => show(true);
|
|
78
|
+
node.addEventListener('pointerenter', enter);
|
|
79
|
+
node.addEventListener('pointerleave', hide);
|
|
80
|
+
node.addEventListener('pointerdown', hide);
|
|
81
|
+
node.addEventListener('focusin', focus);
|
|
82
|
+
node.addEventListener('focusout', hide);
|
|
83
|
+
addEventListener('keydown', esc);
|
|
84
|
+
return () => {
|
|
85
|
+
hide();
|
|
86
|
+
node.removeEventListener('pointerenter', enter);
|
|
87
|
+
node.removeEventListener('pointerleave', hide);
|
|
88
|
+
node.removeEventListener('pointerdown', hide);
|
|
89
|
+
node.removeEventListener('focusin', focus);
|
|
90
|
+
node.removeEventListener('focusout', hide);
|
|
91
|
+
removeEventListener('keydown', esc);
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
|
|
41
95
|
const iconPx = $derived(size === 'lg' ? 20 : 16) as 16 | 20;
|
|
42
96
|
const asLink = $derived(!!href && !disabled && !loading);
|
|
43
97
|
/** Attributes that only make sense on an anchor are dropped when a disabled/loading link becomes a button. */
|
|
@@ -55,11 +109,12 @@
|
|
|
55
109
|
{/snippet}
|
|
56
110
|
|
|
57
111
|
{#if asLink}
|
|
58
|
-
<a class="tr-btn {klass}" data-variant={variant} data-intent={intent} data-size={size} data-icon-only={!children} {...anchorRest} {href} {rel}>
|
|
112
|
+
<a bind:this={el} class="tr-btn {klass}" data-variant={variant} data-intent={intent} data-size={size} data-icon-only={!children} aria-keyshortcuts={shortcut ? ariaKeyShortcuts(shortcut) : undefined} {...anchorRest} {href} {rel}>
|
|
59
113
|
{@render content()}
|
|
60
114
|
</a>
|
|
61
115
|
{:else}
|
|
62
116
|
<button
|
|
117
|
+
bind:this={el}
|
|
63
118
|
class="tr-btn {klass}"
|
|
64
119
|
data-variant={variant}
|
|
65
120
|
data-intent={intent}
|
|
@@ -68,6 +123,7 @@
|
|
|
68
123
|
{type}
|
|
69
124
|
disabled={disabled || loading}
|
|
70
125
|
aria-busy={loading || undefined}
|
|
126
|
+
aria-keyshortcuts={shortcut ? ariaKeyShortcuts(shortcut) : undefined}
|
|
71
127
|
{...buttonRest}
|
|
72
128
|
>
|
|
73
129
|
{@render content()}
|
|
@@ -77,6 +133,7 @@
|
|
|
77
133
|
<style>
|
|
78
134
|
.tr-btn {
|
|
79
135
|
--c: var(--tr-primary);
|
|
136
|
+
--ct: var(--tr-primary-text, var(--tr-primary));
|
|
80
137
|
--on: var(--tr-on-primary);
|
|
81
138
|
--cc: var(--tr-primary-container);
|
|
82
139
|
--on-cc: var(--tr-on-primary-container);
|
|
@@ -104,11 +161,11 @@
|
|
|
104
161
|
.tr-btn[data-size='lg'] { height: var(--tr-control-h-lg); min-width: var(--tr-control-h-lg); padding: 0 16px; font-size: var(--tr-text-lg); }
|
|
105
162
|
.tr-btn[data-icon-only='true'] { padding: 0; }
|
|
106
163
|
|
|
107
|
-
.tr-btn[data-intent='neutral'] { --c: var(--tr-on-surface); --on: var(--tr-surface); --cc: var(--tr-surface-container-high); --on-cc: var(--tr-on-surface); }
|
|
108
|
-
.tr-btn[data-intent='secondary'] { --c: var(--tr-secondary); --on: var(--tr-on-secondary); --cc: var(--tr-secondary-container); --on-cc: var(--tr-on-secondary-container); }
|
|
109
|
-
.tr-btn[data-intent='success'] { --c: var(--tr-success); --on: var(--tr-on-success); --cc: var(--tr-success-container); --on-cc: var(--tr-on-success-container); }
|
|
110
|
-
.tr-btn[data-intent='warning'] { --c: var(--tr-warning); --on: var(--tr-on-warning); --cc: var(--tr-warning-container); --on-cc: var(--tr-on-warning-container); }
|
|
111
|
-
.tr-btn[data-intent='danger'] { --c: var(--tr-danger); --on: var(--tr-on-danger); --cc: var(--tr-danger-container); --on-cc: var(--tr-on-danger-container); }
|
|
164
|
+
.tr-btn[data-intent='neutral'] { --c: var(--tr-on-surface); --ct: var(--tr-on-surface); --on: var(--tr-surface); --cc: var(--tr-surface-container-high); --on-cc: var(--tr-on-surface); }
|
|
165
|
+
.tr-btn[data-intent='secondary'] { --c: var(--tr-secondary); --ct: var(--tr-secondary-text, var(--tr-secondary)); --on: var(--tr-on-secondary); --cc: var(--tr-secondary-container); --on-cc: var(--tr-on-secondary-container); }
|
|
166
|
+
.tr-btn[data-intent='success'] { --c: var(--tr-success); --ct: var(--tr-success); --on: var(--tr-on-success); --cc: var(--tr-success-container); --on-cc: var(--tr-on-success-container); }
|
|
167
|
+
.tr-btn[data-intent='warning'] { --c: var(--tr-warning); --ct: var(--tr-warning); --on: var(--tr-on-warning); --cc: var(--tr-warning-container); --on-cc: var(--tr-on-warning-container); }
|
|
168
|
+
.tr-btn[data-intent='danger'] { --c: var(--tr-danger); --ct: var(--tr-danger); --on: var(--tr-on-danger); --cc: var(--tr-danger-container); --on-cc: var(--tr-on-danger-container); }
|
|
112
169
|
|
|
113
170
|
/* Primary filled: solid, high contrast */
|
|
114
171
|
.tr-btn[data-variant='filled'] { background: var(--c); color: var(--on); box-shadow: var(--tr-elevation-1); }
|
|
@@ -117,9 +174,9 @@
|
|
|
117
174
|
.tr-btn[data-variant='tonal'] { background: var(--cc); color: var(--on-cc); }
|
|
118
175
|
.tr-btn[data-variant='tonal']:hover:not(:disabled) { background: color-mix(in srgb, var(--on-cc) 10%, var(--cc)); }
|
|
119
176
|
/* Outlined: precise 1px border */
|
|
120
|
-
.tr-btn[data-variant='outlined'] { border-color: var(--tr-outline); color: var(--
|
|
177
|
+
.tr-btn[data-variant='outlined'] { border-color: var(--tr-outline); color: var(--ct); background: transparent; }
|
|
121
178
|
.tr-btn[data-variant='outlined']:hover:not(:disabled) { background: color-mix(in srgb, var(--c) 8%, transparent); }
|
|
122
|
-
.tr-btn[data-variant='minimal'] { color: var(--
|
|
179
|
+
.tr-btn[data-variant='minimal'] { color: var(--ct); background: transparent; }
|
|
123
180
|
.tr-btn[data-variant='minimal']:hover:not(:disabled) { background: color-mix(in srgb, var(--c) 10%, transparent); }
|
|
124
181
|
|
|
125
182
|
.tr-btn:active:not(:disabled) { transform: scale(0.98); }
|
|
@@ -9,6 +9,8 @@ type Common = {
|
|
|
9
9
|
icon?: IconSource;
|
|
10
10
|
rightIcon?: IconSource;
|
|
11
11
|
loading?: boolean;
|
|
12
|
+
/** A key combination (`mod+s`) shown in a hover/focus tooltip beside the label, never inline, and exposed as `aria-keyshortcuts`. Display only: bind the key with `registerCommand` or `hotkeys`. */
|
|
13
|
+
shortcut?: string;
|
|
12
14
|
children?: Snippet;
|
|
13
15
|
};
|
|
14
16
|
/**
|
|
@@ -149,10 +149,10 @@
|
|
|
149
149
|
.title { flex: 1; text-align: center; font-weight: 600; }
|
|
150
150
|
.grid { outline: none; }
|
|
151
151
|
.row { display: grid; grid-template-columns: repeat(7, 1fr); }
|
|
152
|
-
.dow { height:
|
|
152
|
+
.dow { height: var(--tr-control-h-sm); display: grid; place-items: center; font-size: var(--tr-text-xs); font-weight: 700; text-transform: uppercase; color: var(--tr-on-surface-variant); }
|
|
153
153
|
[role='gridcell'] { display: contents; }
|
|
154
154
|
.day {
|
|
155
|
-
height:
|
|
155
|
+
height: var(--tr-control-h); border: 0; background: none; color: var(--tr-on-surface); font: inherit; font-variant-numeric: tabular-nums;
|
|
156
156
|
border-radius: var(--tr-radius-sm); cursor: pointer; position: relative;
|
|
157
157
|
}
|
|
158
158
|
.day:hover:not(:disabled) { background: var(--tr-surface-container-highest); }
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { HTMLAnchorAttributes, HTMLAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
|
4
4
|
|
|
5
5
|
type Base = {
|
|
6
|
+
/** Padding of the body only (the `children` between header and footer): `md` (default) is 16px, `sm` is 8px 12px, `none` removes it so the content can supply its own spacing or a media-only card has none. The header, footer and actions keep their own padding. */
|
|
6
7
|
padding?: 'none' | 'sm' | 'md';
|
|
7
8
|
elevation?: 0 | 1 | 2 | 3;
|
|
8
9
|
/** Edge-to-edge content above the header and body (an image, video or map); clipped by the card's rounded corners. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
import type { HTMLAnchorAttributes, HTMLAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
|
3
3
|
type Base = {
|
|
4
|
+
/** Padding of the body only (the `children` between header and footer): `md` (default) is 16px, `sm` is 8px 12px, `none` removes it so the content can supply its own spacing or a media-only card has none. The header, footer and actions keep their own padding. */
|
|
4
5
|
padding?: 'none' | 'sm' | 'md';
|
|
5
6
|
elevation?: 0 | 1 | 2 | 3;
|
|
6
7
|
/** Edge-to-edge content above the header and body (an image, video or map); clipped by the card's rounded corners. */
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
</label>
|
|
26
26
|
|
|
27
27
|
<style>
|
|
28
|
-
.cb { display: inline-flex; align-items: flex-start; gap: 8px; min-height:
|
|
28
|
+
.cb { display: inline-flex; align-items: flex-start; gap: 8px; min-height: var(--tr-control-h-sm); cursor: pointer; }
|
|
29
29
|
.cb[data-disabled='true'] { opacity: 0.5; cursor: not-allowed; }
|
|
30
30
|
.box { flex: none; display: grid; place-items: center; width: 18px; height: 18px; margin-top: 3px; border: 1px solid var(--tr-outline); border-radius: var(--tr-radius-sm); background: var(--tr-surface-container-low); color: var(--tr-on-primary); transition: background-color var(--tr-dur) var(--tr-ease), box-shadow var(--tr-dur) var(--tr-ease); }
|
|
31
31
|
.box :global(svg) { width: 14px; height: 14px; }
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { IconSource } from '../types.js';
|
|
3
|
+
|
|
4
|
+
/** A result that is not a registered command, typically returned by a provider (search hits, recent documents...). */
|
|
5
|
+
export type PaletteItem = {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
group?: string;
|
|
9
|
+
icon?: IconSource;
|
|
10
|
+
keywords?: string[];
|
|
11
|
+
/** Shown beside the label (display only). */
|
|
12
|
+
shortcut?: string;
|
|
13
|
+
run: () => void | Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** An async source of extra results, queried as the user types. */
|
|
17
|
+
export type PaletteProvider = {
|
|
18
|
+
id: string;
|
|
19
|
+
/** Group heading for items that do not set their own. */
|
|
20
|
+
label: string;
|
|
21
|
+
/** Fewer typed characters than this skip the provider (default 1; use 0 to also query with an empty box). */
|
|
22
|
+
minLength?: number;
|
|
23
|
+
search: (query: string, signal: AbortSignal) => Promise<PaletteItem[]>;
|
|
24
|
+
};
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<script lang="ts">
|
|
28
|
+
import { tick } from 'svelte';
|
|
29
|
+
import Dialog from './Dialog.svelte';
|
|
30
|
+
import Icon from '../icons/Icon.svelte';
|
|
31
|
+
import Kbd from './Kbd.svelte';
|
|
32
|
+
import { commands, fuzzyScore } from '../commands.svelte.js';
|
|
33
|
+
import { hotkeys } from '../hotkeys.svelte.js';
|
|
34
|
+
|
|
35
|
+
let {
|
|
36
|
+
open = $bindable(false),
|
|
37
|
+
shortcut = 'mod+k',
|
|
38
|
+
providers = [],
|
|
39
|
+
placeholder = 'Type a command or search…',
|
|
40
|
+
title = 'Command palette',
|
|
41
|
+
recents = 5,
|
|
42
|
+
storageKey = 'truss-command-recents',
|
|
43
|
+
debounce = 150
|
|
44
|
+
}: {
|
|
45
|
+
open?: boolean;
|
|
46
|
+
/** Key combination that toggles the palette; `null` disables the built-in binding. */
|
|
47
|
+
shortcut?: string | null;
|
|
48
|
+
/** Extra async result sources shown beneath the registered commands. */
|
|
49
|
+
providers?: PaletteProvider[];
|
|
50
|
+
placeholder?: string;
|
|
51
|
+
/** Accessible name of the dialog. */
|
|
52
|
+
title?: string;
|
|
53
|
+
/** How many recently run commands to list first when the box is empty (0 turns recents off). */
|
|
54
|
+
recents?: number;
|
|
55
|
+
/** localStorage key that remembers recent commands. */
|
|
56
|
+
storageKey?: string;
|
|
57
|
+
debounce?: number;
|
|
58
|
+
} = $props();
|
|
59
|
+
|
|
60
|
+
type Row = { key: string; id: string; label: string; group: string; icon?: IconSource; shortcut?: string; run: () => void | Promise<void>; command: boolean; i: number };
|
|
61
|
+
|
|
62
|
+
const uid = $props.id();
|
|
63
|
+
let query = $state('');
|
|
64
|
+
let active = $state(0);
|
|
65
|
+
let remote = $state.raw<Omit<Row, 'i'>[]>([]);
|
|
66
|
+
let searching = $state(false);
|
|
67
|
+
let input = $state<HTMLInputElement>();
|
|
68
|
+
let list = $state<HTMLDivElement>();
|
|
69
|
+
let recent = $state.raw<string[]>([]);
|
|
70
|
+
|
|
71
|
+
// ───── recents (localStorage may be unavailable or throw)
|
|
72
|
+
const loadRecent = (): string[] => {
|
|
73
|
+
try { const v = JSON.parse(localStorage.getItem(storageKey) ?? '[]'); return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []; } catch { return []; }
|
|
74
|
+
};
|
|
75
|
+
const remember = (id: string) => {
|
|
76
|
+
recent = [id, ...recent.filter((x) => x !== id)].slice(0, Math.max(recents, 0));
|
|
77
|
+
try { localStorage.setItem(storageKey, JSON.stringify(recent)); } catch { /* storage unavailable */ }
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// ───── bindings: toggle on the shortcut, and own the keyboard while open
|
|
81
|
+
$effect(() => {
|
|
82
|
+
if (!shortcut) return;
|
|
83
|
+
const off = [
|
|
84
|
+
hotkeys.register(shortcut, () => (open = !open), { allowInInput: true, description: 'Open command palette', group: 'General' }),
|
|
85
|
+
hotkeys.register(shortcut, () => (open = false), { scope: 'palette', allowInInput: true })
|
|
86
|
+
];
|
|
87
|
+
return () => off.forEach((f) => f());
|
|
88
|
+
});
|
|
89
|
+
$effect(() => { if (open) return hotkeys.pushScope('palette'); });
|
|
90
|
+
$effect(() => {
|
|
91
|
+
if (!open) return;
|
|
92
|
+
query = ''; active = 0; remote = [];
|
|
93
|
+
recent = loadRecent();
|
|
94
|
+
tick().then(() => input?.focus());
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ───── results
|
|
98
|
+
const toRow = (c: { id: string; label: string; group?: string; icon?: IconSource; shortcut?: string; run: () => void | Promise<void> }, command: boolean, fallback: string): Omit<Row, 'i'> =>
|
|
99
|
+
({ key: `${command ? 'c' : 'r'}:${c.id}`, id: c.id, label: c.label, group: c.group ?? fallback, icon: c.icon, shortcut: c.shortcut, run: c.run, command });
|
|
100
|
+
|
|
101
|
+
const rows = $derived.by<Row[]>(() => {
|
|
102
|
+
if (!open) return [];
|
|
103
|
+
const q = query.trim();
|
|
104
|
+
const cmds = commands.available;
|
|
105
|
+
let out: Omit<Row, 'i'>[];
|
|
106
|
+
if (!q) {
|
|
107
|
+
const byId = new Map(cmds.map((c) => [c.id, c]));
|
|
108
|
+
const rec = recent.map((id) => byId.get(id)).filter((c): c is NonNullable<typeof c> => !!c).slice(0, recents);
|
|
109
|
+
const seen = new Set(rec.map((c) => c.id));
|
|
110
|
+
out = [...rec.map((c) => ({ ...toRow(c, true, 'Commands'), group: 'Recent', key: `recent:${c.id}` })), ...cmds.filter((c) => !seen.has(c.id)).map((c) => toRow(c, true, 'Commands'))];
|
|
111
|
+
} else {
|
|
112
|
+
out = cmds
|
|
113
|
+
.map((c) => ({ c, s: Math.max(fuzzyScore(q, c.label) * 2, fuzzyScore(q, (c.keywords ?? []).join(' ')), fuzzyScore(q, c.group ?? '') * 0.5) }))
|
|
114
|
+
.filter((x) => x.s >= 0)
|
|
115
|
+
.sort((a, b) => b.s - a.s)
|
|
116
|
+
.map((x) => toRow(x.c, true, 'Commands'));
|
|
117
|
+
}
|
|
118
|
+
out = [...out, ...remote];
|
|
119
|
+
// keep groups together, in order of first appearance
|
|
120
|
+
const order = [...new Set(out.map((r) => r.group))];
|
|
121
|
+
return order.flatMap((g) => out.filter((r) => r.group === g)).map((r, i) => ({ ...r, i }));
|
|
122
|
+
});
|
|
123
|
+
const sections = $derived.by(() => {
|
|
124
|
+
const m = new Map<string, Row[]>();
|
|
125
|
+
for (const r of rows) (m.get(r.group) ?? m.set(r.group, []).get(r.group)!).push(r);
|
|
126
|
+
return [...m].map(([name, items]) => ({ name, items }));
|
|
127
|
+
});
|
|
128
|
+
$effect(() => { if (active >= rows.length) active = 0; });
|
|
129
|
+
$effect(() => { void active; list?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }); });
|
|
130
|
+
|
|
131
|
+
const status = $derived(searching ? 'Searching…' : rows.length ? `${rows.length} result${rows.length === 1 ? '' : 's'}` : 'No results');
|
|
132
|
+
|
|
133
|
+
// ───── async providers
|
|
134
|
+
$effect(() => {
|
|
135
|
+
if (!open) return;
|
|
136
|
+
const q = query.trim();
|
|
137
|
+
const live = providers.filter((p) => q.length >= (p.minLength ?? 1));
|
|
138
|
+
if (!live.length) { remote = []; searching = false; return; }
|
|
139
|
+
searching = true;
|
|
140
|
+
const ctrl = new AbortController();
|
|
141
|
+
const timer = setTimeout(async () => {
|
|
142
|
+
const results = await Promise.allSettled(live.map((p) => p.search(q, ctrl.signal)));
|
|
143
|
+
if (ctrl.signal.aborted) return;
|
|
144
|
+
remote = results.flatMap((r, n) => (r.status === 'fulfilled' ? r.value.map((it) => toRow(it, false, live[n].label)) : []));
|
|
145
|
+
searching = false;
|
|
146
|
+
}, debounce);
|
|
147
|
+
return () => { clearTimeout(timer); ctrl.abort(); };
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
async function choose(r: Row | undefined) {
|
|
151
|
+
if (!r) return;
|
|
152
|
+
if (r.command) remember(r.id);
|
|
153
|
+
open = false;
|
|
154
|
+
await tick(); // let the dialog close (and give focus back) before the action runs
|
|
155
|
+
await r.run();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function onKey(e: KeyboardEvent) {
|
|
159
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); active = rows.length ? (active + 1) % rows.length : 0; }
|
|
160
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); active = rows.length ? (active - 1 + rows.length) % rows.length : 0; }
|
|
161
|
+
else if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); choose(rows[active]); }
|
|
162
|
+
}
|
|
163
|
+
</script>
|
|
164
|
+
|
|
165
|
+
<Dialog bind:open {title} size="lg">
|
|
166
|
+
{#if open}
|
|
167
|
+
<div class="pal">
|
|
168
|
+
<input
|
|
169
|
+
bind:this={input} bind:value={query} oninput={() => (active = 0)} onkeydown={onKey}
|
|
170
|
+
class="q" type="text" role="combobox" aria-label={title} aria-expanded="true" aria-controls="{uid}-list" aria-autocomplete="list"
|
|
171
|
+
aria-activedescendant={rows[active] ? `${uid}-o${active}` : undefined} {placeholder} autocomplete="off" spellcheck="false"
|
|
172
|
+
/>
|
|
173
|
+
<div class="list" id="{uid}-list" role="listbox" aria-label="Results" bind:this={list}>
|
|
174
|
+
{#each sections as sec, si (sec.name)}
|
|
175
|
+
<div role="group" aria-labelledby="{uid}-g{si}">
|
|
176
|
+
<div class="gh" id="{uid}-g{si}">{sec.name}</div>
|
|
177
|
+
{#each sec.items as r (r.key)}
|
|
178
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_interactive_supports_focus -->
|
|
179
|
+
<div id="{uid}-o{r.i}" role="option" class="opt" aria-selected={r.i === active} onclick={() => choose(r)} onpointermove={() => { if (active !== r.i) active = r.i; }}>
|
|
180
|
+
<span class="lead">{#if r.icon}<Icon name={r.icon} />{/if}</span>
|
|
181
|
+
<span class="lbl">{r.label}</span>
|
|
182
|
+
{#if r.shortcut}<Kbd keys={r.shortcut} />{/if}
|
|
183
|
+
</div>
|
|
184
|
+
{/each}
|
|
185
|
+
</div>
|
|
186
|
+
{/each}
|
|
187
|
+
{#if !rows.length && !searching}<div class="empty">No matching commands</div>{/if}
|
|
188
|
+
</div>
|
|
189
|
+
<div class="tr-visually-hidden" role="status" aria-live="polite">{status}</div>
|
|
190
|
+
</div>
|
|
191
|
+
{/if}
|
|
192
|
+
</Dialog>
|
|
193
|
+
|
|
194
|
+
<style>
|
|
195
|
+
.pal { display: flex; flex-direction: column; gap: 8px; margin: -16px; }
|
|
196
|
+
.q { height: 44px; padding: 0 16px; border: 0; border-bottom: 1px solid var(--tr-outline-variant); background: transparent; color: var(--tr-on-surface); font: var(--tr-text-lg) var(--tr-font-sans); outline: none; }
|
|
197
|
+
.q::placeholder { color: var(--tr-on-surface-variant); }
|
|
198
|
+
.q:focus-visible { box-shadow: inset 0 -2px 0 var(--tr-primary); }
|
|
199
|
+
.list { max-height: min(50vh, 420px); overflow: auto; padding: 0 8px 8px; }
|
|
200
|
+
.gh { padding: 8px 8px 2px; font-size: var(--tr-text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--tr-on-surface-variant); }
|
|
201
|
+
.opt { display: flex; align-items: center; gap: 8px; height: var(--tr-control-h); padding: 0 8px; border-radius: var(--tr-radius-sm); cursor: pointer; color: var(--tr-on-surface); }
|
|
202
|
+
.opt[aria-selected='true'] { background: var(--tr-surface-container-highest); box-shadow: inset 2px 0 0 var(--tr-primary); }
|
|
203
|
+
.lead { display: inline-flex; width: 16px; flex: none; }
|
|
204
|
+
.lbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
205
|
+
.opt :global(.tr-kbd) { color: var(--tr-on-surface-variant); }
|
|
206
|
+
.empty { padding: 16px 8px; text-align: center; color: var(--tr-on-surface-variant); }
|
|
207
|
+
</style>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { IconSource } from '../types.js';
|
|
2
|
+
/** A result that is not a registered command, typically returned by a provider (search hits, recent documents...). */
|
|
3
|
+
export type PaletteItem = {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
group?: string;
|
|
7
|
+
icon?: IconSource;
|
|
8
|
+
keywords?: string[];
|
|
9
|
+
/** Shown beside the label (display only). */
|
|
10
|
+
shortcut?: string;
|
|
11
|
+
run: () => void | Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
/** An async source of extra results, queried as the user types. */
|
|
14
|
+
export type PaletteProvider = {
|
|
15
|
+
id: string;
|
|
16
|
+
/** Group heading for items that do not set their own. */
|
|
17
|
+
label: string;
|
|
18
|
+
/** Fewer typed characters than this skip the provider (default 1; use 0 to also query with an empty box). */
|
|
19
|
+
minLength?: number;
|
|
20
|
+
search: (query: string, signal: AbortSignal) => Promise<PaletteItem[]>;
|
|
21
|
+
};
|
|
22
|
+
type $$ComponentProps = {
|
|
23
|
+
open?: boolean;
|
|
24
|
+
/** Key combination that toggles the palette; `null` disables the built-in binding. */
|
|
25
|
+
shortcut?: string | null;
|
|
26
|
+
/** Extra async result sources shown beneath the registered commands. */
|
|
27
|
+
providers?: PaletteProvider[];
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
/** Accessible name of the dialog. */
|
|
30
|
+
title?: string;
|
|
31
|
+
/** How many recently run commands to list first when the box is empty (0 turns recents off). */
|
|
32
|
+
recents?: number;
|
|
33
|
+
/** localStorage key that remembers recent commands. */
|
|
34
|
+
storageKey?: string;
|
|
35
|
+
debounce?: number;
|
|
36
|
+
};
|
|
37
|
+
declare const CommandPalette: import("svelte").Component<$$ComponentProps, {}, "open">;
|
|
38
|
+
type CommandPalette = ReturnType<typeof CommandPalette>;
|
|
39
|
+
export default CommandPalette;
|