@heyramzi/ui 3.3.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/docs/index.js +1 -0
- package/dist/components/list-page/toolbar-density.js +20 -1
- package/package.json +1 -1
- package/src/charts/chart-plot.svelte +21 -18
- package/src/components/docs/document-view.svelte +232 -0
- package/src/components/docs/document-view.svelte.d.ts +29 -0
- package/src/components/docs/index.ts +1 -0
- package/src/components/list-page/filter-popover.svelte +17 -2
- package/src/components/list-page/list-page-shell.svelte +155 -69
- package/src/components/list-page/list-page-shell.svelte.d.ts +6 -1
- package/src/components/list-page/toolbar-density.ts +24 -1
|
@@ -3,5 +3,6 @@
|
|
|
3
3
|
* A surface supplies a title and a body, and never lays a document out itself.
|
|
4
4
|
*/
|
|
5
5
|
export { default as DocPage } from "../../../src/components/docs/doc-page.svelte";
|
|
6
|
+
export { default as DocumentView } from "../../../src/components/docs/document-view.svelte";
|
|
6
7
|
export { default as OnThisPage } from "../../../src/components/docs/on-this-page.svelte";
|
|
7
8
|
export { ScrollSpy, READ_LINE } from "./scroll-spy.svelte.js";
|
|
@@ -15,12 +15,31 @@ export function getToolbarDensity() {
|
|
|
15
15
|
*/
|
|
16
16
|
// WHY `max-content` on a probe, not the row's own width: the row is sized by
|
|
17
17
|
// its container, so reading it back always says "it fits" (see collapse.ts).
|
|
18
|
+
// WHY `flex: none` too: a row that is itself a flex item takes its width from
|
|
19
|
+
// flex-basis, so `max-content` alone read back the container (10 Nov 2026).
|
|
20
|
+
// WHY transitions are stopped: under reduced motion `tokens/base.css` gives every
|
|
21
|
+
// element an 80ms transition on `all`, so the probe's own width change started a
|
|
22
|
+
// transition and the read got the old width. The row never folded at 390px and
|
|
23
|
+
// axe flagged the squeezed search field (gallery, 10 Nov 2026). The last fold's
|
|
24
|
+
// gap and shrink changes are still running then too, so they're finished first.
|
|
18
25
|
export function naturalRowWidth(row) {
|
|
19
|
-
const {
|
|
26
|
+
for (const animation of row.getAnimations({ subtree: true })) {
|
|
27
|
+
if (animation instanceof CSSTransition) animation.finish();
|
|
28
|
+
}
|
|
29
|
+
const { width, flexWrap, flex } = row.style;
|
|
30
|
+
const transition = row.style.getPropertyValue("transition");
|
|
31
|
+
const priority = row.style.getPropertyPriority("transition");
|
|
32
|
+
row.style.setProperty("transition", "none", "important");
|
|
20
33
|
row.style.width = "max-content";
|
|
21
34
|
row.style.flexWrap = "nowrap";
|
|
35
|
+
row.style.flex = "none";
|
|
22
36
|
const needed = row.getBoundingClientRect().width;
|
|
23
37
|
row.style.width = width;
|
|
24
38
|
row.style.flexWrap = flexWrap;
|
|
39
|
+
row.style.flex = flex;
|
|
40
|
+
// The restored size has to settle before transitions come back, or the
|
|
41
|
+
// row would animate back from its probe width.
|
|
42
|
+
void row.offsetWidth;
|
|
43
|
+
row.style.setProperty("transition", transition, priority);
|
|
25
44
|
return needed;
|
|
26
45
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "Svelte 5 UI for apps: primitives, effects and animations, plus work (tasks and docs), charts, slides, an editor, onboarding and a canvas as subpaths.",
|
|
5
5
|
"homepage": "https://ui.heyramzi.com",
|
|
6
6
|
"license": "MIT",
|
|
@@ -82,25 +82,28 @@
|
|
|
82
82
|
{#if count > 0}
|
|
83
83
|
<p class="hr-sr-only" aria-live="polite">{announce}</p>
|
|
84
84
|
{/if}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
{#each table.columns as column, j (j)}
|
|
91
|
-
<th scope="col">{column}</th>
|
|
92
|
-
{/each}
|
|
93
|
-
</tr>
|
|
94
|
-
</thead>
|
|
95
|
-
<tbody>
|
|
96
|
-
{#each table.rows as row, i (i)}
|
|
85
|
+
<!-- A table ignores width: 1px and spills past the page, so the clipping box is a div. -->
|
|
86
|
+
<div class="hr-sr-only">
|
|
87
|
+
<table>
|
|
88
|
+
<caption>{label}</caption>
|
|
89
|
+
<thead>
|
|
97
90
|
<tr>
|
|
98
|
-
<th scope="
|
|
99
|
-
{#each
|
|
100
|
-
<
|
|
91
|
+
<th scope="col">{table.xLabel}</th>
|
|
92
|
+
{#each table.columns as column, j (j)}
|
|
93
|
+
<th scope="col">{column}</th>
|
|
101
94
|
{/each}
|
|
102
95
|
</tr>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
96
|
+
</thead>
|
|
97
|
+
<tbody>
|
|
98
|
+
{#each table.rows as row, i (i)}
|
|
99
|
+
<tr>
|
|
100
|
+
<th scope="row">{row.x}</th>
|
|
101
|
+
{#each row.cells as cell, j (j)}
|
|
102
|
+
<td>{cell}</td>
|
|
103
|
+
{/each}
|
|
104
|
+
</tr>
|
|
105
|
+
{/each}
|
|
106
|
+
</tbody>
|
|
107
|
+
</table>
|
|
108
|
+
</div>
|
|
106
109
|
{/if}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// =========================
|
|
3
|
+
// IMPORTS
|
|
4
|
+
// =========================
|
|
5
|
+
import type { Component, Snippet } from "svelte";
|
|
6
|
+
import { untrack } from "svelte";
|
|
7
|
+
import DocPage from "./doc-page.svelte";
|
|
8
|
+
import type { DocHeading } from "./scroll-spy.svelte.js";
|
|
9
|
+
|
|
10
|
+
// =========================
|
|
11
|
+
// TYPES
|
|
12
|
+
// =========================
|
|
13
|
+
interface Props {
|
|
14
|
+
title: string;
|
|
15
|
+
/** Breadcrumb, as a reader would say it: "Marketing / Reports". */
|
|
16
|
+
section?: string;
|
|
17
|
+
/** Already worded and localised: "Updated 30 Jul 2026". */
|
|
18
|
+
updated?: string;
|
|
19
|
+
icon?: Component<{
|
|
20
|
+
class?: string;
|
|
21
|
+
weight?: "bold";
|
|
22
|
+
"aria-hidden"?: boolean | "true" | "false";
|
|
23
|
+
}>;
|
|
24
|
+
actions?: Snippet;
|
|
25
|
+
/** The body's h2s, anchored by id, for the "On this page" rail. */
|
|
26
|
+
headings?: DocHeading[];
|
|
27
|
+
contentsLabel: string;
|
|
28
|
+
/** The rendered body. Trusted HTML from the consumer's own parser, never user input. */
|
|
29
|
+
html: string;
|
|
30
|
+
/** Changes when another document is shown: the pane returns to the top and the body re-enters. */
|
|
31
|
+
resetKey: string;
|
|
32
|
+
measure?: "prose" | "wide";
|
|
33
|
+
/** Runs on the body after every swap (icons, diagrams). Returns its cleanup. */
|
|
34
|
+
enhance?: (root: HTMLElement) => void | (() => void);
|
|
35
|
+
class?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// =========================
|
|
39
|
+
// PROPS
|
|
40
|
+
// =========================
|
|
41
|
+
let {
|
|
42
|
+
title,
|
|
43
|
+
section,
|
|
44
|
+
updated,
|
|
45
|
+
icon,
|
|
46
|
+
actions,
|
|
47
|
+
headings = [],
|
|
48
|
+
contentsLabel,
|
|
49
|
+
html,
|
|
50
|
+
resetKey,
|
|
51
|
+
measure = "prose",
|
|
52
|
+
enhance,
|
|
53
|
+
class: className = "",
|
|
54
|
+
}: Props = $props();
|
|
55
|
+
|
|
56
|
+
// =========================
|
|
57
|
+
// STATE
|
|
58
|
+
// =========================
|
|
59
|
+
let body = $state<HTMLElement | null>(null);
|
|
60
|
+
|
|
61
|
+
// =========================
|
|
62
|
+
// EFFECTS
|
|
63
|
+
// =========================
|
|
64
|
+
$effect(() => {
|
|
65
|
+
void html;
|
|
66
|
+
const root = body;
|
|
67
|
+
if (!root || !enhance) return;
|
|
68
|
+
return untrack(() => enhance(root)) ?? undefined;
|
|
69
|
+
});
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<!-- =========================== -->
|
|
73
|
+
<!-- MARKUP -->
|
|
74
|
+
<!-- =========================== -->
|
|
75
|
+
<!-- Inline, not framed: a link is an ordinary anchor, so the router swaps only this body. -->
|
|
76
|
+
<DocPage
|
|
77
|
+
{title}
|
|
78
|
+
{section}
|
|
79
|
+
{updated}
|
|
80
|
+
{icon}
|
|
81
|
+
{actions}
|
|
82
|
+
{headings}
|
|
83
|
+
{contentsLabel}
|
|
84
|
+
{resetKey}
|
|
85
|
+
{measure}
|
|
86
|
+
>
|
|
87
|
+
{#key resetKey}
|
|
88
|
+
<div bind:this={body} class="document-view t-enter {className}">
|
|
89
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted, parsed server-side -->
|
|
90
|
+
{@html html}
|
|
91
|
+
</div>
|
|
92
|
+
{/key}
|
|
93
|
+
</DocPage>
|
|
94
|
+
|
|
95
|
+
<!-- =========================== -->
|
|
96
|
+
<!-- STYLES -->
|
|
97
|
+
<!-- =========================== -->
|
|
98
|
+
<!-- In the components layer at zero specificity, so a consumer's own document stylesheet wins without !important. -->
|
|
99
|
+
<style>
|
|
100
|
+
@layer theme, base, components, utilities;
|
|
101
|
+
|
|
102
|
+
@layer components {
|
|
103
|
+
:global {
|
|
104
|
+
:where(.document-view) {
|
|
105
|
+
color: var(--color-foreground);
|
|
106
|
+
font-size: 0.9375rem;
|
|
107
|
+
line-height: 1.65;
|
|
108
|
+
overflow-wrap: anywhere;
|
|
109
|
+
}
|
|
110
|
+
:where(.document-view) :where(h1, h2, h3, h4) {
|
|
111
|
+
font-family: var(--font-heading);
|
|
112
|
+
color: var(--color-foreground);
|
|
113
|
+
line-height: 1.25;
|
|
114
|
+
text-wrap: balance;
|
|
115
|
+
scroll-margin-top: 6rem;
|
|
116
|
+
}
|
|
117
|
+
:where(.document-view) :where(h1:first-child) {
|
|
118
|
+
display: none;
|
|
119
|
+
}
|
|
120
|
+
:where(.document-view) :where(h2) {
|
|
121
|
+
margin: 2.5rem 0 0.75rem;
|
|
122
|
+
font-size: 1.25rem;
|
|
123
|
+
font-weight: 700;
|
|
124
|
+
letter-spacing: -0.01em;
|
|
125
|
+
}
|
|
126
|
+
:where(.document-view) :where(h3) {
|
|
127
|
+
margin: 1.75rem 0 0.5rem;
|
|
128
|
+
font-size: 1rem;
|
|
129
|
+
font-weight: 600;
|
|
130
|
+
}
|
|
131
|
+
:where(.document-view) > :where(:first-child, h1:first-child + *) {
|
|
132
|
+
margin-top: 0;
|
|
133
|
+
}
|
|
134
|
+
:where(.document-view) :where(p, ul, ol, blockquote, pre, table) {
|
|
135
|
+
margin: 0 0 1rem;
|
|
136
|
+
}
|
|
137
|
+
:where(.document-view) :where(ul, ol) {
|
|
138
|
+
padding-left: 1.25rem;
|
|
139
|
+
}
|
|
140
|
+
:where(.document-view) :where(li + li) {
|
|
141
|
+
margin-top: 0.25rem;
|
|
142
|
+
}
|
|
143
|
+
:where(.document-view) :where(a) {
|
|
144
|
+
color: var(--color-brand);
|
|
145
|
+
text-decoration: underline;
|
|
146
|
+
text-decoration-color: color-mix(in oklch, currentColor 35%, transparent);
|
|
147
|
+
text-underline-offset: 0.16em;
|
|
148
|
+
transition: text-decoration-color var(--duration-fast) ease-out;
|
|
149
|
+
}
|
|
150
|
+
@media (hover: hover) and (pointer: fine) {
|
|
151
|
+
:where(.document-view) :where(a:hover) {
|
|
152
|
+
text-decoration-color: currentColor;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/* A wikilink to a page that does not exist yet reads as text, not as a dead link. */
|
|
156
|
+
:where(.document-view) :where(.wikilink.missing, a.missing) {
|
|
157
|
+
color: var(--color-muted-foreground);
|
|
158
|
+
text-decoration-style: dashed;
|
|
159
|
+
}
|
|
160
|
+
:where(.document-view) :where(code) {
|
|
161
|
+
font-family: var(--font-mono);
|
|
162
|
+
font-size: 0.85em;
|
|
163
|
+
padding: 0.1em 0.35em;
|
|
164
|
+
border-radius: var(--radius-sm, 0.375rem);
|
|
165
|
+
background: var(--color-muted);
|
|
166
|
+
}
|
|
167
|
+
:where(.document-view) :where(pre) {
|
|
168
|
+
overflow-x: auto;
|
|
169
|
+
padding: 0.875rem 1rem;
|
|
170
|
+
border: 1px solid var(--color-border);
|
|
171
|
+
border-radius: var(--radius-lg);
|
|
172
|
+
background: var(--color-muted);
|
|
173
|
+
font-size: 0.8125rem;
|
|
174
|
+
line-height: 1.55;
|
|
175
|
+
}
|
|
176
|
+
:where(.document-view) :where(pre code) {
|
|
177
|
+
padding: 0;
|
|
178
|
+
background: none;
|
|
179
|
+
font-size: inherit;
|
|
180
|
+
}
|
|
181
|
+
:where(.document-view) :where(blockquote) {
|
|
182
|
+
padding: 0.75rem 1rem;
|
|
183
|
+
border-radius: var(--radius-md);
|
|
184
|
+
background: var(--color-muted);
|
|
185
|
+
color: var(--color-muted-foreground);
|
|
186
|
+
}
|
|
187
|
+
:where(.document-view) :where(blockquote > :last-child) {
|
|
188
|
+
margin-bottom: 0;
|
|
189
|
+
}
|
|
190
|
+
:where(.document-view) :where(.callout) {
|
|
191
|
+
margin: 0 0 1rem;
|
|
192
|
+
padding: 0.75rem 1rem;
|
|
193
|
+
border: 1px solid var(--color-border);
|
|
194
|
+
border-radius: var(--radius-lg);
|
|
195
|
+
background: var(--color-card);
|
|
196
|
+
}
|
|
197
|
+
:where(.document-view) :where(table) {
|
|
198
|
+
display: block;
|
|
199
|
+
max-width: 100%;
|
|
200
|
+
overflow-x: auto;
|
|
201
|
+
border-collapse: collapse;
|
|
202
|
+
font-size: 0.875rem;
|
|
203
|
+
font-variant-numeric: tabular-nums;
|
|
204
|
+
}
|
|
205
|
+
:where(.document-view) :where(th, td) {
|
|
206
|
+
padding: 0.5rem 0.75rem;
|
|
207
|
+
border-bottom: 1px solid var(--color-border);
|
|
208
|
+
text-align: left;
|
|
209
|
+
vertical-align: top;
|
|
210
|
+
}
|
|
211
|
+
:where(.document-view) :where(th) {
|
|
212
|
+
font-weight: 600;
|
|
213
|
+
color: var(--color-muted-foreground);
|
|
214
|
+
}
|
|
215
|
+
:where(.document-view) :where(hr) {
|
|
216
|
+
margin: 2rem 0;
|
|
217
|
+
border: 0;
|
|
218
|
+
border-top: 1px solid var(--color-border);
|
|
219
|
+
}
|
|
220
|
+
:where(.document-view) :where(img) {
|
|
221
|
+
max-width: 100%;
|
|
222
|
+
height: auto;
|
|
223
|
+
border-radius: var(--radius-md);
|
|
224
|
+
}
|
|
225
|
+
@media (prefers-reduced-motion: reduce) {
|
|
226
|
+
:where(.document-view) :where(a) {
|
|
227
|
+
transition: none;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
</style>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Component, Snippet } from "svelte";
|
|
2
|
+
import type { DocHeading } from "./scroll-spy.svelte.js";
|
|
3
|
+
interface Props {
|
|
4
|
+
title: string;
|
|
5
|
+
/** Breadcrumb, as a reader would say it: "Marketing / Reports". */
|
|
6
|
+
section?: string;
|
|
7
|
+
/** Already worded and localised: "Updated 30 Jul 2026". */
|
|
8
|
+
updated?: string;
|
|
9
|
+
icon?: Component<{
|
|
10
|
+
class?: string;
|
|
11
|
+
weight?: "bold";
|
|
12
|
+
"aria-hidden"?: boolean | "true" | "false";
|
|
13
|
+
}>;
|
|
14
|
+
actions?: Snippet;
|
|
15
|
+
/** The body's h2s, anchored by id, for the "On this page" rail. */
|
|
16
|
+
headings?: DocHeading[];
|
|
17
|
+
contentsLabel: string;
|
|
18
|
+
/** The rendered body. Trusted HTML from the consumer's own parser, never user input. */
|
|
19
|
+
html: string;
|
|
20
|
+
/** Changes when another document is shown: the pane returns to the top and the body re-enters. */
|
|
21
|
+
resetKey: string;
|
|
22
|
+
measure?: "prose" | "wide";
|
|
23
|
+
/** Runs on the body after every swap (icons, diagrams). Returns its cleanup. */
|
|
24
|
+
enhance?: (root: HTMLElement) => void | (() => void);
|
|
25
|
+
class?: string;
|
|
26
|
+
}
|
|
27
|
+
declare const $$Component: Component<Props, {}, "">;
|
|
28
|
+
type $$Component = ReturnType<typeof $$Component>;
|
|
29
|
+
export default $$Component;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* A surface supplies a title and a body, and never lays a document out itself.
|
|
4
4
|
*/
|
|
5
5
|
export { default as DocPage } from "./doc-page.svelte";
|
|
6
|
+
export { default as DocumentView } from "./document-view.svelte";
|
|
6
7
|
export { default as OnThisPage } from "./on-this-page.svelte";
|
|
7
8
|
export {
|
|
8
9
|
ScrollSpy,
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { cn } from "../../lib/utils.js";
|
|
10
10
|
import { Button } from "../../shadcn/button/index.js";
|
|
11
11
|
import { toolbarTriggerClass } from "../toolbar/toolbar-trigger.js";
|
|
12
|
+
import { getToolbarDensity } from "./toolbar-density.js";
|
|
12
13
|
import CountChip from "../chip/count-chip.svelte";
|
|
13
14
|
import type { PhosphorIcon } from "../../lib/icon-types.js";
|
|
14
15
|
|
|
@@ -59,6 +60,10 @@
|
|
|
59
60
|
// =========================
|
|
60
61
|
let hasCount = $derived(count > 0);
|
|
61
62
|
let isActive = $derived(active ?? hasCount);
|
|
63
|
+
// In a list-page toolbar that ran out of room even with its tabs folded,
|
|
64
|
+
// the trigger keeps its icon and count and drops the word. See `ListPageShell`.
|
|
65
|
+
const density = getToolbarDensity();
|
|
66
|
+
let iconOnly = $derived(density?.tight === true);
|
|
62
67
|
|
|
63
68
|
// =========================
|
|
64
69
|
// STATE
|
|
@@ -80,9 +85,19 @@
|
|
|
80
85
|
<!-- MARKUP -->
|
|
81
86
|
<!-- =========================== -->
|
|
82
87
|
<DropdownMenu.Root bind:open>
|
|
83
|
-
<DropdownMenu.Trigger
|
|
88
|
+
<DropdownMenu.Trigger
|
|
89
|
+
class={toolbarTriggerClass({
|
|
90
|
+
active: isActive,
|
|
91
|
+
className: cn(
|
|
92
|
+
iconOnly && !hasCount && !trailing && "aspect-square justify-center px-0",
|
|
93
|
+
triggerClass,
|
|
94
|
+
),
|
|
95
|
+
})}
|
|
96
|
+
title={iconOnly ? label : undefined}
|
|
97
|
+
>
|
|
84
98
|
<IconComponent aria-hidden="true" class="size-3.5" />
|
|
85
|
-
|
|
99
|
+
<!-- Hidden, not dropped: the label stays the trigger's accessible name. -->
|
|
100
|
+
<span class={cn(iconOnly && "sr-only")}>{label}</span>
|
|
86
101
|
{#if trailing}
|
|
87
102
|
{@render trailing()}
|
|
88
103
|
{/if}
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// =========================
|
|
3
3
|
// IMPORTS
|
|
4
4
|
// =========================
|
|
5
|
-
import type
|
|
5
|
+
import { flushSync, tick, untrack, type Snippet } from "svelte";
|
|
6
|
+
import { MagnifyingGlass, X } from "phosphor-svelte";
|
|
7
|
+
import { toolbarTriggerClass } from "../toolbar/toolbar-trigger.js";
|
|
6
8
|
import EmptyState from "./empty-state.svelte";
|
|
7
9
|
import { cn } from "../../lib/utils.js";
|
|
8
10
|
import { pageGutterClass } from "../../lib/layout.js";
|
|
@@ -40,6 +42,11 @@
|
|
|
40
42
|
content?: Snippet;
|
|
41
43
|
empty?: Snippet;
|
|
42
44
|
extra?: Snippet;
|
|
45
|
+
/** The folded search icon's name, on a toolbar too narrow for the field.
|
|
46
|
+
* Translate at the call site. */
|
|
47
|
+
searchLabel?: string;
|
|
48
|
+
/** The close button's name once the folded search is open. */
|
|
49
|
+
closeSearchLabel?: string;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
// =========================
|
|
@@ -61,6 +68,8 @@
|
|
|
61
68
|
content,
|
|
62
69
|
empty,
|
|
63
70
|
extra,
|
|
71
|
+
searchLabel = "Search",
|
|
72
|
+
closeSearchLabel = "Close search",
|
|
64
73
|
}: Props = $props();
|
|
65
74
|
|
|
66
75
|
// =========================
|
|
@@ -69,16 +78,33 @@
|
|
|
69
78
|
// Null off an app shell, where the toolbar just has no section strip to draw.
|
|
70
79
|
const section = getSectionHost();
|
|
71
80
|
let row = $state<HTMLElement>();
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
81
|
+
// How far the row has folded to stay on one line. 0 is full size; 1 folds
|
|
82
|
+
// the tabs into a dropdown; 2 also drops the filter and display labels;
|
|
83
|
+
// 3 also turns search into an icon that opens the field across the row.
|
|
84
|
+
type Level = 0 | 1 | 2 | 3;
|
|
85
|
+
const DEEPEST: Level = 3;
|
|
86
|
+
let level = $state<Level>(0);
|
|
87
|
+
let searchOpen = $state(false);
|
|
88
|
+
let searchLayer = $state<HTMLElement>();
|
|
76
89
|
setToolbarDensity({
|
|
77
90
|
get compact() {
|
|
78
|
-
return
|
|
91
|
+
return level >= 1;
|
|
92
|
+
},
|
|
93
|
+
get tight() {
|
|
94
|
+
return level >= 2;
|
|
79
95
|
},
|
|
80
96
|
});
|
|
81
97
|
|
|
98
|
+
// =========================
|
|
99
|
+
// HANDLERS
|
|
100
|
+
// =========================
|
|
101
|
+
// Focus lands in the field, since opening it is asking to type.
|
|
102
|
+
async function openSearch() {
|
|
103
|
+
searchOpen = true;
|
|
104
|
+
await tick();
|
|
105
|
+
searchLayer?.querySelector<HTMLElement>("input, [contenteditable]")?.focus();
|
|
106
|
+
}
|
|
107
|
+
|
|
82
108
|
// =========================
|
|
83
109
|
// DERIVED
|
|
84
110
|
// =========================
|
|
@@ -101,22 +127,39 @@
|
|
|
101
127
|
if (!hasToolbar) return;
|
|
102
128
|
return section?.claim();
|
|
103
129
|
});
|
|
130
|
+
// Widened past the last fold, the field is back in the row, so the open
|
|
131
|
+
// layer has nothing left to cover.
|
|
132
|
+
$effect(() => {
|
|
133
|
+
if (level < DEEPEST) searchOpen = false;
|
|
134
|
+
});
|
|
104
135
|
// One row, never two (Ramzi, 9 Nov 2026): /tasks at 1500px put its status
|
|
105
136
|
// tabs and actions on a second line under search and filters.
|
|
106
|
-
//
|
|
107
|
-
//
|
|
137
|
+
// Then 620px and 420px still wrapped, the filters on a line of their own, so
|
|
138
|
+
// a second fold drops their labels (10 Nov 2026).
|
|
139
|
+
// WHY each level's width is remembered: a folded row only holds the smaller
|
|
140
|
+
// controls, and a probe of it can't say whether the larger ones would fit.
|
|
141
|
+
// The probe refreshes the current level's entry, so new counts or chips
|
|
142
|
+
// cost at most one extra step before the row settles.
|
|
108
143
|
$effect(() => {
|
|
109
144
|
if (!row) return;
|
|
110
145
|
const el = row;
|
|
111
|
-
|
|
146
|
+
const needs = [Infinity, Infinity, Infinity, Infinity];
|
|
112
147
|
let frame = 0;
|
|
148
|
+
// WHY settled in one pass with flushSync, not a frame per level: a phone
|
|
149
|
+
// load walked 0 to 3 over four frames, and axe read the squeezed search
|
|
150
|
+
// field in between (gallery, 390px).
|
|
113
151
|
const measure = () => {
|
|
114
152
|
frame = 0;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
153
|
+
for (let step = 0; step <= DEEPEST * 2; step++) {
|
|
154
|
+
const room = el.clientWidth + 0.5;
|
|
155
|
+
const at = untrack(() => level);
|
|
156
|
+
needs[at] = naturalRowWidth(el);
|
|
157
|
+
let next = at;
|
|
158
|
+
if (needs[at] > room && at < DEEPEST) next = (at + 1) as Level;
|
|
159
|
+
else if (at > 0 && needs[at - 1] <= room) next = (at - 1) as Level;
|
|
160
|
+
if (next === at) return;
|
|
161
|
+
flushSync(() => (level = next));
|
|
162
|
+
}
|
|
120
163
|
};
|
|
121
164
|
// A frame's delay folds a burst of resizes into one probe and keeps the
|
|
122
165
|
// observer from looping on its own state change.
|
|
@@ -160,68 +203,111 @@
|
|
|
160
203
|
{#if hasToolbar}
|
|
161
204
|
<!-- One row on every list page: search and filters left, then tabs,
|
|
162
205
|
view toggle and primary action right. -->
|
|
163
|
-
<!--
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
and clipped a 733px toolbar into a 538px column. -->
|
|
206
|
+
<!-- The row never wraps. Short of room it folds the tabs, then drops the
|
|
207
|
+
filter and display labels; past that, search and the filters strip
|
|
208
|
+
shrink and the strip scrolls. The actions never shrink. -->
|
|
167
209
|
<!-- Sticky, because the section tabs live here. `-mx-4 px-4` covers the
|
|
168
210
|
gutter, `-mt-4 py-4` pulls the top padding inside the sticky box. -->
|
|
211
|
+
<!-- `data-fold` lets a caller's own control fold too: a primary button
|
|
212
|
+
writes `group-data-[fold=3]/toolbar:sr-only` on its label. -->
|
|
169
213
|
<div
|
|
170
|
-
|
|
214
|
+
data-fold={level}
|
|
215
|
+
class="group/toolbar @container/toolbar sticky top-0 z-20 -mx-4 -mt-4 bg-card/95 px-4 py-4 backdrop-blur supports-[backdrop-filter]:bg-card/70"
|
|
171
216
|
>
|
|
172
|
-
<!--
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
compact && "@3xl/toolbar:flex-nowrap",
|
|
179
|
-
toolbarClass,
|
|
180
|
-
)}
|
|
181
|
-
>
|
|
182
|
-
<!-- The section tabs lead: which page comes before which slice of it.
|
|
183
|
-
One row replaces two rows that held four controls. -->
|
|
217
|
+
<!-- WHY the section strip sits outside the measured row: it's the nav
|
|
218
|
+
between pages, and under 768px it takes a line of its own above the
|
|
219
|
+
toolbar. Counted in the probe, it folded controls that had room. -->
|
|
220
|
+
<!-- A container query, not a breakpoint: an 834px iPad read as desktop
|
|
221
|
+
and clipped a 733px toolbar into a 538px column. -->
|
|
222
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
184
223
|
{#if section?.strip}
|
|
185
|
-
<SectionRuns
|
|
186
|
-
strip={section.strip}
|
|
187
|
-
class="order-first w-full max-w-full @3xl/toolbar:w-auto"
|
|
188
|
-
/>
|
|
189
|
-
{/if}
|
|
190
|
-
{#if search}
|
|
191
|
-
<div class="order-1 flex min-w-0 flex-1 items-center @3xl/toolbar:flex-none">
|
|
192
|
-
{@render search()}
|
|
193
|
-
</div>
|
|
224
|
+
<SectionRuns strip={section.strip} class="w-full max-w-full @3xl/toolbar:w-auto" />
|
|
194
225
|
{/if}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
226
|
+
<div
|
|
227
|
+
bind:this={row}
|
|
228
|
+
class={cn(
|
|
229
|
+
"relative flex min-w-0 flex-1 flex-nowrap items-center",
|
|
230
|
+
// Icon triggers sit closer: at 390px the last fold was 4px over.
|
|
231
|
+
level >= 2 ? "gap-1.5" : "gap-2",
|
|
232
|
+
toolbarClass,
|
|
233
|
+
)}
|
|
234
|
+
>
|
|
235
|
+
{#if search && level < DEEPEST}
|
|
236
|
+
<div class="flex min-w-0 shrink items-center">
|
|
237
|
+
{@render search()}
|
|
238
|
+
</div>
|
|
239
|
+
{:else if search && !searchOpen}
|
|
240
|
+
<!-- WHY a fold and not a squeeze: at 390px the field shrank under
|
|
241
|
+
axe's 24px target size and showed two letters of its placeholder. -->
|
|
242
|
+
<button
|
|
243
|
+
type="button"
|
|
244
|
+
class={toolbarTriggerClass({
|
|
245
|
+
className: "aspect-square shrink-0 justify-center px-0",
|
|
246
|
+
})}
|
|
247
|
+
aria-label={searchLabel}
|
|
248
|
+
title={searchLabel}
|
|
249
|
+
onclick={openSearch}
|
|
250
|
+
>
|
|
251
|
+
<MagnifyingGlass aria-hidden="true" class="size-3.5" />
|
|
252
|
+
</button>
|
|
253
|
+
{:else if search}
|
|
254
|
+
<!-- Open, the field covers the whole row; Escape or the close
|
|
255
|
+
button hands the row back, with the query kept. -->
|
|
256
|
+
<div
|
|
257
|
+
bind:this={searchLayer}
|
|
258
|
+
role="search"
|
|
259
|
+
class="absolute inset-0 z-10 flex items-center gap-2 bg-card"
|
|
260
|
+
onkeydown={(event) => {
|
|
261
|
+
if (event.key === "Escape") searchOpen = false;
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
<div class="flex min-w-0 flex-1 items-center [&>*]:w-full [&>*]:max-w-none">
|
|
265
|
+
{@render search()}
|
|
211
266
|
</div>
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
class={toolbarTriggerClass({
|
|
270
|
+
className: "aspect-square shrink-0 justify-center px-0",
|
|
271
|
+
})}
|
|
272
|
+
aria-label={closeSearchLabel}
|
|
273
|
+
onclick={() => (searchOpen = false)}
|
|
274
|
+
>
|
|
275
|
+
<X aria-hidden="true" class="size-3.5" />
|
|
276
|
+
</button>
|
|
277
|
+
</div>
|
|
278
|
+
{/if}
|
|
279
|
+
{#if filters}
|
|
280
|
+
<!-- Tight, the strip stops shrinking: squeezed under its icon
|
|
281
|
+
trigger, the trigger slid beneath the tabs (420px). Its
|
|
282
|
+
chips scroll inside a capped width instead. -->
|
|
283
|
+
<div
|
|
284
|
+
class={cn(
|
|
285
|
+
"no-scrollbar flex min-w-0 items-center gap-2 overflow-x-auto",
|
|
286
|
+
level >= 2 ? "max-w-40 shrink-0" : "shrink",
|
|
287
|
+
)}
|
|
288
|
+
>
|
|
289
|
+
{@render filters()}
|
|
290
|
+
</div>
|
|
291
|
+
{/if}
|
|
292
|
+
{#if tabs || rightActions}
|
|
293
|
+
<div
|
|
294
|
+
class={cn(
|
|
295
|
+
"flex shrink-0 items-center",
|
|
296
|
+
level >= 2 ? "gap-1.5" : "gap-2",
|
|
297
|
+
hasLeading ? "ms-auto" : "flex-1",
|
|
298
|
+
)}
|
|
299
|
+
>
|
|
300
|
+
{#if tabs}
|
|
301
|
+
<div class={cn("flex min-w-0 items-center", !hasLeading && "me-auto")}>
|
|
302
|
+
{@render tabs()}
|
|
303
|
+
</div>
|
|
304
|
+
{/if}
|
|
305
|
+
{#if rightActions}
|
|
306
|
+
{@render rightActions()}
|
|
307
|
+
{/if}
|
|
308
|
+
</div>
|
|
309
|
+
{/if}
|
|
310
|
+
</div>
|
|
225
311
|
</div>
|
|
226
312
|
</div>
|
|
227
313
|
{/if}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Snippet } from "svelte";
|
|
2
2
|
import type { PhosphorIcon, IconWeight } from "../../lib/icon-types.js";
|
|
3
3
|
type EmptyConfig = {
|
|
4
4
|
icon?: PhosphorIcon;
|
|
@@ -25,6 +25,11 @@ interface Props {
|
|
|
25
25
|
content?: Snippet;
|
|
26
26
|
empty?: Snippet;
|
|
27
27
|
extra?: Snippet;
|
|
28
|
+
/** The folded search icon's name, on a toolbar too narrow for the field.
|
|
29
|
+
* Translate at the call site. */
|
|
30
|
+
searchLabel?: string;
|
|
31
|
+
/** The close button's name once the folded search is open. */
|
|
32
|
+
closeSearchLabel?: string;
|
|
28
33
|
}
|
|
29
34
|
declare const $$Component: import("svelte").Component<Props, {}, "">;
|
|
30
35
|
type $$Component = ReturnType<typeof $$Component>;
|
|
@@ -5,7 +5,11 @@ import { getContext, setContext } from "svelte";
|
|
|
5
5
|
* its row and sets this; a control inside it reads it and draws a smaller self.
|
|
6
6
|
*/
|
|
7
7
|
export interface ToolbarDensity {
|
|
8
|
+
/** The tabs fold into one dropdown. */
|
|
8
9
|
readonly compact: boolean;
|
|
10
|
+
/** Folded and still short: a filter or display trigger drops its label and
|
|
11
|
+
* keeps the icon, so the row never wraps (TSK-2121, 420px). Implies compact. */
|
|
12
|
+
readonly tight: boolean;
|
|
9
13
|
}
|
|
10
14
|
|
|
11
15
|
// WHY context: the shell owns the measurement and 26 pages render their tabs
|
|
@@ -27,12 +31,31 @@ export function getToolbarDensity(): ToolbarDensity | undefined {
|
|
|
27
31
|
*/
|
|
28
32
|
// WHY `max-content` on a probe, not the row's own width: the row is sized by
|
|
29
33
|
// its container, so reading it back always says "it fits" (see collapse.ts).
|
|
34
|
+
// WHY `flex: none` too: a row that is itself a flex item takes its width from
|
|
35
|
+
// flex-basis, so `max-content` alone read back the container (10 Nov 2026).
|
|
36
|
+
// WHY transitions are stopped: under reduced motion `tokens/base.css` gives every
|
|
37
|
+
// element an 80ms transition on `all`, so the probe's own width change started a
|
|
38
|
+
// transition and the read got the old width. The row never folded at 390px and
|
|
39
|
+
// axe flagged the squeezed search field (gallery, 10 Nov 2026). The last fold's
|
|
40
|
+
// gap and shrink changes are still running then too, so they're finished first.
|
|
30
41
|
export function naturalRowWidth(row: HTMLElement): number {
|
|
31
|
-
const {
|
|
42
|
+
for (const animation of row.getAnimations({ subtree: true })) {
|
|
43
|
+
if (animation instanceof CSSTransition) animation.finish();
|
|
44
|
+
}
|
|
45
|
+
const { width, flexWrap, flex } = row.style;
|
|
46
|
+
const transition = row.style.getPropertyValue("transition");
|
|
47
|
+
const priority = row.style.getPropertyPriority("transition");
|
|
48
|
+
row.style.setProperty("transition", "none", "important");
|
|
32
49
|
row.style.width = "max-content";
|
|
33
50
|
row.style.flexWrap = "nowrap";
|
|
51
|
+
row.style.flex = "none";
|
|
34
52
|
const needed = row.getBoundingClientRect().width;
|
|
35
53
|
row.style.width = width;
|
|
36
54
|
row.style.flexWrap = flexWrap;
|
|
55
|
+
row.style.flex = flex;
|
|
56
|
+
// The restored size has to settle before transitions come back, or the
|
|
57
|
+
// row would animate back from its probe width.
|
|
58
|
+
void row.offsetWidth;
|
|
59
|
+
row.style.setProperty("transition", transition, priority);
|
|
37
60
|
return needed;
|
|
38
61
|
}
|