@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.
@@ -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 { width, flexWrap } = row.style;
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.0",
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
- <table class="hr-sr-only">
86
- <caption>{label}</caption>
87
- <thead>
88
- <tr>
89
- <th scope="col">{table.xLabel}</th>
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="row">{row.x}</th>
99
- {#each row.cells as cell, j (j)}
100
- <td>{cell}</td>
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
- {/each}
104
- </tbody>
105
- </table>
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 class={toolbarTriggerClass({ active: isActive, className: triggerClass })}>
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
- <span>{label}</span>
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 { Snippet } from "svelte";
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
- let tabsBox = $state<HTMLElement>();
73
- // True when the row at full size would wrap. The tabs then draw as one
74
- // dropdown, the first control with a smaller self that still does its job.
75
- let compact = $state(false);
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 compact;
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
- // WHY the tabs' full width is remembered: once folded, the row only holds the
107
- // dropdown, and a probe of it would say the full strip fits when it doesn't.
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
- let fullTabs = 0;
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
- const needed = naturalRowWidth(el);
116
- const tabs = tabsBox?.getBoundingClientRect().width ?? 0;
117
- if (!compact) fullTabs = tabs;
118
- const full = compact ? needed - tabs + fullTabs : needed;
119
- compact = full > el.clientWidth + 0.5;
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
- <!-- Without room, `order` keeps the actions beside search and drops
164
- filters to a scrolling line below. flex-wrap stays on at every width. -->
165
- <!-- A container query, not a breakpoint: an 834px iPad read as desktop
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
- class="@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"
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
- <!-- Folded and still short of room, the row stops wrapping and the
173
- filters strip scrolls, since it's the one part that already can. -->
174
- <div
175
- bind:this={row}
176
- class={cn(
177
- "flex flex-wrap items-center gap-2",
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
- {#if tabs || rightActions}
196
- <!-- Shrinks and wraps, not shrink-0: /tasks needs 610px here, and
197
- shrink-0 clipped its last control off an iPad screen. -->
198
- <div
199
- class={cn(
200
- "order-2 flex min-w-0 flex-wrap items-center gap-2 @3xl/toolbar:order-3",
201
- hasLeading ? "@3xl/toolbar:ms-auto" : "flex-1",
202
- compact && "@3xl/toolbar:shrink-0",
203
- )}
204
- >
205
- {#if tabs}
206
- <div
207
- bind:this={tabsBox}
208
- class={cn("flex min-w-0 items-center", !hasLeading && "me-auto")}
209
- >
210
- {@render tabs()}
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
- {/if}
213
- {#if rightActions}
214
- {@render rightActions()}
215
- {/if}
216
- </div>
217
- {/if}
218
- {#if filters}
219
- <div
220
- class="no-scrollbar order-3 flex w-full max-w-full min-w-0 items-center gap-2 overflow-x-auto @3xl/toolbar:order-2 @3xl/toolbar:w-auto"
221
- >
222
- {@render filters()}
223
- </div>
224
- {/if}
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 { Snippet } from "svelte";
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 { width, flexWrap } = row.style;
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
  }