@dorsk/tsumikit 0.70.0 → 0.72.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 +49 -8
- package/dist/autoresize.js +13 -0
- package/dist/clipboard.js +12 -0
- package/dist/components/atoms/Artwork.svelte +10 -3
- package/dist/components/atoms/Artwork.svelte.d.ts +2 -0
- package/dist/components/atoms/Avatar.svelte +4 -3
- package/dist/components/atoms/Badge.svelte +23 -8
- package/dist/components/atoms/Badge.svelte.d.ts +2 -1
- package/dist/components/atoms/Button.svelte +39 -11
- package/dist/components/atoms/Card.svelte +2 -0
- package/dist/components/atoms/Dot.svelte +1 -1
- package/dist/components/atoms/Icon.svelte +2 -0
- package/dist/components/atoms/Icon.svelte.d.ts +2 -0
- package/dist/components/atoms/Input.svelte +3 -0
- package/dist/components/atoms/Kbd.svelte +28 -16
- package/dist/components/atoms/Link.svelte +18 -4
- package/dist/components/atoms/Scrim.svelte +18 -1
- package/dist/components/atoms/Select.svelte +1 -0
- package/dist/components/atoms/Slider.svelte +11 -1
- package/dist/components/atoms/Swatch.svelte +12 -0
- package/dist/components/atoms/Switch.svelte +12 -0
- package/dist/components/layouts/AppShell.svelte +70 -39
- 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 +14 -0
- 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/Breadcrumb.svelte +44 -2
- package/dist/components/molecules/Breadcrumb.svelte.d.ts +6 -0
- package/dist/components/molecules/FilterInput.svelte +7 -0
- package/dist/components/molecules/FilterInput.svelte.d.ts +2 -0
- package/dist/components/molecules/PullToRefresh.svelte +205 -0
- package/dist/components/molecules/PullToRefresh.svelte.d.ts +18 -0
- package/dist/components/molecules/SegmentedControl.svelte +44 -1
- package/dist/components/molecules/SwipeRow.svelte +349 -0
- package/dist/components/molecules/SwipeRow.svelte.d.ts +37 -0
- package/dist/components/molecules/Toggle.svelte +12 -0
- package/dist/components/organisms/ConversationFeed.svelte +22 -8
- package/dist/components/organisms/ConversationFeed.svelte.d.ts +2 -0
- package/dist/components/organisms/DataTable.svelte +72 -12
- package/dist/components/organisms/DataTable.svelte.d.ts +7 -2
- package/dist/components/organisms/FilterSearchBar.svelte +4 -0
- package/dist/components/organisms/FilterSearchBar.svelte.d.ts +2 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/markdown.js +15 -7
- package/dist/styles/reset.css +30 -0
- package/dist/styles/syntax.css +0 -29
- package/dist/styles/tokens.css +6 -0
- 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
|
/**
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
char,
|
|
20
20
|
separator,
|
|
21
21
|
maxItems = 0,
|
|
22
|
+
size = 'sm',
|
|
23
|
+
wrap = true,
|
|
24
|
+
collapseCurrent = false,
|
|
22
25
|
class: klass = '',
|
|
23
26
|
style: styleProp = '',
|
|
24
27
|
}: {
|
|
@@ -29,6 +32,12 @@
|
|
|
29
32
|
/** Collapse the middle to an ellipsis when the trail is longer than this
|
|
30
33
|
* (0 = never collapse). Keeps the first item and the last `maxItems - 1`. */
|
|
31
34
|
maxItems?: number;
|
|
35
|
+
size?: 'sm' | 'md';
|
|
36
|
+
/** `false` keeps the trail on one line: ancestors keep their width and the
|
|
37
|
+
* current crumb shrinks with an ellipsis. */
|
|
38
|
+
wrap?: boolean;
|
|
39
|
+
/** Hide the current crumb and its separator, keeping only the ancestors. */
|
|
40
|
+
collapseCurrent?: boolean;
|
|
32
41
|
class?: string;
|
|
33
42
|
style?: string;
|
|
34
43
|
} = $props();
|
|
@@ -43,7 +52,14 @@
|
|
|
43
52
|
</script>
|
|
44
53
|
|
|
45
54
|
<nav aria-label="breadcrumb" data-tsu="Breadcrumb" class={klass} style={styleProp}>
|
|
46
|
-
<ol
|
|
55
|
+
<ol
|
|
56
|
+
class={[
|
|
57
|
+
'crumbs',
|
|
58
|
+
size === 'md' && 'crumbs--md',
|
|
59
|
+
!wrap && 'crumbs--nowrap',
|
|
60
|
+
collapseCurrent && 'crumbs--collapse',
|
|
61
|
+
]}
|
|
62
|
+
>
|
|
47
63
|
{#each shown as item, i (i)}
|
|
48
64
|
<li class="crumb">
|
|
49
65
|
{#if 'ellipsis' in item}
|
|
@@ -57,7 +73,7 @@
|
|
|
57
73
|
<span class="sep" aria-hidden="true">
|
|
58
74
|
{#if separator}{@render separator()}{:else if char}{char}{:else}<Icon
|
|
59
75
|
name="chevron-right"
|
|
60
|
-
size={14}
|
|
76
|
+
size={size === 'md' ? 16 : 14}
|
|
61
77
|
/>{/if}
|
|
62
78
|
</span>
|
|
63
79
|
{/if}
|
|
@@ -77,6 +93,26 @@
|
|
|
77
93
|
list-style: none;
|
|
78
94
|
font-size: var(--fs-sm);
|
|
79
95
|
}
|
|
96
|
+
.crumbs--md {
|
|
97
|
+
font-size: var(--fs-md);
|
|
98
|
+
}
|
|
99
|
+
.crumbs--nowrap {
|
|
100
|
+
flex-wrap: nowrap;
|
|
101
|
+
min-width: 0;
|
|
102
|
+
}
|
|
103
|
+
.crumbs--nowrap .crumb {
|
|
104
|
+
flex-shrink: 0;
|
|
105
|
+
}
|
|
106
|
+
.crumbs--nowrap .crumb:last-child {
|
|
107
|
+
flex-shrink: 1;
|
|
108
|
+
}
|
|
109
|
+
.crumbs--nowrap .current {
|
|
110
|
+
max-width: none;
|
|
111
|
+
}
|
|
112
|
+
.crumbs--collapse .crumb:last-child:not(:first-child),
|
|
113
|
+
.crumbs--collapse .crumb:nth-last-child(2) .sep {
|
|
114
|
+
display: none;
|
|
115
|
+
}
|
|
80
116
|
.crumb {
|
|
81
117
|
display: inline-flex;
|
|
82
118
|
align-items: center;
|
|
@@ -104,6 +140,12 @@
|
|
|
104
140
|
text-overflow: ellipsis;
|
|
105
141
|
max-width: 24ch;
|
|
106
142
|
}
|
|
143
|
+
@media (hover: none) and (pointer: coarse) {
|
|
144
|
+
.link {
|
|
145
|
+
min-height: 40px;
|
|
146
|
+
align-content: center;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
107
149
|
.ellipsis {
|
|
108
150
|
color: var(--text-muted);
|
|
109
151
|
}
|
|
@@ -11,6 +11,12 @@ type $$ComponentProps = {
|
|
|
11
11
|
/** Collapse the middle to an ellipsis when the trail is longer than this
|
|
12
12
|
* (0 = never collapse). Keeps the first item and the last `maxItems - 1`. */
|
|
13
13
|
maxItems?: number;
|
|
14
|
+
size?: 'sm' | 'md';
|
|
15
|
+
/** `false` keeps the trail on one line: ancestors keep their width and the
|
|
16
|
+
* current crumb shrinks with an ellipsis. */
|
|
17
|
+
wrap?: boolean;
|
|
18
|
+
/** Hide the current crumb and its separator, keeping only the ancestors. */
|
|
19
|
+
collapseCurrent?: boolean;
|
|
14
20
|
class?: string;
|
|
15
21
|
style?: string;
|
|
16
22
|
};
|
|
@@ -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;
|
|
@@ -360,6 +363,7 @@
|
|
|
360
363
|
class:surface-raised={surface === 'raised'}
|
|
361
364
|
class:surface-sunken={surface === 'sunken'}
|
|
362
365
|
class:fi--grow={grow}
|
|
366
|
+
class:fi--sans={font === 'sans'}
|
|
363
367
|
data-tsu="FilterInput"
|
|
364
368
|
>
|
|
365
369
|
<div class="fi__bar">
|
|
@@ -532,6 +536,9 @@
|
|
|
532
536
|
font-size: max(16px, var(--fs-sm));
|
|
533
537
|
}
|
|
534
538
|
}
|
|
539
|
+
.fi--sans .fi__input {
|
|
540
|
+
font-family: var(--font-sans);
|
|
541
|
+
}
|
|
535
542
|
.fi__input::placeholder {
|
|
536
543
|
color: var(--text-faint);
|
|
537
544
|
}
|
|
@@ -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;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Pull-down-to-refresh scroll container. The pull only starts from
|
|
3
|
+
// `scrollTop === 0` with a touch or pen pointer, the distance is damped so
|
|
4
|
+
// the indicator asymptotically approaches twice the threshold, and a release
|
|
5
|
+
// past the threshold awaits `onrefresh` while a Spinner holds the slot.
|
|
6
|
+
// Mouse and keyboard users refresh through the page's own control.
|
|
7
|
+
import type { Snippet } from 'svelte';
|
|
8
|
+
import Icon from '../atoms/Icon.svelte';
|
|
9
|
+
import Spinner from '../atoms/Spinner.svelte';
|
|
10
|
+
|
|
11
|
+
let {
|
|
12
|
+
children,
|
|
13
|
+
onrefresh,
|
|
14
|
+
threshold = 72,
|
|
15
|
+
label = 'Refreshing…',
|
|
16
|
+
pullLabel = 'Pull to refresh',
|
|
17
|
+
releaseLabel = 'Release to refresh',
|
|
18
|
+
disabled = false,
|
|
19
|
+
class: klass = '',
|
|
20
|
+
...rest
|
|
21
|
+
}: {
|
|
22
|
+
children?: Snippet;
|
|
23
|
+
/** Awaited after a release past the threshold; the spinner shows until it settles. */
|
|
24
|
+
onrefresh?: () => Promise<void> | void;
|
|
25
|
+
/** Damped pull distance (px) that arms the refresh. */
|
|
26
|
+
threshold?: number;
|
|
27
|
+
/** Announced through the live region while `onrefresh` is pending. */
|
|
28
|
+
label?: string;
|
|
29
|
+
pullLabel?: string;
|
|
30
|
+
releaseLabel?: string;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
class?: string;
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
} = $props();
|
|
35
|
+
|
|
36
|
+
const SLOP = 6;
|
|
37
|
+
|
|
38
|
+
let scroller: HTMLElement | undefined;
|
|
39
|
+
let offset = $state(0);
|
|
40
|
+
let pending = $state(false);
|
|
41
|
+
let pulling = $state(false);
|
|
42
|
+
let press: { id: number; y: number } | null = null;
|
|
43
|
+
|
|
44
|
+
const ready = $derived(pulling && offset >= threshold);
|
|
45
|
+
const phase = $derived(pending ? 'refreshing' : ready ? 'ready' : pulling ? 'pulling' : 'idle');
|
|
46
|
+
const progress = $derived(Math.min(1, offset / Math.max(1, threshold)));
|
|
47
|
+
|
|
48
|
+
function damp(dy: number) {
|
|
49
|
+
const max = threshold * 2;
|
|
50
|
+
return max * (1 - Math.exp(-dy / max));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function onpointerdown(e: PointerEvent) {
|
|
54
|
+
if (disabled || pending || press) return;
|
|
55
|
+
if (e.pointerType !== 'touch' && e.pointerType !== 'pen') return;
|
|
56
|
+
if (!scroller || scroller.scrollTop > 0) return;
|
|
57
|
+
press = { id: e.pointerId, y: e.clientY };
|
|
58
|
+
addEventListener('pointermove', onpointermove);
|
|
59
|
+
addEventListener('pointerup', onpointerup);
|
|
60
|
+
addEventListener('pointercancel', onpointercancel);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function onpointermove(e: PointerEvent) {
|
|
64
|
+
if (!press || e.pointerId !== press.id) return;
|
|
65
|
+
const dy = e.clientY - press.y;
|
|
66
|
+
if (!pulling) {
|
|
67
|
+
if (dy < -SLOP || (scroller && scroller.scrollTop > 0)) return release();
|
|
68
|
+
if (dy < SLOP) return;
|
|
69
|
+
pulling = true;
|
|
70
|
+
}
|
|
71
|
+
offset = dy > 0 ? damp(dy) : 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function onpointerup(e: PointerEvent) {
|
|
75
|
+
if (!press || e.pointerId !== press.id) return;
|
|
76
|
+
const fire = ready;
|
|
77
|
+
release();
|
|
78
|
+
if (fire) void refresh();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function onpointercancel(e: PointerEvent) {
|
|
82
|
+
if (!press || e.pointerId !== press.id) return;
|
|
83
|
+
release();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function release() {
|
|
87
|
+
press = null;
|
|
88
|
+
pulling = false;
|
|
89
|
+
if (!pending) offset = 0;
|
|
90
|
+
removeEventListener('pointermove', onpointermove);
|
|
91
|
+
removeEventListener('pointerup', onpointerup);
|
|
92
|
+
removeEventListener('pointercancel', onpointercancel);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function refresh() {
|
|
96
|
+
if (pending) return;
|
|
97
|
+
pending = true;
|
|
98
|
+
offset = threshold;
|
|
99
|
+
try {
|
|
100
|
+
await onrefresh?.();
|
|
101
|
+
} finally {
|
|
102
|
+
pending = false;
|
|
103
|
+
offset = 0;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function scrollRoot(el: HTMLElement) {
|
|
108
|
+
scroller = el;
|
|
109
|
+
const block = (e: TouchEvent) => {
|
|
110
|
+
if (pulling) e.preventDefault();
|
|
111
|
+
};
|
|
112
|
+
el.addEventListener('touchmove', block, { passive: false });
|
|
113
|
+
return () => {
|
|
114
|
+
el.removeEventListener('touchmove', block);
|
|
115
|
+
release();
|
|
116
|
+
scroller = undefined;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
</script>
|
|
120
|
+
|
|
121
|
+
<div
|
|
122
|
+
data-tsu="PullToRefresh"
|
|
123
|
+
data-state={phase}
|
|
124
|
+
class="ptr {klass}"
|
|
125
|
+
class:pulling
|
|
126
|
+
class:pending
|
|
127
|
+
style:--ptr-offset="{offset}px"
|
|
128
|
+
style:--ptr-progress={progress}
|
|
129
|
+
aria-busy={pending || undefined}
|
|
130
|
+
{onpointerdown}
|
|
131
|
+
{@attach scrollRoot}
|
|
132
|
+
{...rest}
|
|
133
|
+
>
|
|
134
|
+
<div class="ptr-indicator" aria-hidden="true">
|
|
135
|
+
{#if pending}
|
|
136
|
+
<Spinner label={undefined} />
|
|
137
|
+
{:else}
|
|
138
|
+
<Icon name="arrow-down" class="ptr-arrow" />
|
|
139
|
+
{/if}
|
|
140
|
+
</div>
|
|
141
|
+
<div class="ptr-status sr-only" role="status" aria-live="polite">
|
|
142
|
+
{#if pending}{label}{:else if ready}{releaseLabel}{:else if pulling}{pullLabel}{/if}
|
|
143
|
+
</div>
|
|
144
|
+
<div class="ptr-content">
|
|
145
|
+
{@render children?.()}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<style>
|
|
150
|
+
.ptr {
|
|
151
|
+
position: relative;
|
|
152
|
+
overflow-y: auto;
|
|
153
|
+
overscroll-behavior-y: contain;
|
|
154
|
+
-webkit-overflow-scrolling: touch;
|
|
155
|
+
touch-action: pan-y;
|
|
156
|
+
}
|
|
157
|
+
.ptr-content {
|
|
158
|
+
translate: 0 var(--ptr-offset, 0px);
|
|
159
|
+
transition: translate 200ms var(--ease);
|
|
160
|
+
}
|
|
161
|
+
.pulling .ptr-content {
|
|
162
|
+
transition: none;
|
|
163
|
+
}
|
|
164
|
+
.ptr-indicator {
|
|
165
|
+
position: absolute;
|
|
166
|
+
inset: 0 0 auto;
|
|
167
|
+
display: flex;
|
|
168
|
+
align-items: center;
|
|
169
|
+
justify-content: center;
|
|
170
|
+
height: var(--ptr-offset, 0px);
|
|
171
|
+
overflow: hidden;
|
|
172
|
+
color: var(--text-muted);
|
|
173
|
+
opacity: var(--ptr-progress, 0);
|
|
174
|
+
transition: height 200ms var(--ease);
|
|
175
|
+
pointer-events: none;
|
|
176
|
+
}
|
|
177
|
+
.pulling .ptr-indicator {
|
|
178
|
+
transition: none;
|
|
179
|
+
}
|
|
180
|
+
.ptr-indicator :global(.ptr-arrow) {
|
|
181
|
+
rotate: calc(180deg * var(--ptr-progress, 0));
|
|
182
|
+
transition: rotate 150ms var(--ease);
|
|
183
|
+
}
|
|
184
|
+
.pending .ptr-indicator {
|
|
185
|
+
color: var(--accent);
|
|
186
|
+
}
|
|
187
|
+
.sr-only {
|
|
188
|
+
position: absolute;
|
|
189
|
+
width: 1px;
|
|
190
|
+
height: 1px;
|
|
191
|
+
overflow: hidden;
|
|
192
|
+
clip: rect(0 0 0 0);
|
|
193
|
+
white-space: nowrap;
|
|
194
|
+
}
|
|
195
|
+
@media (prefers-reduced-motion: reduce) {
|
|
196
|
+
.ptr-content,
|
|
197
|
+
.ptr-indicator,
|
|
198
|
+
.ptr-indicator :global(.ptr-arrow) {
|
|
199
|
+
transition: none;
|
|
200
|
+
}
|
|
201
|
+
.ptr-indicator :global(.ptr-arrow) {
|
|
202
|
+
rotate: none;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
</style>
|