@dorsk/tsumikit 0.80.3 → 0.82.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 +180 -2
- package/dist/breakpoints.d.ts +15 -0
- package/dist/breakpoints.js +14 -0
- package/dist/components/atoms/Artwork.svelte +37 -10
- package/dist/components/atoms/Badge.svelte +2 -1
- package/dist/components/atoms/Button.svelte +17 -6
- package/dist/components/atoms/Button.svelte.d.ts +2 -0
- package/dist/components/atoms/Input.svelte +2 -1
- package/dist/components/atoms/Slider.svelte +1 -1
- package/dist/components/atoms/Text.svelte +4 -4
- package/dist/components/atoms/Text.svelte.d.ts +1 -1
- package/dist/components/layouts/AppShell.svelte +14 -0
- package/dist/components/layouts/Backdrop.svelte +152 -0
- package/dist/components/layouts/Backdrop.svelte.d.ts +30 -0
- package/dist/components/layouts/NavSection.svelte +2 -2
- package/dist/components/molecules/AttachmentList.svelte +1 -1
- package/dist/components/molecules/CapBar.svelte +1 -1
- package/dist/components/molecules/Composer.svelte +1 -1
- package/dist/components/molecules/Dropzone.svelte +1 -1
- package/dist/components/molecules/EmptyState.svelte +13 -6
- package/dist/components/molecules/EmptyState.svelte.d.ts +3 -0
- package/dist/components/molecules/Field.svelte +1 -1
- package/dist/components/molecules/FilterInput.svelte +8 -6
- package/dist/components/molecules/Meter.svelte +1 -1
- package/dist/components/molecules/Metric.svelte +6 -4
- package/dist/components/molecules/Modal.svelte +4 -4
- package/dist/components/molecules/Pagination.svelte +26 -2
- package/dist/components/molecules/Pagination.svelte.d.ts +3 -1
- package/dist/components/molecules/Prose.svelte +1 -0
- package/dist/components/molecules/RadioGroup.svelte +2 -2
- package/dist/components/molecules/RichSelect.svelte.d.ts +2 -2
- package/dist/components/molecules/SectionHeader.svelte +1 -1
- package/dist/components/molecules/SegmentedControl.svelte +59 -26
- package/dist/components/molecules/SegmentedControl.svelte.d.ts +4 -0
- package/dist/components/molecules/SortableList.svelte +1 -1
- package/dist/components/molecules/StatusBar.svelte +2 -2
- package/dist/components/molecules/Tabs.svelte +32 -0
- package/dist/components/molecules/Tabs.svelte.d.ts +4 -0
- package/dist/components/molecules/ThemePicker.svelte +3 -3
- package/dist/components/molecules/VolumeSlider.svelte.d.ts +1 -1
- package/dist/components/organisms/DataTable.svelte +2 -2
- package/dist/dev-audit.d.ts +19 -0
- package/dist/dev-audit.js +259 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/dist/media.svelte.d.ts +8 -0
- package/dist/media.svelte.js +26 -0
- package/dist/pending.svelte.d.ts +16 -0
- package/dist/pending.svelte.js +75 -0
- package/dist/stores/toast.svelte.d.ts +22 -0
- package/dist/stores/toast.svelte.js +54 -2
- package/dist/styles/app.css +2 -1
- package/dist/styles/font.css +19 -0
- package/dist/styles/fonts/LICENSE-Inter.txt +92 -0
- package/dist/styles/fonts/inter.woff2 +0 -0
- package/dist/styles/themes.css +58 -58
- package/dist/styles/tokens.css +17 -5
- package/dist/url-state.svelte.d.ts +22 -0
- package/dist/url-state.svelte.js +64 -0
- package/lint/breakpoints.mjs +33 -0
- package/lint/cli.mjs +99 -0
- package/lint/context.mjs +242 -0
- package/lint/index.mjs +97 -0
- package/lint/rules/breakpoints.mjs +25 -0
- package/lint/rules/img-alt.mjs +13 -0
- package/lint/rules/motion.mjs +85 -0
- package/lint/rules/no-raw-color.mjs +59 -0
- package/lint/rules/no-raw-control.mjs +23 -0
- package/lint/rules/no-raw-font.mjs +64 -0
- package/lint/rules/one-primary.mjs +74 -0
- package/lint/rules/spacing-scale.mjs +66 -0
- package/package.json +12 -2
- package/principles/01-visibility-of-status.md +32 -0
- package/principles/02-one-primary-action.md +31 -0
- package/principles/03-emphasise-by-de-emphasising.md +31 -0
- package/principles/04-labels-last-resort.md +30 -0
- package/principles/05-tint-the-greys.md +30 -0
- package/principles/06-depth-from-shade.md +30 -0
- package/principles/07-one-accent.md +31 -0
- package/principles/08-spacing-scale.md +30 -0
- package/principles/09-group-with-space.md +31 -0
- package/principles/10-real-typeface-fixed-scale.md +33 -0
- package/principles/11-mono-only-for-code.md +30 -0
- package/principles/12-measure.md +29 -0
- package/principles/13-imagery-sets-the-mood.md +32 -0
- package/principles/14-instant-calm-feedback.md +32 -0
- package/principles/15-undo-over-confirm.md +30 -0
- package/principles/16-empty-states.md +31 -0
- package/principles/17-errors-in-plain-words.md +31 -0
- package/principles/18-match-the-real-world.md +31 -0
- package/principles/19-consistency-and-standards.md +32 -0
- package/principles/20-error-prevention.md +31 -0
- package/principles/21-recognition-over-recall.md +31 -0
- package/principles/22-flexibility-and-shortcuts.md +31 -0
- package/principles/23-url-is-state.md +31 -0
- package/principles/24-links-are-links.md +31 -0
- package/principles/25-visible-focus.md +32 -0
- package/principles/26-generous-targets.md +31 -0
- package/principles/27-cheap-motion-respect-preferences.md +32 -0
- package/principles/28-less-but-better.md +31 -0
- package/principles/29-colour-is-not-the-only-cue.md +31 -0
- package/principles/30-help-where-needed.md +31 -0
- package/principles/README.md +62 -0
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
color: var(--text-muted);
|
|
66
66
|
}
|
|
67
67
|
.status-bar.idle {
|
|
68
|
-
color: var(--text-
|
|
68
|
+
color: var(--text-muted);
|
|
69
69
|
}
|
|
70
70
|
.status-bar :global(.status-glyph) {
|
|
71
71
|
flex: none;
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
display: inline-flex;
|
|
86
86
|
align-items: center;
|
|
87
87
|
gap: var(--sp-2);
|
|
88
|
-
color: var(--text-
|
|
88
|
+
color: var(--text-muted);
|
|
89
89
|
font-variant-numeric: tabular-nums;
|
|
90
90
|
white-space: nowrap;
|
|
91
91
|
}
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
title?: string;
|
|
12
12
|
/** Per-tab override of the strip's `closable` (pin one tab open). */
|
|
13
13
|
closable?: boolean;
|
|
14
|
+
/** Render this tab as a real link; overrides the strip's `href`. */
|
|
15
|
+
href?: string;
|
|
14
16
|
/**
|
|
15
17
|
* Extra attributes for this tab's button — `data-*`, `title`…
|
|
16
18
|
* The tab's own semantics (`role`, `aria-*`, `id`, `class`, `disabled`,
|
|
@@ -37,11 +39,13 @@
|
|
|
37
39
|
import Icon from '../atoms/Icon.svelte';
|
|
38
40
|
import IconButton from './IconButton.svelte';
|
|
39
41
|
import { playSound } from '../../sound';
|
|
42
|
+
import { isModifiedClick } from '../../url-state.svelte';
|
|
40
43
|
|
|
41
44
|
let {
|
|
42
45
|
tabs,
|
|
43
46
|
value = $bindable(),
|
|
44
47
|
label = 'Tabs',
|
|
48
|
+
href,
|
|
45
49
|
panel,
|
|
46
50
|
closable = false,
|
|
47
51
|
onclose,
|
|
@@ -60,6 +64,8 @@
|
|
|
60
64
|
tabs: TabItem[];
|
|
61
65
|
value?: string;
|
|
62
66
|
label?: string;
|
|
67
|
+
/** Tabs become real links (`<a aria-current>`): middle-click opens the tab's URL in a new tab. */
|
|
68
|
+
href?: (id: string) => string;
|
|
63
69
|
/** Content for the active tab. Omit it for a strip-only tablist. */
|
|
64
70
|
panel?: Snippet<[string]>;
|
|
65
71
|
/** Every tab gets a close control; Delete and middle-click close too. */
|
|
@@ -101,6 +107,10 @@
|
|
|
101
107
|
function canClose(t: TabItem): boolean {
|
|
102
108
|
return t.closable ?? closable;
|
|
103
109
|
}
|
|
110
|
+
function linkTo(t: TabItem): string | undefined {
|
|
111
|
+
if (t.disabled || canClose(t)) return undefined;
|
|
112
|
+
return t.href ?? href?.(t.id);
|
|
113
|
+
}
|
|
104
114
|
|
|
105
115
|
// One indicator slides under the selected tab on translate/scale (never
|
|
106
116
|
// width/left). It snaps on mount and on reflow, and only animates on a
|
|
@@ -288,6 +298,27 @@
|
|
|
288
298
|
}}
|
|
289
299
|
/>
|
|
290
300
|
</div>
|
|
301
|
+
{:else if linkTo(t) !== undefined}
|
|
302
|
+
<a
|
|
303
|
+
{...t.attrs as HTMLAttributes<HTMLAnchorElement>}
|
|
304
|
+
href={linkTo(t)}
|
|
305
|
+
role="tab"
|
|
306
|
+
id="{baseId}-tab-{t.id}"
|
|
307
|
+
title={t.title ?? t.attrs?.title}
|
|
308
|
+
aria-selected={value === t.id}
|
|
309
|
+
aria-current={value === t.id ? 'page' : undefined}
|
|
310
|
+
aria-controls={panelId}
|
|
311
|
+
tabindex={stop?.id === t.id ? 0 : -1}
|
|
312
|
+
class="tab"
|
|
313
|
+
class:selected={value === t.id}
|
|
314
|
+
onclick={(e) => {
|
|
315
|
+
if (isModifiedClick(e)) return;
|
|
316
|
+
inkFrom(e);
|
|
317
|
+
select(t.id);
|
|
318
|
+
}}
|
|
319
|
+
>
|
|
320
|
+
{@render body(t)}
|
|
321
|
+
</a>
|
|
291
322
|
{:else}
|
|
292
323
|
<button
|
|
293
324
|
type="button"
|
|
@@ -362,6 +393,7 @@
|
|
|
362
393
|
border: none;
|
|
363
394
|
background: none;
|
|
364
395
|
color: var(--text-muted);
|
|
396
|
+
text-decoration: none;
|
|
365
397
|
font-size: var(--control-fs-md);
|
|
366
398
|
font-weight: var(--fw-medium);
|
|
367
399
|
--icon-size: var(--control-icon-md);
|
|
@@ -10,6 +10,8 @@ export interface TabItem {
|
|
|
10
10
|
title?: string;
|
|
11
11
|
/** Per-tab override of the strip's `closable` (pin one tab open). */
|
|
12
12
|
closable?: boolean;
|
|
13
|
+
/** Render this tab as a real link; overrides the strip's `href`. */
|
|
14
|
+
href?: string;
|
|
13
15
|
/**
|
|
14
16
|
* Extra attributes for this tab's button — `data-*`, `title`…
|
|
15
17
|
* The tab's own semantics (`role`, `aria-*`, `id`, `class`, `disabled`,
|
|
@@ -23,6 +25,8 @@ type Own = {
|
|
|
23
25
|
tabs: TabItem[];
|
|
24
26
|
value?: string;
|
|
25
27
|
label?: string;
|
|
28
|
+
/** Tabs become real links (`<a aria-current>`): middle-click opens the tab's URL in a new tab. */
|
|
29
|
+
href?: (id: string) => string;
|
|
26
30
|
/** Content for the active tab. Omit it for a strip-only tablist. */
|
|
27
31
|
panel?: Snippet<[string]>;
|
|
28
32
|
/** Every tab gets a close control; Delete and middle-click close too. */
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
--c-bg: #0f1115;
|
|
182
182
|
--c-surface: #21262f;
|
|
183
183
|
--c-text: #e6e9ef;
|
|
184
|
-
--c-accent: #
|
|
184
|
+
--c-accent: #4cc3f0;
|
|
185
185
|
}
|
|
186
186
|
.q {
|
|
187
187
|
display: block;
|
|
@@ -212,7 +212,7 @@
|
|
|
212
212
|
font-size: var(--fs-xs);
|
|
213
213
|
text-transform: uppercase;
|
|
214
214
|
letter-spacing: 0.06em;
|
|
215
|
-
color: var(--text-
|
|
215
|
+
color: var(--text-muted);
|
|
216
216
|
}
|
|
217
217
|
.grid {
|
|
218
218
|
display: grid;
|
|
@@ -297,7 +297,7 @@
|
|
|
297
297
|
}
|
|
298
298
|
.auto-help {
|
|
299
299
|
font-size: var(--fs-xs);
|
|
300
|
-
color: var(--text-
|
|
300
|
+
color: var(--text-muted);
|
|
301
301
|
white-space: normal;
|
|
302
302
|
}
|
|
303
303
|
.auto-pair {
|
|
@@ -27,6 +27,6 @@ type Own = {
|
|
|
27
27
|
style?: string;
|
|
28
28
|
};
|
|
29
29
|
type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own;
|
|
30
|
-
declare const VolumeSlider: import("svelte").Component<$$ComponentProps, {}, "
|
|
30
|
+
declare const VolumeSlider: import("svelte").Component<$$ComponentProps, {}, "value" | "muted">;
|
|
31
31
|
type VolumeSlider = ReturnType<typeof VolumeSlider>;
|
|
32
32
|
export default VolumeSlider;
|
|
@@ -631,7 +631,7 @@
|
|
|
631
631
|
[data-stacked] td[data-role='meta']::before {
|
|
632
632
|
content: attr(data-label) ' ';
|
|
633
633
|
font-family: var(--font-sans);
|
|
634
|
-
color: var(--text-
|
|
634
|
+
color: var(--text-muted);
|
|
635
635
|
}
|
|
636
636
|
[data-stacked] td[data-role='detail'][data-stack-label='false'] {
|
|
637
637
|
flex: 0 1 auto;
|
|
@@ -675,7 +675,7 @@
|
|
|
675
675
|
}
|
|
676
676
|
.dt-empty {
|
|
677
677
|
text-align: center;
|
|
678
|
-
color: var(--text-
|
|
678
|
+
color: var(--text-muted);
|
|
679
679
|
padding: var(--sp-8);
|
|
680
680
|
}
|
|
681
681
|
.dt-more {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type DesignIssueKind = 'multiple-primary' | 'unnamed-control' | 'small-target' | 'text-over-image';
|
|
2
|
+
export type DesignIssue = {
|
|
3
|
+
kind: DesignIssueKind;
|
|
4
|
+
el: Element;
|
|
5
|
+
message: string;
|
|
6
|
+
};
|
|
7
|
+
export type DesignAuditOptions = {
|
|
8
|
+
root?: ParentNode;
|
|
9
|
+
/** Debounce window after a DOM mutation, in ms. */
|
|
10
|
+
delay?: number;
|
|
11
|
+
/** Minimum hit box, in px; defaults to the `--touch-target` token (44px). */
|
|
12
|
+
touchTarget?: number;
|
|
13
|
+
/** Treat the pointer as coarse regardless of the media query. */
|
|
14
|
+
coarse?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export declare function auditDesign(options?: DesignAuditOptions): DesignIssue[];
|
|
17
|
+
export declare function reportDesignIssues(issues: DesignIssue[]): DesignIssue[];
|
|
18
|
+
/** Idempotent: a second call while running returns the same stop function. */
|
|
19
|
+
export declare function startDesignAudit(options?: DesignAuditOptions): () => void;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
const PRIMARY = '[data-tsu="Button"].btn-primary';
|
|
2
|
+
const CONTROL = '[data-tsu="Button"], [data-tsu="IconButton"]';
|
|
3
|
+
const LANDMARK = 'dialog, [role="dialog"], [role="alertdialog"], main, [role="main"]';
|
|
4
|
+
const INTERACTIVE = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"], [role="tab"], [role="menuitem"], [role="option"], [role="slider"]';
|
|
5
|
+
const SCRIM = '[data-tsu="Scrim"], .scrim, [data-scrim]';
|
|
6
|
+
function view(el) {
|
|
7
|
+
return el.ownerDocument.defaultView ?? window;
|
|
8
|
+
}
|
|
9
|
+
function isVisible(el) {
|
|
10
|
+
if (el.closest('[hidden], [inert]'))
|
|
11
|
+
return false;
|
|
12
|
+
const win = view(el);
|
|
13
|
+
for (let node = el; node; node = node.parentElement) {
|
|
14
|
+
const style = win.getComputedStyle(node);
|
|
15
|
+
if (style.display === 'none' || style.visibility === 'hidden')
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
function isDisabled(el) {
|
|
21
|
+
return el.matches(':disabled, [aria-disabled="true"]');
|
|
22
|
+
}
|
|
23
|
+
function ownText(el) {
|
|
24
|
+
let out = '';
|
|
25
|
+
for (const node of el.childNodes) {
|
|
26
|
+
if (node.nodeType === 3)
|
|
27
|
+
out += node.textContent ?? '';
|
|
28
|
+
else if (node.nodeType === 1 &&
|
|
29
|
+
!node.matches('[aria-hidden="true"], script, style'))
|
|
30
|
+
out += ownText(node);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
function accessibleName(el) {
|
|
35
|
+
const label = el.getAttribute('aria-label')?.trim();
|
|
36
|
+
if (label)
|
|
37
|
+
return label;
|
|
38
|
+
const by = el.getAttribute('aria-labelledby');
|
|
39
|
+
if (by) {
|
|
40
|
+
const text = by
|
|
41
|
+
.split(/\s+/)
|
|
42
|
+
.map((id) => el.ownerDocument.getElementById(id)?.textContent?.trim() ?? '')
|
|
43
|
+
.join(' ')
|
|
44
|
+
.trim();
|
|
45
|
+
if (text)
|
|
46
|
+
return text;
|
|
47
|
+
}
|
|
48
|
+
const text = ownText(el).trim();
|
|
49
|
+
if (text)
|
|
50
|
+
return text;
|
|
51
|
+
for (const child of el.querySelectorAll('img[alt], svg title, [aria-label]')) {
|
|
52
|
+
const alt = child.getAttribute('alt') ?? child.getAttribute('aria-label') ?? child.textContent;
|
|
53
|
+
if (alt?.trim())
|
|
54
|
+
return alt.trim();
|
|
55
|
+
}
|
|
56
|
+
return el.getAttribute('title')?.trim() ?? '';
|
|
57
|
+
}
|
|
58
|
+
function describe(el) {
|
|
59
|
+
const tsu = el.getAttribute('data-tsu');
|
|
60
|
+
return tsu ? `<${tsu}>` : `<${el.tagName.toLowerCase()}>`;
|
|
61
|
+
}
|
|
62
|
+
function landmarkOf(el) {
|
|
63
|
+
return el.closest(LANDMARK) ?? el.ownerDocument.body;
|
|
64
|
+
}
|
|
65
|
+
function landmarkName(el) {
|
|
66
|
+
return el === el.ownerDocument.body
|
|
67
|
+
? 'page'
|
|
68
|
+
: el.matches('main, [role="main"]')
|
|
69
|
+
? 'main'
|
|
70
|
+
: 'dialog';
|
|
71
|
+
}
|
|
72
|
+
function readToken(root, name, fallback) {
|
|
73
|
+
const doc = root.ownerDocument ?? root;
|
|
74
|
+
const raw = doc.defaultView?.getComputedStyle(doc.documentElement).getPropertyValue(name);
|
|
75
|
+
const n = Number.parseFloat(raw ?? '');
|
|
76
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
77
|
+
}
|
|
78
|
+
/* Kit controls are sized to --touch-target-compact by design (see tokens.css). */
|
|
79
|
+
function isKitControl(el) {
|
|
80
|
+
return el.matches('[data-tsu], .btn');
|
|
81
|
+
}
|
|
82
|
+
/** Kit controls stretch their hit box with an absolutely positioned ::before/::after slab. */
|
|
83
|
+
function hasHitSlab(el) {
|
|
84
|
+
const win = view(el);
|
|
85
|
+
for (const pseudo of ['::before', '::after']) {
|
|
86
|
+
const s = win.getComputedStyle(el, pseudo);
|
|
87
|
+
if (s.position === 'absolute' && s.content && s.content !== 'none' && s.content !== 'normal')
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
function hitBox(el) {
|
|
93
|
+
const own = el.getBoundingClientRect();
|
|
94
|
+
const label = el.closest('label');
|
|
95
|
+
if (!label || label === el)
|
|
96
|
+
return own;
|
|
97
|
+
const lr = label.getBoundingClientRect();
|
|
98
|
+
return lr.width * lr.height > own.width * own.height ? lr : own;
|
|
99
|
+
}
|
|
100
|
+
function hasOpaqueBackground(el) {
|
|
101
|
+
const s = view(el).getComputedStyle(el);
|
|
102
|
+
if (s.backgroundImage && s.backgroundImage !== 'none')
|
|
103
|
+
return true;
|
|
104
|
+
const bg = s.backgroundColor;
|
|
105
|
+
return (!!bg && bg !== 'transparent' && !/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)$/.test(bg));
|
|
106
|
+
}
|
|
107
|
+
function hasOwnText(el) {
|
|
108
|
+
for (const node of el.childNodes)
|
|
109
|
+
if (node.nodeType === 3 && node.textContent?.trim())
|
|
110
|
+
return true;
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
function imageUnder(text) {
|
|
114
|
+
const artwork = text.closest('[data-tsu="Artwork"]');
|
|
115
|
+
if (artwork)
|
|
116
|
+
return text.matches('[data-tsu="Artwork"] > .initials') ? null : artwork;
|
|
117
|
+
const win = view(text);
|
|
118
|
+
let positioned = false;
|
|
119
|
+
for (let node = text; node; node = node.parentElement) {
|
|
120
|
+
const pos = win.getComputedStyle(node).position;
|
|
121
|
+
if (pos === 'absolute' || pos === 'fixed')
|
|
122
|
+
positioned = true;
|
|
123
|
+
if (!positioned)
|
|
124
|
+
continue;
|
|
125
|
+
if (pos !== 'static' && node !== text) {
|
|
126
|
+
const img = node.querySelector('img, picture, video, [data-tsu="Artwork"]');
|
|
127
|
+
if (img && !img.contains(text))
|
|
128
|
+
return node;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
function scrimmed(text, container) {
|
|
134
|
+
if (container.querySelector(SCRIM))
|
|
135
|
+
return true;
|
|
136
|
+
for (let node = text; node && node !== container; node = node.parentElement)
|
|
137
|
+
if (hasOpaqueBackground(node))
|
|
138
|
+
return true;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
function coarsePointer(root) {
|
|
142
|
+
const win = (root.ownerDocument ?? root).defaultView;
|
|
143
|
+
return !!win?.matchMedia?.('(pointer: coarse)').matches;
|
|
144
|
+
}
|
|
145
|
+
export function auditDesign(options = {}) {
|
|
146
|
+
if (typeof document === 'undefined' && !options.root)
|
|
147
|
+
return [];
|
|
148
|
+
const root = options.root ?? document;
|
|
149
|
+
const issues = [];
|
|
150
|
+
const byLandmark = new Map();
|
|
151
|
+
for (const btn of root.querySelectorAll(PRIMARY)) {
|
|
152
|
+
if (!isVisible(btn))
|
|
153
|
+
continue;
|
|
154
|
+
const lm = landmarkOf(btn);
|
|
155
|
+
const list = byLandmark.get(lm) ?? [];
|
|
156
|
+
list.push(btn);
|
|
157
|
+
byLandmark.set(lm, list);
|
|
158
|
+
}
|
|
159
|
+
for (const [lm, list] of byLandmark) {
|
|
160
|
+
if (list.length < 2)
|
|
161
|
+
continue;
|
|
162
|
+
for (const el of list.slice(1))
|
|
163
|
+
issues.push({
|
|
164
|
+
kind: 'multiple-primary',
|
|
165
|
+
el,
|
|
166
|
+
message: `${list.length} primary Buttons visible in the same ${landmarkName(lm)}; keep one primary per view`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
for (const el of root.querySelectorAll(CONTROL)) {
|
|
170
|
+
if (accessibleName(el))
|
|
171
|
+
continue;
|
|
172
|
+
issues.push({ kind: 'unnamed-control', el, message: `${describe(el)} has no accessible name` });
|
|
173
|
+
}
|
|
174
|
+
const coarse = options.coarse ?? coarsePointer(root);
|
|
175
|
+
if (coarse) {
|
|
176
|
+
const target = options.touchTarget ?? readToken(root, '--touch-target', 44);
|
|
177
|
+
const compact = Math.min(target, readToken(root, '--touch-target-compact', 40));
|
|
178
|
+
for (const el of root.querySelectorAll(INTERACTIVE)) {
|
|
179
|
+
if (el.matches('.hit-compact, [tabindex="-1"]') || isDisabled(el) || !isVisible(el))
|
|
180
|
+
continue;
|
|
181
|
+
const box = hitBox(el);
|
|
182
|
+
if (box.width === 0 || box.height === 0)
|
|
183
|
+
continue;
|
|
184
|
+
const min = isKitControl(el) ? compact : target;
|
|
185
|
+
if (box.width >= min && box.height >= min)
|
|
186
|
+
continue;
|
|
187
|
+
if (hasHitSlab(el))
|
|
188
|
+
continue;
|
|
189
|
+
issues.push({
|
|
190
|
+
kind: 'small-target',
|
|
191
|
+
el,
|
|
192
|
+
message: `${describe(el)} hit box is ${Math.round(box.width)}x${Math.round(box.height)}px, under the ${min}px touch target`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const el of root.querySelectorAll('*')) {
|
|
197
|
+
if (!hasOwnText(el) || el.closest('[aria-hidden="true"]'))
|
|
198
|
+
continue;
|
|
199
|
+
const container = imageUnder(el);
|
|
200
|
+
if (!container || scrimmed(el, container))
|
|
201
|
+
continue;
|
|
202
|
+
issues.push({
|
|
203
|
+
kind: 'text-over-image',
|
|
204
|
+
el,
|
|
205
|
+
message: `${describe(el)} text sits on an image without a Scrim`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return issues;
|
|
209
|
+
}
|
|
210
|
+
const reported = new WeakMap();
|
|
211
|
+
export function reportDesignIssues(issues) {
|
|
212
|
+
const fresh = [];
|
|
213
|
+
for (const issue of issues) {
|
|
214
|
+
const seen = reported.get(issue.el) ?? new Set();
|
|
215
|
+
if (seen.has(issue.kind))
|
|
216
|
+
continue;
|
|
217
|
+
seen.add(issue.kind);
|
|
218
|
+
reported.set(issue.el, seen);
|
|
219
|
+
fresh.push(issue);
|
|
220
|
+
console.warn(`[tsumikit] ${issue.message}`, issue.el);
|
|
221
|
+
}
|
|
222
|
+
return fresh;
|
|
223
|
+
}
|
|
224
|
+
let active;
|
|
225
|
+
/** Idempotent: a second call while running returns the same stop function. */
|
|
226
|
+
export function startDesignAudit(options = {}) {
|
|
227
|
+
if (!import.meta.env.DEV || typeof document === 'undefined')
|
|
228
|
+
return () => { };
|
|
229
|
+
if (active)
|
|
230
|
+
return active;
|
|
231
|
+
const root = options.root ?? document;
|
|
232
|
+
const win = (root.ownerDocument ?? root).defaultView ?? window;
|
|
233
|
+
const delay = options.delay ?? 250;
|
|
234
|
+
let timer;
|
|
235
|
+
const run = () => {
|
|
236
|
+
timer = undefined;
|
|
237
|
+
reportDesignIssues(auditDesign(options));
|
|
238
|
+
};
|
|
239
|
+
const schedule = () => {
|
|
240
|
+
if (timer !== undefined)
|
|
241
|
+
win.clearTimeout(timer);
|
|
242
|
+
timer = win.setTimeout(run, delay);
|
|
243
|
+
};
|
|
244
|
+
const observer = new win.MutationObserver(schedule);
|
|
245
|
+
observer.observe(root === document ? document.documentElement : root, {
|
|
246
|
+
subtree: true,
|
|
247
|
+
childList: true,
|
|
248
|
+
attributes: true,
|
|
249
|
+
characterData: true,
|
|
250
|
+
});
|
|
251
|
+
schedule();
|
|
252
|
+
active = () => {
|
|
253
|
+
observer.disconnect();
|
|
254
|
+
if (timer !== undefined)
|
|
255
|
+
win.clearTimeout(timer);
|
|
256
|
+
active = undefined;
|
|
257
|
+
};
|
|
258
|
+
return active;
|
|
259
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export type { AnchorAttributes } from './anchor';
|
|
|
2
2
|
export { artworkGradient, artworkHue, initials } from './artwork';
|
|
3
3
|
export { autoresize } from './autoresize';
|
|
4
4
|
export { avatarHue, avatarInitial } from './avatar';
|
|
5
|
+
export { type Breakpoint, breakpoints, breakpointsPx, maxWidth, minWidth } from './breakpoints';
|
|
5
6
|
export { copyToClipboard } from './clipboard';
|
|
6
7
|
export { default as Artwork } from './components/atoms/Artwork.svelte';
|
|
7
8
|
export { default as Avatar } from './components/atoms/Avatar.svelte';
|
|
@@ -35,6 +36,7 @@ export { default as Thinking } from './components/atoms/Thinking.svelte';
|
|
|
35
36
|
export { default as TimeDisplay } from './components/atoms/TimeDisplay.svelte';
|
|
36
37
|
export { default as AppShell } from './components/layouts/AppShell.svelte';
|
|
37
38
|
export { default as AutoGrid } from './components/layouts/AutoGrid.svelte';
|
|
39
|
+
export { default as Backdrop } from './components/layouts/Backdrop.svelte';
|
|
38
40
|
export { default as Cluster } from './components/layouts/Cluster.svelte';
|
|
39
41
|
export { default as Container } from './components/layouts/Container.svelte';
|
|
40
42
|
export { default as MasterDetail } from './components/layouts/MasterDetail.svelte';
|
|
@@ -116,7 +118,9 @@ export { EMOJI_GROUPS, type EmojiEntry, type EmojiGroup, searchEmoji } from './e
|
|
|
116
118
|
export { FIELD_KEY, type FieldContext, getFieldContext, setFieldContext, warnUnlabelled, } from './field-context';
|
|
117
119
|
export { DEFAULT_FX, FX, FX_FAMILIES, type FxFamily, type FxSettings, type FxVariant, fxAttr, fxDiff, fxPrepaint, isFxVariant, parseFx, } from './fx';
|
|
118
120
|
export { escapeHtml, type Highlight, type RenderMarkdownOptions, renderMarkdown, stripAnsi, } from './markdown';
|
|
121
|
+
export { type Media, media, mq } from './media.svelte';
|
|
119
122
|
export { describeMediaTime, formatMediaTime, isLiveDuration } from './media-time';
|
|
123
|
+
export { PENDING_DELAY_MS, PENDING_MIN_MS, type Pending, type PendingOptions, pending, } from './pending.svelte';
|
|
120
124
|
export { trackPointer } from './pointer';
|
|
121
125
|
export * as filterQuery from './query';
|
|
122
126
|
export { type AndNode, activeToken, compilePredicate, defaultOperator, type ExprNode, type FieldDef, type FieldType, type FilterNode, filters, findField, freeText, type LeafNode, type NotNode, OPERATORS, type Operator, type OperatorId, type OrNode, operatorByCode, operatorById, operatorsFor, parse, type Query, type QueryNode, resolveValues, type Schema, type Suggestion, type SuggestKind, type SuggestState, serialize, serializeFilter, singleQuery, suggest, suggestSingle, type TextNode, toSql, type ValueContext, type ValueOption, type ValueProvider, walk, } from './query';
|
|
@@ -127,9 +131,10 @@ export { fontScale, SCALE_LEVELS, type ScaleLevel } from './stores/fontscale.sve
|
|
|
127
131
|
export { FX_PREPAINT, fx } from './stores/fx.svelte';
|
|
128
132
|
export { sound } from './stores/sound.svelte';
|
|
129
133
|
export { type Mode, THEME_PREPAINT, THEMES, theme } from './stores/theme.svelte';
|
|
130
|
-
export { type Toast, type ToastAction, type ToastOptions, type ToastTone, type ToastToneInput, toasts, } from './stores/toast.svelte';
|
|
134
|
+
export { type Toast, type ToastAction, type ToastOptions, type ToastTone, type ToastToneInput, toasts, UNDO_FAIL_MESSAGE, type UndoOptions, } from './stores/toast.svelte';
|
|
131
135
|
export { AUTO_THEME, chooseTheme, DEFAULT_LOOK, DEFAULT_THEME_PREFERENCE, LOOKS, type LookId, type LookStatus, parseLook, pickerValue, preferenceFrom, resolveTheme, type SlotOf, type ThemeChoice, type ThemePreference, type ThemeSlot, themePrepaint, } from './theme-mode';
|
|
132
136
|
export { formatTimestamp, localTimeZone, relativeTime, type TimeInput, type TimestampMode, } from './timestamp';
|
|
133
137
|
export { canonicalTone, type Tone } from './tone';
|
|
134
138
|
export { pathCandidates, type TruncateMode, type TruncateOptions, truncate } from './truncate';
|
|
139
|
+
export { isModifiedClick, type UrlState, type UrlStateOptions, urlState } from './url-state.svelte';
|
|
135
140
|
export { normalizeWorkingDir, type WorkingDirOptions, workingDirCandidates } from './working-dir';
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
export { artworkGradient, artworkHue, initials } from './artwork';
|
|
8
8
|
export { autoresize } from './autoresize';
|
|
9
9
|
export { avatarHue, avatarInitial } from './avatar';
|
|
10
|
+
export { breakpoints, breakpointsPx, maxWidth, minWidth } from './breakpoints';
|
|
10
11
|
export { copyToClipboard } from './clipboard';
|
|
11
12
|
export { default as Artwork } from './components/atoms/Artwork.svelte';
|
|
12
13
|
export { default as Avatar } from './components/atoms/Avatar.svelte';
|
|
@@ -39,6 +40,7 @@ export { default as TimeDisplay } from './components/atoms/TimeDisplay.svelte';
|
|
|
39
40
|
// ---- layouts ----
|
|
40
41
|
export { default as AppShell } from './components/layouts/AppShell.svelte';
|
|
41
42
|
export { default as AutoGrid } from './components/layouts/AutoGrid.svelte';
|
|
43
|
+
export { default as Backdrop } from './components/layouts/Backdrop.svelte';
|
|
42
44
|
export { default as Cluster } from './components/layouts/Cluster.svelte';
|
|
43
45
|
export { default as Container } from './components/layouts/Container.svelte';
|
|
44
46
|
export { default as MasterDetail } from './components/layouts/MasterDetail.svelte';
|
|
@@ -124,7 +126,9 @@ export { EMOJI_GROUPS, searchEmoji } from './emoji';
|
|
|
124
126
|
export { FIELD_KEY, getFieldContext, setFieldContext, warnUnlabelled, } from './field-context';
|
|
125
127
|
export { DEFAULT_FX, FX, FX_FAMILIES, fxAttr, fxDiff, fxPrepaint, isFxVariant, parseFx, } from './fx';
|
|
126
128
|
export { escapeHtml, renderMarkdown, stripAnsi, } from './markdown';
|
|
129
|
+
export { media, mq } from './media.svelte';
|
|
127
130
|
export { describeMediaTime, formatMediaTime, isLiveDuration } from './media-time';
|
|
131
|
+
export { PENDING_DELAY_MS, PENDING_MIN_MS, pending, } from './pending.svelte';
|
|
128
132
|
export { trackPointer } from './pointer';
|
|
129
133
|
// ---- query core (headless: schema / parser / AST / suggest / compilers) ----
|
|
130
134
|
export * as filterQuery from './query';
|
|
@@ -136,9 +140,10 @@ export { FX_PREPAINT, fx } from './stores/fx.svelte';
|
|
|
136
140
|
// ---- stores / actions ----
|
|
137
141
|
export { sound } from './stores/sound.svelte';
|
|
138
142
|
export { THEME_PREPAINT, THEMES, theme } from './stores/theme.svelte';
|
|
139
|
-
export { toasts, } from './stores/toast.svelte';
|
|
143
|
+
export { toasts, UNDO_FAIL_MESSAGE, } from './stores/toast.svelte';
|
|
140
144
|
export { AUTO_THEME, chooseTheme, DEFAULT_LOOK, DEFAULT_THEME_PREFERENCE, LOOKS, parseLook, pickerValue, preferenceFrom, resolveTheme, themePrepaint, } from './theme-mode';
|
|
141
145
|
export { formatTimestamp, localTimeZone, relativeTime, } from './timestamp';
|
|
142
146
|
export { canonicalTone } from './tone';
|
|
143
147
|
export { pathCandidates, truncate } from './truncate';
|
|
148
|
+
export { isModifiedClick, urlState } from './url-state.svelte';
|
|
144
149
|
export { normalizeWorkingDir, workingDirCandidates } from './working-dir';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Breakpoint } from './breakpoints';
|
|
2
|
+
export interface Media {
|
|
3
|
+
/** Reactive inside effects and templates; `false` during SSR. */
|
|
4
|
+
readonly matches: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function media(query: string): Media;
|
|
7
|
+
/** `mq.md` is true when the viewport is at least `breakpoints.md` wide. */
|
|
8
|
+
export declare const mq: Readonly<Record<Breakpoint, boolean>>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { MediaQuery } from 'svelte/reactivity';
|
|
2
|
+
import { breakpoints, minWidth } from './breakpoints';
|
|
3
|
+
const cache = new Map();
|
|
4
|
+
export function media(query) {
|
|
5
|
+
let m = cache.get(query);
|
|
6
|
+
if (!m) {
|
|
7
|
+
if (typeof window === 'undefined') {
|
|
8
|
+
m = { matches: false };
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
const mq = new MediaQuery(query);
|
|
12
|
+
m = {
|
|
13
|
+
get matches() {
|
|
14
|
+
return mq.current;
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
cache.set(query, m);
|
|
19
|
+
}
|
|
20
|
+
return m;
|
|
21
|
+
}
|
|
22
|
+
/** `mq.md` is true when the viewport is at least `breakpoints.md` wide. */
|
|
23
|
+
export const mq = Object.defineProperties({}, Object.fromEntries(Object.keys(breakpoints).map((bp) => [
|
|
24
|
+
bp,
|
|
25
|
+
{ enumerable: true, get: () => media(minWidth(bp)).matches },
|
|
26
|
+
])));
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Default show delay (ms): work that finishes sooner never shows an indicator. */
|
|
2
|
+
export declare const PENDING_DELAY_MS = 200;
|
|
3
|
+
/** Default minimum visible time (ms) once an indicator has appeared. */
|
|
4
|
+
export declare const PENDING_MIN_MS = 500;
|
|
5
|
+
export interface PendingOptions {
|
|
6
|
+
/** Wait this long before showing; 0 shows at once. */
|
|
7
|
+
delay?: number;
|
|
8
|
+
/** Once shown, stay visible at least this long; 0 hides at once. */
|
|
9
|
+
min?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface Pending {
|
|
12
|
+
/** The calmed indicator state: delayed in, held out. */
|
|
13
|
+
readonly visible: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** Call during component init; keep `aria-busy` on the real flag, only the visual is calmed. */
|
|
16
|
+
export declare function pending(busy: () => boolean, opts?: PendingOptions | (() => PendingOptions)): Pending;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { untrack } from 'svelte';
|
|
2
|
+
/** Default show delay (ms): work that finishes sooner never shows an indicator. */
|
|
3
|
+
export const PENDING_DELAY_MS = 200;
|
|
4
|
+
/** Default minimum visible time (ms) once an indicator has appeared. */
|
|
5
|
+
export const PENDING_MIN_MS = 500;
|
|
6
|
+
/** Call during component init; keep `aria-busy` on the real flag, only the visual is calmed. */
|
|
7
|
+
export function pending(busy, opts = {}) {
|
|
8
|
+
if (typeof document === 'undefined')
|
|
9
|
+
return { visible: false };
|
|
10
|
+
const options = () => (typeof opts === 'function' ? opts() : opts);
|
|
11
|
+
const delay = () => Math.max(0, options().delay ?? PENDING_DELAY_MS);
|
|
12
|
+
const min = () => Math.max(0, options().min ?? PENDING_MIN_MS);
|
|
13
|
+
let visible = $state(false);
|
|
14
|
+
let showTimer;
|
|
15
|
+
let minTimer;
|
|
16
|
+
let holding = false;
|
|
17
|
+
let hideWhenReleased = false;
|
|
18
|
+
const clearShow = () => {
|
|
19
|
+
if (showTimer !== undefined)
|
|
20
|
+
clearTimeout(showTimer);
|
|
21
|
+
showTimer = undefined;
|
|
22
|
+
};
|
|
23
|
+
const show = () => {
|
|
24
|
+
showTimer = undefined;
|
|
25
|
+
visible = true;
|
|
26
|
+
const hold = min();
|
|
27
|
+
if (hold > 0) {
|
|
28
|
+
holding = true;
|
|
29
|
+
minTimer = setTimeout(() => {
|
|
30
|
+
holding = false;
|
|
31
|
+
minTimer = undefined;
|
|
32
|
+
if (hideWhenReleased) {
|
|
33
|
+
hideWhenReleased = false;
|
|
34
|
+
visible = false;
|
|
35
|
+
}
|
|
36
|
+
}, hold);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const start = () => {
|
|
40
|
+
if (visible) {
|
|
41
|
+
hideWhenReleased = false;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (showTimer !== undefined)
|
|
45
|
+
return;
|
|
46
|
+
const wait = delay();
|
|
47
|
+
if (wait === 0)
|
|
48
|
+
show();
|
|
49
|
+
else
|
|
50
|
+
showTimer = setTimeout(show, wait);
|
|
51
|
+
};
|
|
52
|
+
const stop = () => {
|
|
53
|
+
clearShow();
|
|
54
|
+
if (!visible)
|
|
55
|
+
return;
|
|
56
|
+
if (holding)
|
|
57
|
+
hideWhenReleased = true;
|
|
58
|
+
else
|
|
59
|
+
visible = false;
|
|
60
|
+
};
|
|
61
|
+
$effect(() => {
|
|
62
|
+
const on = busy();
|
|
63
|
+
untrack(() => (on ? start() : stop()));
|
|
64
|
+
});
|
|
65
|
+
$effect(() => () => {
|
|
66
|
+
clearShow();
|
|
67
|
+
if (minTimer !== undefined)
|
|
68
|
+
clearTimeout(minTimer);
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
get visible() {
|
|
72
|
+
return visible;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|