@dorsk/tsumikit 0.71.0 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +54 -5
  2. package/dist/autoresize.js +13 -0
  3. package/dist/clipboard.js +12 -0
  4. package/dist/components/atoms/Artwork.svelte +4 -2
  5. package/dist/components/atoms/Avatar.svelte +4 -3
  6. package/dist/components/atoms/Badge.svelte +13 -0
  7. package/dist/components/atoms/Button.svelte +25 -9
  8. package/dist/components/atoms/Card.svelte +2 -0
  9. package/dist/components/atoms/Dot.svelte +1 -1
  10. package/dist/components/atoms/Icon.svelte +7 -0
  11. package/dist/components/atoms/Icon.svelte.d.ts +7 -0
  12. package/dist/components/atoms/Kbd.svelte +28 -16
  13. package/dist/components/atoms/Link.svelte +6 -4
  14. package/dist/components/atoms/Scrim.svelte +18 -1
  15. package/dist/components/atoms/Slider.svelte +11 -1
  16. package/dist/components/atoms/Swatch.svelte +12 -0
  17. package/dist/components/atoms/TimeDisplay.svelte +98 -0
  18. package/dist/components/atoms/TimeDisplay.svelte.d.ts +24 -0
  19. package/dist/components/layouts/AppShell.svelte +72 -40
  20. package/dist/components/layouts/Container.svelte +25 -3
  21. package/dist/components/layouts/MasterDetail.svelte +59 -33
  22. package/dist/components/layouts/MasterDetail.svelte.d.ts +1 -1
  23. package/dist/components/layouts/NavItem.svelte +8 -4
  24. package/dist/components/layouts/NavSection.svelte +8 -2
  25. package/dist/components/layouts/ResizablePanel.svelte +19 -4
  26. package/dist/components/layouts/TabBar.svelte +217 -0
  27. package/dist/components/layouts/TabBar.svelte.d.ts +27 -0
  28. package/dist/components/layouts/Toolbar.svelte +5 -0
  29. package/dist/components/layouts/toolbar-roving.d.ts +16 -0
  30. package/dist/components/layouts/toolbar-roving.js +47 -1
  31. package/dist/components/molecules/Accordion.svelte +2 -1
  32. package/dist/components/molecules/AttachmentList.svelte +1 -0
  33. package/dist/components/molecules/Breadcrumb.svelte +7 -6
  34. package/dist/components/molecules/Breadcrumb.svelte.d.ts +2 -1
  35. package/dist/components/molecules/CapBar.svelte +3 -1
  36. package/dist/components/molecules/Composer.svelte +27 -5
  37. package/dist/components/molecules/Drawer.svelte +10 -1
  38. package/dist/components/molecules/Dropzone.svelte +2 -1
  39. package/dist/components/molecules/FilterInput.svelte +17 -1
  40. package/dist/components/molecules/FilterInput.svelte.d.ts +2 -0
  41. package/dist/components/molecules/Metric.svelte +28 -3
  42. package/dist/components/molecules/Modal.svelte +20 -4
  43. package/dist/components/molecules/Popover.svelte +7 -3
  44. package/dist/components/molecules/PullToRefresh.svelte +205 -0
  45. package/dist/components/molecules/PullToRefresh.svelte.d.ts +18 -0
  46. package/dist/components/molecules/SectionHeader.svelte +29 -14
  47. package/dist/components/molecules/SeekSlider.svelte +370 -0
  48. package/dist/components/molecules/SeekSlider.svelte.d.ts +37 -0
  49. package/dist/components/molecules/SegmentedControl.svelte +10 -6
  50. package/dist/components/molecules/SelectButton.svelte +33 -22
  51. package/dist/components/molecules/SwipeRow.svelte +349 -0
  52. package/dist/components/molecules/SwipeRow.svelte.d.ts +37 -0
  53. package/dist/components/molecules/Tabs.svelte +11 -5
  54. package/dist/components/molecules/Timestamp.svelte +1 -1
  55. package/dist/components/molecules/Toaster.svelte +70 -13
  56. package/dist/components/molecules/Tooltip.svelte +19 -10
  57. package/dist/components/molecules/VolumeSlider.svelte +367 -0
  58. package/dist/components/molecules/VolumeSlider.svelte.d.ts +30 -0
  59. package/dist/components/molecules/combobox-keyboard.js +2 -1
  60. package/dist/components/organisms/ConversationFeed.svelte +22 -8
  61. package/dist/components/organisms/ConversationFeed.svelte.d.ts +2 -0
  62. package/dist/components/organisms/DataTable.svelte +22 -5
  63. package/dist/components/organisms/DataTable.svelte.d.ts +4 -2
  64. package/dist/components/organisms/FilterSearchBar.svelte +4 -0
  65. package/dist/components/organisms/FilterSearchBar.svelte.d.ts +2 -0
  66. package/dist/index.d.ts +7 -0
  67. package/dist/index.js +7 -0
  68. package/dist/markdown.js +15 -7
  69. package/dist/media-time.d.ts +6 -0
  70. package/dist/media-time.js +25 -0
  71. package/dist/query/edit.js +1 -16
  72. package/dist/query/parser.js +68 -28
  73. package/dist/query/query.js +9 -5
  74. package/dist/query/quotes.d.ts +24 -0
  75. package/dist/query/quotes.js +78 -0
  76. package/dist/query/single.js +1 -3
  77. package/dist/query/suggest.js +25 -32
  78. package/dist/stores/fontscale.svelte.js +3 -2
  79. package/dist/stores/storage.d.ts +2 -0
  80. package/dist/stores/storage.js +16 -0
  81. package/dist/stores/theme.svelte.js +4 -7
  82. package/dist/styles/reset.css +30 -0
  83. package/dist/styles/syntax.css +0 -29
  84. package/package.json +1 -1
@@ -0,0 +1,217 @@
1
+ <script lang="ts">
2
+ // Phone bottom tab bar: fixed to the bottom edge with safe-area padding.
3
+ // Items without `href` form a `tablist` of `tab`s with roving focus; with
4
+ // `href` the bar is a `<nav>` of `<a aria-current="page">`. While mounted as
5
+ // a bar it publishes its measured height as `--tabbar-h` on `<html>` so
6
+ // content can clear it (`padding-bottom: var(--tabbar-h)`).
7
+ import Badge from '../atoms/Badge.svelte';
8
+ import Icon from '../atoms/Icon.svelte';
9
+ import type { IconName } from '../atoms/Icon.svelte';
10
+ import { formatCount, hasCount } from '../../count';
11
+
12
+ export interface TabBarItem {
13
+ id: string;
14
+ label: string;
15
+ icon?: IconName;
16
+ /** Renders an `<a>`; without it the item is a `<button role="tab">`. */
17
+ href?: string;
18
+ /** Count (capped at `99+`) or short text pinned to the glyph. 0 / '' render nothing. */
19
+ badge?: number | string;
20
+ /** Id of the panel a tab shows (`aria-controls`). */
21
+ controls?: string;
22
+ }
23
+
24
+ let {
25
+ items,
26
+ value = $bindable(),
27
+ onselect,
28
+ label = 'Primary',
29
+ placement = 'bar',
30
+ class: klass = '',
31
+ ...rest
32
+ }: {
33
+ items: TabBarItem[];
34
+ /** `id` of the selected item. */
35
+ value?: string;
36
+ onselect?: (id: string, item: TabBarItem) => void;
37
+ /** Accessible name of the bar. */
38
+ label?: string;
39
+ /** 'bar' = fixed to the bottom edge; 'inline' = flows with its parent. */
40
+ placement?: 'bar' | 'inline';
41
+ class?: string;
42
+ [key: string]: unknown;
43
+ } = $props();
44
+
45
+ const links = $derived(items.some((item) => item.href));
46
+ const isCurrent = (item: TabBarItem) => item.id === value;
47
+ const focusIndex = $derived(Math.max(0, items.findIndex(isCurrent)));
48
+ const badgeText = (badge: TabBarItem['badge']) =>
49
+ typeof badge === 'number' ? (hasCount(badge) ? formatCount(badge) : '') : (badge ?? '');
50
+
51
+ let height = $state(0);
52
+
53
+ $effect(() => {
54
+ if (placement !== 'bar' || typeof document === 'undefined') return;
55
+ const root = document.documentElement;
56
+ root.style.setProperty('--tabbar-h', `${height}px`);
57
+ return () => root.style.removeProperty('--tabbar-h');
58
+ });
59
+
60
+ function select(item: TabBarItem) {
61
+ if (item.id === value) return;
62
+ value = item.id;
63
+ onselect?.(item.id, item);
64
+ }
65
+
66
+ function onkeydown(e: KeyboardEvent) {
67
+ const all = [...(e.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('.tabbar-item')];
68
+ const i = all.indexOf(e.target as HTMLElement);
69
+ if (i < 0) return;
70
+ let next: number;
71
+ if (e.key === 'ArrowRight') next = (i + 1) % all.length;
72
+ else if (e.key === 'ArrowLeft') next = (i - 1 + all.length) % all.length;
73
+ else if (e.key === 'Home') next = 0;
74
+ else if (e.key === 'End') next = all.length - 1;
75
+ else return;
76
+ e.preventDefault();
77
+ all[next].focus();
78
+ if (!links) select(items[next]);
79
+ }
80
+ </script>
81
+
82
+ {#snippet glyph(item: TabBarItem)}
83
+ <span class="tabbar-glyph">
84
+ {#if item.icon}
85
+ <Icon name={item.icon} size={22} />
86
+ {/if}
87
+ {#if badgeText(item.badge)}
88
+ <Badge class="tabbar-badge" tone="accent" size="xs" border={false} active numeric={typeof item.badge === 'number'}>
89
+ {badgeText(item.badge)}
90
+ </Badge>
91
+ {/if}
92
+ </span>
93
+ <span class="tabbar-label">{item.label}</span>
94
+ {/snippet}
95
+
96
+ <nav
97
+ bind:offsetHeight={height}
98
+ data-tsu="TabBar"
99
+ aria-label={label}
100
+ class="tabbar {klass}"
101
+ class:bar={placement === 'bar'}
102
+ {...rest}
103
+ >
104
+ <div
105
+ class="tabbar-row"
106
+ role={links ? undefined : 'tablist'}
107
+ aria-label={links ? undefined : label}
108
+ {onkeydown}
109
+ >
110
+ {#each items as item, i (item.id)}
111
+ {#if item.href}
112
+ <a
113
+ href={item.href}
114
+ class="tabbar-item"
115
+ class:current={isCurrent(item)}
116
+ aria-current={isCurrent(item) ? 'page' : undefined}
117
+ onclick={() => select(item)}
118
+ >
119
+ {@render glyph(item)}
120
+ </a>
121
+ {:else}
122
+ <button
123
+ type="button"
124
+ role="tab"
125
+ class="tabbar-item"
126
+ class:current={isCurrent(item)}
127
+ aria-selected={isCurrent(item)}
128
+ aria-controls={item.controls}
129
+ tabindex={i === focusIndex ? 0 : -1}
130
+ onclick={() => select(item)}
131
+ >
132
+ {@render glyph(item)}
133
+ </button>
134
+ {/if}
135
+ {/each}
136
+ </div>
137
+ </nav>
138
+
139
+ <style>
140
+ .tabbar {
141
+ min-width: 0;
142
+ background: var(--bg-elevated);
143
+ border-top: 1px solid var(--border);
144
+ }
145
+ .bar {
146
+ position: fixed;
147
+ inset-inline: 0;
148
+ bottom: 0;
149
+ z-index: var(--z-nav);
150
+ padding-bottom: env(safe-area-inset-bottom, 0px);
151
+ padding-inline: env(safe-area-inset-left, 0px) env(safe-area-inset-right, 0px);
152
+ background: color-mix(in srgb, var(--bg-elevated) 92%, transparent);
153
+ backdrop-filter: blur(12px);
154
+ }
155
+ .tabbar-row {
156
+ display: flex;
157
+ align-items: stretch;
158
+ min-width: 0;
159
+ min-height: var(--nav-h);
160
+ }
161
+ .tabbar-item {
162
+ position: relative;
163
+ display: flex;
164
+ flex: 1 1 0;
165
+ min-width: 0;
166
+ min-height: max(48px, var(--touch-target));
167
+ flex-direction: column;
168
+ align-items: center;
169
+ justify-content: center;
170
+ gap: 2px;
171
+ padding: var(--sp-1) var(--sp-2);
172
+ border: none;
173
+ border-radius: 0;
174
+ background: none;
175
+ color: var(--text-muted);
176
+ font: inherit;
177
+ font-size: var(--fs-xs);
178
+ font-weight: var(--fw-medium);
179
+ text-decoration: none;
180
+ cursor: pointer;
181
+ -webkit-tap-highlight-color: transparent;
182
+ transition: color 0.12s var(--ease);
183
+ }
184
+ .tabbar-item:hover {
185
+ color: var(--text);
186
+ text-decoration: none;
187
+ }
188
+ .tabbar-item:focus-visible {
189
+ outline: 2px solid var(--accent);
190
+ outline-offset: -2px;
191
+ }
192
+ .tabbar-item.current {
193
+ color: var(--accent);
194
+ }
195
+ .tabbar-glyph {
196
+ position: relative;
197
+ display: inline-flex;
198
+ flex: none;
199
+ align-items: center;
200
+ }
201
+ .tabbar-label {
202
+ max-width: 100%;
203
+ overflow: hidden;
204
+ text-overflow: ellipsis;
205
+ white-space: nowrap;
206
+ }
207
+ .tabbar :global(.tabbar-badge) {
208
+ position: absolute;
209
+ top: -0.4rem;
210
+ left: calc(100% - 0.5rem);
211
+ }
212
+ @media (prefers-reduced-motion: reduce) {
213
+ .tabbar-item {
214
+ transition: none;
215
+ }
216
+ }
217
+ </style>
@@ -0,0 +1,27 @@
1
+ import type { IconName } from '../atoms/Icon.svelte';
2
+ export interface TabBarItem {
3
+ id: string;
4
+ label: string;
5
+ icon?: IconName;
6
+ /** Renders an `<a>`; without it the item is a `<button role="tab">`. */
7
+ href?: string;
8
+ /** Count (capped at `99+`) or short text pinned to the glyph. 0 / '' render nothing. */
9
+ badge?: number | string;
10
+ /** Id of the panel a tab shows (`aria-controls`). */
11
+ controls?: string;
12
+ }
13
+ type $$ComponentProps = {
14
+ items: TabBarItem[];
15
+ /** `id` of the selected item. */
16
+ value?: string;
17
+ onselect?: (id: string, item: TabBarItem) => void;
18
+ /** Accessible name of the bar. */
19
+ label?: string;
20
+ /** 'bar' = fixed to the bottom edge; 'inline' = flows with its parent. */
21
+ placement?: 'bar' | 'inline';
22
+ class?: string;
23
+ [key: string]: unknown;
24
+ };
25
+ declare const TabBar: import("svelte").Component<$$ComponentProps, {}, "value">;
26
+ type TabBar = ReturnType<typeof TabBar>;
27
+ export default TabBar;
@@ -16,6 +16,7 @@
16
16
  consumesArrowKeys,
17
17
  isToolbarNavKey,
18
18
  nextToolbarStop,
19
+ radioGroupPeers,
19
20
  TOOLBAR_STOP_ATTR,
20
21
  toolbarStops
21
22
  } from './toolbar-roving.js';
@@ -93,6 +94,10 @@
93
94
  list.forEach((node, i) => {
94
95
  node.setAttribute(TOOLBAR_STOP_ATTR, '');
95
96
  node.tabIndex = i === index ? 0 : -1;
97
+ for (const peer of radioGroupPeers(node, el)) {
98
+ peer.setAttribute(TOOLBAR_STOP_ATTR, '');
99
+ peer.tabIndex = -1;
100
+ }
96
101
  });
97
102
  }
98
103
 
@@ -38,6 +38,22 @@ export function acceptsRovingStop(el: {
38
38
  * @returns {HTMLElement[]}
39
39
  */
40
40
  export function toolbarStops(container: Element | null, isHidden: (node: HTMLElement) => boolean): HTMLElement[];
41
+ /**
42
+ * A named radio group is a single stop, like a native one: its checked radio,
43
+ * else its first. The arrows inside it stay the browser's.
44
+ *
45
+ * @param {HTMLElement[]} stops
46
+ * @returns {HTMLElement[]}
47
+ */
48
+ export function oneStopPerRadioGroup(stops: HTMLElement[]): HTMLElement[];
49
+ /**
50
+ * The other radios sharing `node`'s group inside `container`.
51
+ *
52
+ * @param {HTMLElement} node
53
+ * @param {Element | null} container
54
+ * @returns {HTMLElement[]}
55
+ */
56
+ export function radioGroupPeers(node: HTMLElement, container: Element | null): HTMLElement[];
41
57
  /**
42
58
  * Arrows wrap; Home/End jump to the ends. `undefined` means "not ours" — the
43
59
  * caller must leave the event alone.
@@ -20,6 +20,7 @@ const ARROW_CONSUMING_INPUTS = new Set([
20
20
  'week',
21
21
  'time',
22
22
  'range',
23
+ 'radio',
23
24
  ]);
24
25
 
25
26
  /** @param {string} key */
@@ -67,7 +68,7 @@ export function acceptsRovingStop(el) {
67
68
  */
68
69
  export function toolbarStops(container, isHidden) {
69
70
  if (!container) return [];
70
- return [
71
+ const stops = [
71
72
  .../** @type {NodeListOf<HTMLElement>} */ (container.querySelectorAll(TOOLBAR_STOP_SELECTOR)),
72
73
  ].filter((node) =>
73
74
  acceptsRovingStop({
@@ -79,6 +80,51 @@ export function toolbarStops(container, isHidden) {
79
80
  claimed: node.hasAttribute(TOOLBAR_STOP_ATTR),
80
81
  }),
81
82
  );
83
+ return oneStopPerRadioGroup(stops);
84
+ }
85
+
86
+ /** @param {HTMLElement} node */
87
+ function radioName(node) {
88
+ const input = /** @type {HTMLInputElement} */ (node);
89
+ return node.tagName === 'INPUT' && input.type === 'radio' && input.name ? input.name : null;
90
+ }
91
+
92
+ /**
93
+ * A named radio group is a single stop, like a native one: its checked radio,
94
+ * else its first. The arrows inside it stay the browser's.
95
+ *
96
+ * @param {HTMLElement[]} stops
97
+ * @returns {HTMLElement[]}
98
+ */
99
+ export function oneStopPerRadioGroup(stops) {
100
+ /** @type {Map<string, HTMLElement>} */
101
+ const picked = new Map();
102
+ for (const node of stops) {
103
+ const name = radioName(node);
104
+ if (name === null) continue;
105
+ const current = /** @type {HTMLInputElement | undefined} */ (picked.get(name));
106
+ if (!current || (!current.checked && /** @type {HTMLInputElement} */ (node).checked))
107
+ picked.set(name, node);
108
+ }
109
+ return stops.filter((node) => {
110
+ const name = radioName(node);
111
+ return name === null || picked.get(name) === node;
112
+ });
113
+ }
114
+
115
+ /**
116
+ * The other radios sharing `node`'s group inside `container`.
117
+ *
118
+ * @param {HTMLElement} node
119
+ * @param {Element | null} container
120
+ * @returns {HTMLElement[]}
121
+ */
122
+ export function radioGroupPeers(node, container) {
123
+ const name = radioName(node);
124
+ if (name === null || !container) return [];
125
+ return [
126
+ .../** @type {NodeListOf<HTMLElement>} */ (container.querySelectorAll('input[type="radio"]')),
127
+ ].filter((peer) => peer !== node && radioName(peer) === name);
82
128
  }
83
129
 
84
130
  /**
@@ -56,6 +56,7 @@
56
56
  style?: string;
57
57
  };
58
58
 
59
+ const uid = $props.id();
59
60
  let local = $state<Record<string, boolean>>({});
60
61
  let seen: Record<string, boolean | undefined> = {};
61
62
 
@@ -98,7 +99,7 @@
98
99
  {#each items as item (item.id)}
99
100
  <Disclosure
100
101
  class="acc-item"
101
- id={`acc-${item.id}`}
102
+ id={`${uid}-acc-${item.id}`}
102
103
  open={isOpen(item)}
103
104
  onchange={(o) => set(item, o)}
104
105
  disabled={item.disabled}
@@ -103,6 +103,7 @@
103
103
  maxWidth="16rem"
104
104
  removable={!!onremove}
105
105
  onremove={() => onremove?.(i)}
106
+ actionLabel="{removeLabel} {f.name}"
106
107
  title={f.name}
107
108
  >
108
109
  {@const src = thumb(f)}
@@ -6,9 +6,9 @@
6
6
  </script>
7
7
 
8
8
  <script lang="ts">
9
- // Navigation trail (e.g. "Musique > Artist > Album > Track"). The last item —
10
- // or any item without an `href` — renders unlinked and is marked
11
- // aria-current="page". When the trail exceeds `maxItems` the middle collapses
9
+ // Navigation trail (e.g. "Musique > Artist > Album > Track"). The last item
10
+ // renders unlinked with aria-current="page"; other items without an `href`
11
+ // render as plain text. When the trail exceeds `maxItems` the middle collapses
12
12
  // to an ellipsis, always keeping the first item and the tail. `separator` is a
13
13
  // snippet for custom markup, else a char via the `char` prop.
14
14
  import type { Snippet } from 'svelte';
@@ -30,7 +30,8 @@
30
30
  char?: string;
31
31
  separator?: Snippet;
32
32
  /** Collapse the middle to an ellipsis when the trail is longer than this
33
- * (0 = never collapse). Keeps the first item and the last `maxItems - 1`. */
33
+ * (0 = never collapse). Keeps the first item and the last `maxItems - 1`;
34
+ * values below 2 behave as 2 so the current page always shows. */
34
35
  maxItems?: number;
35
36
  size?: 'sm' | 'md';
36
37
  /** `false` keeps the trail on one line: ancestors keep their width and the
@@ -46,7 +47,7 @@
46
47
 
47
48
  const shown = $derived<Crumb[]>(
48
49
  maxItems > 0 && items.length > maxItems
49
- ? [items[0], { ellipsis: true }, ...items.slice(items.length - (maxItems - 1))]
50
+ ? [items[0], { ellipsis: true }, ...items.slice(items.length - (Math.max(2, maxItems) - 1))]
50
51
  : items
51
52
  );
52
53
  </script>
@@ -67,7 +68,7 @@
67
68
  {:else if item.href && i < shown.length - 1}
68
69
  <a class="link" href={item.href}>{item.label}</a>
69
70
  {:else}
70
- <span class="current" aria-current="page">{item.label}</span>
71
+ <span class="current" aria-current={i === shown.length - 1 ? 'page' : undefined}>{item.label}</span>
71
72
  {/if}
72
73
  {#if i < shown.length - 1}
73
74
  <span class="sep" aria-hidden="true">
@@ -9,7 +9,8 @@ type $$ComponentProps = {
9
9
  char?: string;
10
10
  separator?: Snippet;
11
11
  /** Collapse the middle to an ellipsis when the trail is longer than this
12
- * (0 = never collapse). Keeps the first item and the last `maxItems - 1`. */
12
+ * (0 = never collapse). Keeps the first item and the last `maxItems - 1`;
13
+ * values below 2 behave as 2 so the current page always shows. */
13
14
  maxItems?: number;
14
15
  size?: 'sm' | 'md';
15
16
  /** `false` keeps the trail on one line: ancestors keep their width and the
@@ -112,7 +112,7 @@
112
112
  let limit = $state(0);
113
113
  let dragging = $state(false);
114
114
  let keying = $state(false);
115
- let committed = cap;
115
+ let committed: number | null = null;
116
116
 
117
117
  const stacked = $derived(isStacked(layout, width, limit));
118
118
 
@@ -151,6 +151,7 @@
151
151
  if (readonly || !trackEl || e.button !== 0) return;
152
152
  e.preventDefault();
153
153
  dragging = true;
154
+ committed = cap;
154
155
  trackEl.setPointerCapture(e.pointerId);
155
156
  setCap(capFromPointer(e.clientX, trackEl.getBoundingClientRect(), step, min, max));
156
157
  trackEl.querySelector<HTMLElement>('.handle')?.focus();
@@ -169,6 +170,7 @@
169
170
  const next = capKeyStep(e.key, e.shiftKey, snapCap(cap ?? max, step, min, max), step, min, max);
170
171
  if (next === null) return;
171
172
  e.preventDefault();
173
+ if (!keying) committed = cap;
172
174
  keying = true;
173
175
  setCap(next);
174
176
  }
@@ -129,8 +129,32 @@
129
129
  addFiles(files);
130
130
  }
131
131
  }
132
+ // Only file drags are handled; native text drag-and-drop into the field is
133
+ // left to the browser. The depth counter stops dragleave on child crossings
134
+ // from flickering the `dragging` state.
135
+ let dragDepth = 0;
136
+ function hasFiles(e: DragEvent): boolean {
137
+ return Array.from(e.dataTransfer?.types ?? []).includes('Files');
138
+ }
139
+ function ondragenter(e: DragEvent) {
140
+ if (!hasFiles(e)) return;
141
+ dragDepth++;
142
+ dragging = true;
143
+ }
144
+ function ondragleave(e: DragEvent) {
145
+ if (!hasFiles(e)) return;
146
+ dragDepth = Math.max(0, dragDepth - 1);
147
+ if (dragDepth === 0) dragging = false;
148
+ }
149
+ function ondragover(e: DragEvent) {
150
+ if (!hasFiles(e)) return;
151
+ e.preventDefault();
152
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
153
+ }
132
154
  function ondrop(e: DragEvent) {
155
+ if (!hasFiles(e)) return;
133
156
  e.preventDefault();
157
+ dragDepth = 0;
134
158
  dragging = false;
135
159
  addFiles(Array.from(e.dataTransfer?.files ?? []));
136
160
  }
@@ -156,11 +180,9 @@
156
180
  class:busy
157
181
  style={styleProp}
158
182
  role="group"
159
- ondragover={(e) => {
160
- e.preventDefault();
161
- dragging = true;
162
- }}
163
- ondragleave={() => (dragging = false)}
183
+ {ondragenter}
184
+ {ondragleave}
185
+ {ondragover}
164
186
  {ondrop}
165
187
  >
166
188
  <AttachmentList files={attachments} onremove={removeAt} />
@@ -85,8 +85,16 @@
85
85
  onclose?.();
86
86
  }
87
87
 
88
+ // A drag that starts in the panel and ends on the backdrop fires click on the
89
+ // dialog too, so the press must have landed on the backdrop as well.
90
+ let pressOnBackdrop = false;
91
+ function onDialogPointerDown(e: PointerEvent) {
92
+ pressOnBackdrop = e.target === dialogEl;
93
+ }
88
94
  function onDialogClick(e: MouseEvent) {
89
- if (e.target === dialogEl) requestClose();
95
+ const outside = pressOnBackdrop && e.target === dialogEl;
96
+ pressOnBackdrop = false;
97
+ if (outside) requestClose();
90
98
  }
91
99
  </script>
92
100
 
@@ -104,6 +112,7 @@
104
112
  onclose={() => {
105
113
  if (controlled) open = false;
106
114
  }}
115
+ onpointerdown={onDialogPointerDown}
107
116
  onclick={onDialogClick}
108
117
  >
109
118
  <div
@@ -91,12 +91,13 @@
91
91
  if (depth === 0) setOver(false);
92
92
  }
93
93
  function onDragOver(e: DragEvent) {
94
- if (disabled) return;
94
+ if (disabled || !hasFiles(e)) return;
95
95
  // preventDefault is required or `drop` never fires.
96
96
  e.preventDefault();
97
97
  if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
98
98
  }
99
99
  function onDrop(e: DragEvent) {
100
+ if (!hasFiles(e)) return;
100
101
  e.preventDefault();
101
102
  depth = 0;
102
103
  setOver(false);
@@ -60,6 +60,7 @@
60
60
  size = 'md',
61
61
  shape = 'square',
62
62
  surface = 'base',
63
+ font = 'mono',
63
64
  hotkey,
64
65
  showHotkey = false,
65
66
  grow = false,
@@ -106,6 +107,8 @@
106
107
  shape?: 'square' | 'pill';
107
108
  /** Theme-aware surface shade of the bar (mirrors Card). */
108
109
  surface?: 'base' | 'raised' | 'sunken';
110
+ /** Typeface of the typed text and placeholder; `sans` for plain search boxes. */
111
+ font?: 'mono' | 'sans';
109
112
  /** Key (e.g. `'/'`) that focuses the input when pressed outside an editable
110
113
  * element and without modifiers. */
111
114
  hotkey?: string;
@@ -145,6 +148,8 @@
145
148
  };
146
149
 
147
150
  const field = getFieldContext();
151
+ const uid = $props.id();
152
+ const listboxId = `${uid}-lb`;
148
153
  let el = $state<HTMLInputElement | null>(null);
149
154
  let menuEl = $state<HTMLDivElement | null>(null);
150
155
  let menu = $state<SuggestState | null>(null);
@@ -360,6 +365,7 @@
360
365
  class:surface-raised={surface === 'raised'}
361
366
  class:surface-sunken={surface === 'sunken'}
362
367
  class:fi--grow={grow}
368
+ class:fi--sans={font === 'sans'}
363
369
  data-tsu="FilterInput"
364
370
  >
365
371
  <div class="fi__bar">
@@ -374,6 +380,11 @@
374
380
  id={id ?? field?.id}
375
381
  aria-describedby={ariaDescribedby ?? field?.describedBy}
376
382
  aria-invalid={ariaInvalid ?? (field?.invalid ? 'true' : undefined)}
383
+ role="combobox"
384
+ aria-autocomplete="list"
385
+ aria-expanded={open && !!menu}
386
+ aria-controls={open && menu ? listboxId : undefined}
387
+ aria-activedescendant={open && menu ? `${uid}-opt-${active}` : undefined}
377
388
  spellcheck="false"
378
389
  autocomplete="off"
379
390
  placeholder={hint}
@@ -416,14 +427,16 @@
416
427
  </div>
417
428
 
418
429
  {#if open && menu}
419
- <div bind:this={menuEl} class="fi__menu" role="listbox" aria-label={kindLabel[menu.kind]}>
430
+ <div bind:this={menuEl} id={listboxId} class="fi__menu" role="listbox" aria-label={kindLabel[menu.kind]}>
420
431
  <div class="fi__menu-head">{kindLabel[menu.kind]}</div>
421
432
  {#each menu.items as item, i (item.insert + i)}
422
433
  <button
423
434
  type="button"
435
+ id="{uid}-opt-{i}"
424
436
  class="fi__opt"
425
437
  class:is-active={i === active}
426
438
  role="option"
439
+ tabindex="-1"
427
440
  aria-selected={i === active}
428
441
  onmousedown={(e) => e.preventDefault()}
429
442
  onmouseenter={() => {
@@ -532,6 +545,9 @@
532
545
  font-size: max(16px, var(--fs-sm));
533
546
  }
534
547
  }
548
+ .fi--sans .fi__input {
549
+ font-family: var(--font-sans);
550
+ }
535
551
  .fi__input::placeholder {
536
552
  color: var(--text-faint);
537
553
  }
@@ -54,6 +54,8 @@ type Own = {
54
54
  shape?: 'square' | 'pill';
55
55
  /** Theme-aware surface shade of the bar (mirrors Card). */
56
56
  surface?: 'base' | 'raised' | 'sunken';
57
+ /** Typeface of the typed text and placeholder; `sans` for plain search boxes. */
58
+ font?: 'mono' | 'sans';
57
59
  /** Key (e.g. `'/'`) that focuses the input when pressed outside an editable
58
60
  * element and without modifiers. */
59
61
  hotkey?: string;
@@ -98,20 +98,27 @@
98
98
  {/snippet}
99
99
 
100
100
  {#if layout === 'inline'}
101
- <div
101
+ <svelte:element
102
+ this={href ? 'a' : onclick ? 'button' : 'div'}
102
103
  data-tsu="Metric"
103
104
  class="metric metric-inline metric-{canonicalTone(tone)} {tintValue ? 'metric-tint' : ''} {klass}"
104
105
  class:metric-sm={size === 'sm'}
106
+ class:metric-tap={!!(href || onclick)}
107
+ href={href || undefined}
108
+ target={href && external ? '_blank' : undefined}
109
+ rel={href && external ? 'noopener noreferrer' : undefined}
110
+ type={!href && onclick ? 'button' : undefined}
111
+ {onclick}
105
112
  {...rest}
106
113
  >
107
- <span class="metric-label">{label}</span>
114
+ <span class="metric-label">{label}{#if href && external}<Icon name="external" />{/if}</span>
108
115
  <span class="metric-value">
109
116
  <span class="metric-num">{value}</span>
110
117
  {#if unit}<span class="metric-unit">{unit}</span>{/if}
111
118
  </span>
112
119
  {@render segmentRow()}
113
120
  {#if sub}<span class="metric-sub">{sub}</span>{/if}
114
- </div>
121
+ </svelte:element>
115
122
  {:else}
116
123
  <Card
117
124
  data-tsu="Metric"
@@ -242,6 +249,24 @@
242
249
  .metric-inline .metric-num {
243
250
  font-size: var(--fs-md);
244
251
  }
252
+ .metric-inline.metric-tap {
253
+ padding: 0;
254
+ border: 0;
255
+ background: none;
256
+ font: inherit;
257
+ color: inherit;
258
+ text-align: start;
259
+ text-decoration: none;
260
+ cursor: pointer;
261
+ border-radius: var(--r-sm);
262
+ }
263
+ .metric-inline.metric-tap:hover .metric-label {
264
+ color: var(--text);
265
+ }
266
+ .metric-inline.metric-tap:focus-visible {
267
+ outline: 2px solid var(--accent);
268
+ outline-offset: 2px;
269
+ }
245
270
  .metric-segments {
246
271
  display: flex;
247
272
  flex-wrap: wrap;