@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.
- package/README.md +54 -5
- package/dist/autoresize.js +13 -0
- package/dist/clipboard.js +12 -0
- package/dist/components/atoms/Artwork.svelte +4 -2
- package/dist/components/atoms/Avatar.svelte +4 -3
- package/dist/components/atoms/Badge.svelte +13 -0
- package/dist/components/atoms/Button.svelte +25 -9
- package/dist/components/atoms/Card.svelte +2 -0
- package/dist/components/atoms/Dot.svelte +1 -1
- package/dist/components/atoms/Icon.svelte +7 -0
- package/dist/components/atoms/Icon.svelte.d.ts +7 -0
- package/dist/components/atoms/Kbd.svelte +28 -16
- package/dist/components/atoms/Link.svelte +6 -4
- package/dist/components/atoms/Scrim.svelte +18 -1
- package/dist/components/atoms/Slider.svelte +11 -1
- package/dist/components/atoms/Swatch.svelte +12 -0
- package/dist/components/atoms/TimeDisplay.svelte +98 -0
- package/dist/components/atoms/TimeDisplay.svelte.d.ts +24 -0
- package/dist/components/layouts/AppShell.svelte +72 -40
- package/dist/components/layouts/Container.svelte +25 -3
- package/dist/components/layouts/MasterDetail.svelte +59 -33
- package/dist/components/layouts/MasterDetail.svelte.d.ts +1 -1
- package/dist/components/layouts/NavItem.svelte +8 -4
- package/dist/components/layouts/NavSection.svelte +8 -2
- package/dist/components/layouts/ResizablePanel.svelte +19 -4
- package/dist/components/layouts/TabBar.svelte +217 -0
- package/dist/components/layouts/TabBar.svelte.d.ts +27 -0
- package/dist/components/layouts/Toolbar.svelte +5 -0
- package/dist/components/layouts/toolbar-roving.d.ts +16 -0
- package/dist/components/layouts/toolbar-roving.js +47 -1
- package/dist/components/molecules/Accordion.svelte +2 -1
- package/dist/components/molecules/AttachmentList.svelte +1 -0
- package/dist/components/molecules/Breadcrumb.svelte +7 -6
- package/dist/components/molecules/Breadcrumb.svelte.d.ts +2 -1
- package/dist/components/molecules/CapBar.svelte +3 -1
- package/dist/components/molecules/Composer.svelte +27 -5
- package/dist/components/molecules/Drawer.svelte +10 -1
- package/dist/components/molecules/Dropzone.svelte +2 -1
- package/dist/components/molecules/FilterInput.svelte +17 -1
- package/dist/components/molecules/FilterInput.svelte.d.ts +2 -0
- package/dist/components/molecules/Metric.svelte +28 -3
- package/dist/components/molecules/Modal.svelte +20 -4
- package/dist/components/molecules/Popover.svelte +7 -3
- package/dist/components/molecules/PullToRefresh.svelte +205 -0
- package/dist/components/molecules/PullToRefresh.svelte.d.ts +18 -0
- package/dist/components/molecules/SectionHeader.svelte +29 -14
- package/dist/components/molecules/SeekSlider.svelte +370 -0
- package/dist/components/molecules/SeekSlider.svelte.d.ts +37 -0
- package/dist/components/molecules/SegmentedControl.svelte +10 -6
- package/dist/components/molecules/SelectButton.svelte +33 -22
- package/dist/components/molecules/SwipeRow.svelte +349 -0
- package/dist/components/molecules/SwipeRow.svelte.d.ts +37 -0
- package/dist/components/molecules/Tabs.svelte +11 -5
- package/dist/components/molecules/Timestamp.svelte +1 -1
- package/dist/components/molecules/Toaster.svelte +70 -13
- package/dist/components/molecules/Tooltip.svelte +19 -10
- package/dist/components/molecules/VolumeSlider.svelte +367 -0
- package/dist/components/molecules/VolumeSlider.svelte.d.ts +30 -0
- package/dist/components/molecules/combobox-keyboard.js +2 -1
- package/dist/components/organisms/ConversationFeed.svelte +22 -8
- package/dist/components/organisms/ConversationFeed.svelte.d.ts +2 -0
- package/dist/components/organisms/DataTable.svelte +22 -5
- package/dist/components/organisms/DataTable.svelte.d.ts +4 -2
- package/dist/components/organisms/FilterSearchBar.svelte +4 -0
- package/dist/components/organisms/FilterSearchBar.svelte.d.ts +2 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/markdown.js +15 -7
- package/dist/media-time.d.ts +6 -0
- package/dist/media-time.js +25 -0
- package/dist/query/edit.js +1 -16
- package/dist/query/parser.js +68 -28
- package/dist/query/query.js +9 -5
- package/dist/query/quotes.d.ts +24 -0
- package/dist/query/quotes.js +78 -0
- package/dist/query/single.js +1 -3
- package/dist/query/suggest.js +25 -32
- package/dist/stores/fontscale.svelte.js +3 -2
- package/dist/stores/storage.d.ts +2 -0
- package/dist/stores/storage.js +16 -0
- package/dist/stores/theme.svelte.js +4 -7
- package/dist/styles/reset.css +30 -0
- package/dist/styles/syntax.css +0 -29
- 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
|
-
|
|
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={
|
|
102
|
+
id={`${uid}-acc-${item.id}`}
|
|
102
103
|
open={isOpen(item)}
|
|
103
104
|
onchange={(o) => set(item, o)}
|
|
104
105
|
disabled={item.disabled}
|
|
@@ -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
|
-
//
|
|
11
|
-
//
|
|
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=
|
|
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 =
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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;
|