@lyeve-labs/ui-kit 0.23.3 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +42 -0
  2. package/dist/components/AccordionItem.svelte +3 -5
  3. package/dist/components/AccountMenu.svelte +4 -3
  4. package/dist/components/Alert.svelte +2 -1
  5. package/dist/components/AppShell.svelte +166 -54
  6. package/dist/components/AppShell.svelte.d.ts +41 -6
  7. package/dist/components/Autocomplete.svelte +3 -2
  8. package/dist/components/Badge.svelte +10 -5
  9. package/dist/components/Banner.svelte +2 -1
  10. package/dist/components/Breadcrumb.svelte +7 -2
  11. package/dist/components/Button.svelte +15 -2
  12. package/dist/components/Card.svelte +1 -1
  13. package/dist/components/Collapsible.svelte +5 -4
  14. package/dist/components/CopyButton.svelte +2 -1
  15. package/dist/components/DatePicker.svelte +77 -62
  16. package/dist/components/Drawer.svelte +6 -5
  17. package/dist/components/Dropdown.svelte +5 -2
  18. package/dist/components/FileInput.svelte +1 -1
  19. package/dist/components/Modal.svelte +6 -3
  20. package/dist/components/MultiSelect.svelte +8 -6
  21. package/dist/components/NumberInput.svelte +2 -2
  22. package/dist/components/PageHeader.svelte +13 -1
  23. package/dist/components/PageShell.svelte +62 -7
  24. package/dist/components/PageShell.svelte.d.ts +27 -1
  25. package/dist/components/Pagination.svelte +7 -2
  26. package/dist/components/PasswordInput.svelte +2 -1
  27. package/dist/components/Progress.svelte +1 -1
  28. package/dist/components/SearchInput.svelte +3 -2
  29. package/dist/components/SectionHeading.svelte +10 -1
  30. package/dist/components/SegmentedControl.svelte +101 -27
  31. package/dist/components/SegmentedControl.svelte.d.ts +9 -0
  32. package/dist/components/Select.svelte +4 -3
  33. package/dist/components/SidebarNav.svelte +26 -17
  34. package/dist/components/StepIndicator.svelte +7 -9
  35. package/dist/components/Table.svelte +39 -29
  36. package/dist/components/Tabs.svelte +128 -24
  37. package/dist/components/Tag.svelte +2 -1
  38. package/dist/components/ThemeToggle.svelte +2 -1
  39. package/dist/components/Toaster.svelte +6 -2
  40. package/dist/components/Toggle.svelte +23 -5
  41. package/dist/components/Tooltip.svelte +10 -5
  42. package/dist/components/TreeView.svelte +2 -2
  43. package/dist/components/dialog/Dialog.svelte +15 -32
  44. package/dist/index.d.ts +4 -1
  45. package/dist/index.js +7 -1
  46. package/dist/internal/choice.js +10 -3
  47. package/dist/internal/field.js +3 -3
  48. package/dist/internal/layout.d.ts +10 -1
  49. package/dist/internal/layout.js +10 -1
  50. package/dist/internal/panel.d.ts +13 -3
  51. package/dist/internal/panel.js +16 -5
  52. package/dist/internal/scroll-edges.d.ts +33 -0
  53. package/dist/internal/scroll-edges.js +33 -0
  54. package/dist/internal/touch.d.ts +28 -0
  55. package/dist/internal/touch.js +28 -0
  56. package/dist/motion.d.ts +63 -0
  57. package/dist/motion.js +165 -0
  58. package/dist/styles/theme.css +131 -63
  59. package/package.json +1 -1
  60. package/src/lib/styles/theme.css +131 -63
@@ -13,6 +13,7 @@
13
13
  * items is open, and an item asks it; this decides nothing beyond itself.
14
14
  */
15
15
  import type { Component, Snippet } from 'svelte';
16
+ import { TOUCH_GROW } from '../internal/touch.js';
16
17
 
17
18
  interface Props {
18
19
  open?: boolean;
@@ -70,8 +71,8 @@
70
71
  aria-expanded={open}
71
72
  aria-controls={panelId}
72
73
  onclick={toggle}
73
- class="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-start text-sm font-medium
74
- text-fg transition-colors duration-150 outline-none focus-visible:ring-2
74
+ class="{TOUCH_GROW} flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-start text-sm font-medium
75
+ text-fg transition-colors outline-none focus-visible:ring-2
75
76
  focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed
76
77
  disabled:opacity-50 {disabled ? '' : 'hover:bg-surface-2'}"
77
78
  >
@@ -86,7 +87,7 @@
86
87
  stroke-width="2"
87
88
  stroke-linecap="round"
88
89
  stroke-linejoin="round"
89
- class="shrink-0 text-faint transition-transform duration-200 ease-out {open
90
+ class="shrink-0 text-faint transition-transform duration-base {open
90
91
  ? ''
91
92
  : '-rotate-90 rtl:rotate-90'}"
92
93
  aria-hidden="true"
@@ -116,7 +117,7 @@
116
117
  clipped rather than spilling while the row grows.
117
118
  -->
118
119
  <div
119
- class="grid transition-[grid-template-rows] duration-[var(--duration-collapse)] ease-out {open
120
+ class="grid transition-[grid-template-rows] duration-base {open
120
121
  ? 'grid-rows-[1fr]'
121
122
  : 'grid-rows-[0fr]'}"
122
123
  >
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { Check, Copy } from '@lucide/svelte';
3
+ import { HIT_AREA } from '../internal/touch.js';
3
4
 
4
5
  interface Props {
5
6
  /** The text written to the clipboard. */
@@ -105,7 +106,7 @@
105
106
  type="button"
106
107
  aria-label={label}
107
108
  onclick={copy}
108
- class="inline-flex items-center justify-center rounded-md p-1 outline-none transition-colors duration-150 active:bg-surface-2 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand {status ===
109
+ class="{HIT_AREA} inline-flex items-center justify-center rounded-md p-1 outline-none transition-colors active:bg-surface-2 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand {status ===
109
110
  'copied'
110
111
  ? 'text-success'
111
112
  : 'text-faint hover:text-fg'}"
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { HIT_AREA } from '../internal/touch.js';
2
3
  import {
3
4
  CONTROL_BASE,
4
5
  FIELD_ERROR,
@@ -8,6 +9,8 @@
8
9
  controlBorder,
9
10
  describedBy,
10
11
  } from '../internal/field.js';
12
+ import { PANEL_LIST_UNCAPPED, PANEL_SURFACE, placePanel } from '../internal/panel.js';
13
+ import * as motion from '../motion.js';
11
14
  // DatePicker: a text trigger + calendar popover. Binds `value` to an ISO date
12
15
  // string ("YYYY-MM-DD"). Pure local-date math (no timezone surprises).
13
16
  let {
@@ -237,80 +240,92 @@
237
240
  dialog with no name is announced as "dialog" and nothing else. The name
238
241
  is fixed rather than built from the field label, so a reader hears what
239
242
  the popup does instead of the label they just heard.
243
+
244
+ The surface is the shared panel, placed by placePanel: the calendar sat
245
+ under the trigger unconditionally, so inside a modal body it opened into
246
+ the part of the scroll region nobody could reach and was cut below the
247
+ weekday row. The width stays here, sized to seven columns. The region
248
+ inside is capped by the room and not by the list token, which is shorter
249
+ than six weeks of days, so a short modal scrolls the calendar rather
250
+ than cutting it.
240
251
  -->
241
252
  <div
242
253
  id={calendarId}
243
254
  role="dialog"
244
255
  aria-label="Choose date"
245
- class="absolute z-dropdown mt-1 w-[17rem] rounded-xl border border-line bg-surface shadow-2xl p-3"
256
+ use:placePanel
257
+ transition:motion.popover
258
+ class="{PANEL_SURFACE} w-68"
246
259
  >
247
- <!-- Header -->
248
- <div class="flex items-center justify-between mb-2">
249
- <button
250
- type="button"
251
- aria-label="Previous month"
252
- onclick={prevMonth}
253
- class="p-1.5 rounded-md text-muted hover:bg-surface-2 hover:text-fg transition-colors duration-150"
254
- >
255
- <svg width="14" height="14" viewBox="0 0 12 12" fill="none"
256
- ><path
257
- d="M7.5 2L4 6l3.5 4"
258
- stroke="currentColor"
259
- stroke-width="1.5"
260
- stroke-linecap="round"
261
- stroke-linejoin="round"
262
- /></svg
260
+ <div class="overflow-y-auto overscroll-contain p-3" data-panel-list={PANEL_LIST_UNCAPPED}>
261
+ <!-- Header -->
262
+ <div class="flex items-center justify-between mb-2">
263
+ <button
264
+ type="button"
265
+ aria-label="Previous month"
266
+ onclick={prevMonth}
267
+ class="{HIT_AREA} p-1.5 rounded-md text-muted hover:bg-surface-2 hover:text-fg transition-colors"
263
268
  >
264
- </button>
265
- <span class="text-sm font-medium text-fg">{MONTHS[viewMonth - 1]} {viewYear}</span>
266
- <button
267
- type="button"
268
- aria-label="Next month"
269
- onclick={nextMonth}
270
- class="p-1.5 rounded-md text-muted hover:bg-surface-2 hover:text-fg transition-colors duration-150"
271
- >
272
- <svg width="14" height="14" viewBox="0 0 12 12" fill="none"
273
- ><path
274
- d="M4.5 2L8 6l-3.5 4"
275
- stroke="currentColor"
276
- stroke-width="1.5"
277
- stroke-linecap="round"
278
- stroke-linejoin="round"
279
- /></svg
269
+ <svg width="14" height="14" viewBox="0 0 12 12" fill="none"
270
+ ><path
271
+ d="M7.5 2L4 6l3.5 4"
272
+ stroke="currentColor"
273
+ stroke-width="1.5"
274
+ stroke-linecap="round"
275
+ stroke-linejoin="round"
276
+ /></svg
277
+ >
278
+ </button>
279
+ <span class="text-sm font-medium text-fg">{MONTHS[viewMonth - 1]} {viewYear}</span>
280
+ <button
281
+ type="button"
282
+ aria-label="Next month"
283
+ onclick={nextMonth}
284
+ class="{HIT_AREA} p-1.5 rounded-md text-muted hover:bg-surface-2 hover:text-fg transition-colors"
280
285
  >
281
- </button>
282
- </div>
286
+ <svg width="14" height="14" viewBox="0 0 12 12" fill="none"
287
+ ><path
288
+ d="M4.5 2L8 6l-3.5 4"
289
+ stroke="currentColor"
290
+ stroke-width="1.5"
291
+ stroke-linecap="round"
292
+ stroke-linejoin="round"
293
+ /></svg
294
+ >
295
+ </button>
296
+ </div>
283
297
 
284
- <!-- Weekday labels -->
285
- <div class="grid grid-cols-7 mb-1">
286
- {#each WEEKDAYS as wd}
287
- <span class="text-center text-[0.65rem] font-medium text-faint py-1">{wd}</span>
288
- {/each}
289
- </div>
298
+ <!-- Weekday labels -->
299
+ <div class="grid grid-cols-7 mb-1">
300
+ {#each WEEKDAYS as wd}
301
+ <span class="text-center text-[0.65rem] font-medium text-faint py-1">{wd}</span>
302
+ {/each}
303
+ </div>
290
304
 
291
- <!-- Day grid -->
292
- <div class="grid grid-cols-7 gap-0.5">
293
- {#each cells as d}
294
- {#if d === null}
295
- <span></span>
296
- {:else}
297
- {@const iso = toISO(viewYear, viewMonth, d)}
298
- <button
299
- type="button"
300
- disabled={isDisabled(d)}
301
- onclick={() => pick(d)}
302
- aria-current={iso === todayISO ? 'date' : undefined}
303
- aria-label={iso}
304
- class="h-8 w-8 mx-auto flex items-center justify-center rounded-md text-sm transition-colors duration-150
305
+ <!-- Day grid -->
306
+ <div class="grid grid-cols-7 gap-0.5">
307
+ {#each cells as d}
308
+ {#if d === null}
309
+ <span></span>
310
+ {:else}
311
+ {@const iso = toISO(viewYear, viewMonth, d)}
312
+ <button
313
+ type="button"
314
+ disabled={isDisabled(d)}
315
+ onclick={() => pick(d)}
316
+ aria-current={iso === todayISO ? 'date' : undefined}
317
+ aria-label={iso}
318
+ class="{HIT_AREA} h-8 w-8 mx-auto flex items-center justify-center rounded-md text-sm transition-colors
305
319
  disabled:opacity-30 disabled:cursor-not-allowed
306
320
  {iso === value
307
- ? 'bg-brand text-ink font-medium'
308
- : iso === todayISO
309
- ? 'text-brand font-medium hover:bg-surface-2'
310
- : 'text-fg hover:bg-surface-2'}">{d}</button
311
- >
312
- {/if}
313
- {/each}
321
+ ? 'bg-brand text-ink font-medium'
322
+ : iso === todayISO
323
+ ? 'text-brand font-medium hover:bg-surface-2'
324
+ : 'text-fg hover:bg-surface-2'}">{d}</button
325
+ >
326
+ {/if}
327
+ {/each}
328
+ </div>
314
329
  </div>
315
330
  </div>
316
331
  {/if}
@@ -1,6 +1,8 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { HIT_AREA } from '../internal/touch.js';
3
4
  import { overlay } from '../internal/overlay.js';
5
+ import * as motion from '../motion.js';
4
6
 
5
7
  type Side = 'left' | 'right';
6
8
  type Size = 'sm' | 'md' | 'lg' | 'xl';
@@ -63,15 +65,14 @@
63
65
  aria-hidden="true"
64
66
  class="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-default"
65
67
  onclick={close}
68
+ transition:motion.scrim
66
69
  ></button>
67
70
 
68
71
  <div
69
72
  use:overlay
73
+ transition:motion.drawer={{ side }}
70
74
  class="relative flex h-full max-w-full flex-col {widths[size]} bg-surface shadow-2xl
71
- {side === 'right' ? 'border-s' : 'border-e'} border-line
72
- {side === 'right'
73
- ? 'animate-[drawer-in-right_var(--duration-drawer-in)_ease-out]'
74
- : 'animate-[drawer-in-left_var(--duration-drawer-in)_ease-out]'}"
75
+ {side === 'right' ? 'border-s' : 'border-e'} border-line"
75
76
  role="dialog"
76
77
  aria-modal="true"
77
78
  aria-labelledby={title ? headingId : undefined}
@@ -89,7 +90,7 @@
89
90
  <button
90
91
  type="button"
91
92
  onclick={close}
92
- class="-mt-0.5 text-xl leading-none text-faint transition-colors duration-150 hover:text-fg"
93
+ class="{HIT_AREA} -mt-0.5 text-xl leading-none text-faint transition-colors hover:text-fg"
93
94
  aria-label="Close"
94
95
  >
95
96
  <svg
@@ -12,7 +12,9 @@
12
12
  * and Escape and Tab both close and hand focus back to the trigger.
13
13
  */
14
14
  import type { Component, Snippet } from 'svelte';
15
+ import { TOUCH_GROW } from '../internal/touch.js';
15
16
  import { PANEL_LIST, PANEL_SURFACE, placePanel } from '../internal/panel.js';
17
+ import * as motion from '../motion.js';
16
18
 
17
19
  interface DropdownItem {
18
20
  label: string;
@@ -254,6 +256,7 @@
254
256
  bind:this={menuEl}
255
257
  role="menu"
256
258
  use:placePanel
259
+ transition:motion.popover
257
260
  class="{PANEL_SURFACE} min-w-36 {align === 'right' ? 'end-0' : 'start-0'}"
258
261
  >
259
262
  <div class={PANEL_LIST} data-panel-list>
@@ -264,8 +267,8 @@
264
267
  disabled={item.disabled}
265
268
  tabindex={index === active ? 0 : -1}
266
269
  onclick={() => handleItemClick(item)}
267
- class="flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-sm transition-colors
268
- duration-150 outline-none focus-visible:ring-2 focus-visible:ring-inset
270
+ class="{TOUCH_GROW} flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-sm transition-colors
271
+ outline-none focus-visible:ring-2 focus-visible:ring-inset
269
272
  focus-visible:ring-brand disabled:cursor-not-allowed disabled:opacity-40
270
273
  {item.variant === 'danger' ? 'text-danger hover:bg-danger/10' : 'hover:bg-surface-2'}"
271
274
  >
@@ -57,7 +57,7 @@
57
57
  half alpha reads as a weaker affordance for no reason a user can infer. -->
58
58
  <label
59
59
  class="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-xl
60
- border-2 border-dashed px-4 py-8 text-center transition-colors duration-150
60
+ border-2 border-dashed px-4 py-8 text-center transition-colors
61
61
  {error
62
62
  ? 'border-danger bg-danger/5 hover:bg-danger/8'
63
63
  : dragOver
@@ -1,6 +1,8 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { HIT_AREA } from '../internal/touch.js';
3
4
  import { overlay } from '../internal/overlay.js';
5
+ import * as motion from '../motion.js';
4
6
 
5
7
  type Size = 'sm' | 'md' | 'lg';
6
8
 
@@ -58,13 +60,14 @@
58
60
  aria-hidden="true"
59
61
  class="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-default"
60
62
  onclick={close}
63
+ transition:motion.scrim
61
64
  ></button>
62
65
 
63
66
  <div
64
67
  use:overlay
68
+ transition:motion.dialog
65
69
  class="relative flex max-h-[calc(100dvh-2rem)] w-full {widths[size]} flex-col
66
- overflow-hidden rounded-xl border border-line bg-surface shadow-2xl
67
- animate-[modal-in_var(--duration-modal-in)_ease-out]"
70
+ overflow-hidden rounded-xl border border-line bg-surface shadow-2xl"
68
71
  role="dialog"
69
72
  aria-modal="true"
70
73
  aria-labelledby={title ? headingId : undefined}
@@ -82,7 +85,7 @@
82
85
  <button
83
86
  type="button"
84
87
  onclick={close}
85
- class="shrink-0 rounded text-faint transition-colors duration-150 hover:text-fg
88
+ class="{HIT_AREA} shrink-0 rounded text-faint transition-colors hover:text-fg
86
89
  outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
87
90
  aria-label="Close"
88
91
  >
@@ -9,6 +9,7 @@
9
9
  * reason the factory reports a selection rather than closing on one.
10
10
  */
11
11
  import { applyFilter, type FilterInput } from '../internal/filter.js';
12
+ import { HIT_AREA } from '../internal/touch.js';
12
13
  import {
13
14
  FIELD_ERROR,
14
15
  FIELD_HINT,
@@ -25,6 +26,7 @@
25
26
  panelOption,
26
27
  placePanel,
27
28
  } from '../internal/panel.js';
29
+ import * as motion from '../motion.js';
28
30
  import type { ListOption } from './Autocomplete.svelte';
29
31
 
30
32
  interface Props {
@@ -187,7 +189,7 @@
187
189
  onkeydown={onTriggerKeydown}
188
190
  {...box.triggerAttrs}
189
191
  class="flex min-h-control w-full cursor-pointer flex-wrap items-center gap-1.5 rounded-lg
190
- border bg-surface-2 px-2.5 py-1.5 text-start text-sm transition-colors duration-150
192
+ border bg-surface-2 px-2.5 py-1.5 text-start text-sm transition-colors
191
193
  outline-none {disabled ? 'cursor-not-allowed opacity-50' : ''} {controlBorder(!!error)}"
192
194
  >
193
195
  {#if selected.length === 0}
@@ -205,7 +207,7 @@
205
207
  event.stopPropagation();
206
208
  remove(option.value);
207
209
  }}
208
- class="-m-1.5 p-1.5 transition-colors duration-150 hover:text-brand-light"
210
+ class="{HIT_AREA} -m-1.5 p-1.5 transition-colors hover:text-brand-light"
209
211
  >
210
212
  <!--
211
213
  12px inside 6px of padding is a 24px target, which is the floor
@@ -238,7 +240,7 @@
238
240
  height="12"
239
241
  viewBox="0 0 12 12"
240
242
  fill="none"
241
- class="transition-transform duration-150 {box.open ? 'rotate-180' : ''}"
243
+ class="transition-transform duration-base {box.open ? 'rotate-180' : ''}"
242
244
  >
243
245
  <path
244
246
  d="M2 4l4 4 4-4"
@@ -252,7 +254,7 @@
252
254
  </div>
253
255
 
254
256
  {#if box.open}
255
- <div use:placePanel class="{PANEL_SURFACE} w-full">
257
+ <div use:placePanel transition:motion.popover class="{PANEL_SURFACE} w-full">
256
258
  {#if searchable}
257
259
  <div class="border-b border-line p-2">
258
260
  <input
@@ -268,7 +270,7 @@
268
270
  aria-label={label ? `Search ${label}` : 'Search options'}
269
271
  {...box.triggerAttrs}
270
272
  class="w-full rounded-md border border-line-strong bg-surface-2 px-2.5 py-1.5
271
- text-sm text-fg outline-none transition-colors duration-150
273
+ text-sm text-fg outline-none transition-colors
272
274
  placeholder:text-faint focus:border-brand"
273
275
  />
274
276
  </div>
@@ -310,7 +312,7 @@
310
312
  >
311
313
  <span
312
314
  class="flex h-4 w-4 shrink-0 items-center justify-center rounded border
313
- transition-colors duration-150
315
+ transition-colors
314
316
  {isSelected
315
317
  ? 'border-brand bg-brand text-ink'
316
318
  : 'border-line-strong bg-surface-2'}"
@@ -72,7 +72,7 @@
72
72
  // the part a low-vision user could not find.
73
73
  const step_ =
74
74
  'flex w-control shrink-0 items-center justify-center border-line-strong bg-surface-2 text-muted ' +
75
- 'transition-colors duration-150 hover:bg-line hover:text-fg ' +
75
+ 'transition-colors hover:bg-line hover:text-fg ' +
76
76
  'disabled:opacity-50 disabled:cursor-not-allowed';
77
77
  </script>
78
78
 
@@ -119,7 +119,7 @@
119
119
  aria-invalid={error ? 'true' : undefined}
120
120
  aria-describedby={describedBy(fieldId, error, hint)}
121
121
  class="min-w-0 flex-1 border-y bg-surface-2 text-center text-sm text-fg
122
- outline-none transition-colors duration-150
122
+ outline-none transition-colors
123
123
  disabled:cursor-not-allowed disabled:opacity-50
124
124
  [appearance:textfield]
125
125
  [&::-webkit-inner-spin-button]:appearance-none
@@ -73,7 +73,19 @@
73
73
  </h1>
74
74
  {#if description && !compact}<p class="mt-1 text-sm text-muted">{description}</p>{/if}
75
75
  </div>
76
+ <!-- The slot wraps in two steps. It sits beside the title while both fit
77
+ and drops under it, at the end edge, when they do not; wider than the
78
+ row on its own, it wraps its controls inside itself, each line ending at
79
+ the end edge, in the order they were given. It used to be `shrink-0`,
80
+ which sizes a flex item to its content and so cannot wrap even when the
81
+ page puts a wrapping row inside it: four actions pushed the primary one
82
+ 83px past a 400px screen. -->
76
83
  {#if actions}
77
- <div class="flex shrink-0 items-center gap-2">{@render actions()}</div>
84
+ <div
85
+ data-testid="page-actions"
86
+ class="ms-auto flex min-w-0 flex-wrap items-center justify-end gap-2"
87
+ >
88
+ {@render actions()}
89
+ </div>
78
90
  {/if}
79
91
  </header>
@@ -15,7 +15,10 @@
15
15
  * matches for every assistive technology reading the page.
16
16
  */
17
17
  import type { Snippet } from 'svelte';
18
+ import { ArrowLeft } from '@lucide/svelte';
19
+ import { safeHref } from '../internal/href.js';
18
20
  import { PAGE_PAD, PAGE_STACK, PAGE_WIDTH, type PageWidth } from '../internal/layout.js';
21
+ import { HIT_AREA } from '../internal/touch.js';
19
22
  import PageHeader from './PageHeader.svelte';
20
23
 
21
24
  interface Props {
@@ -41,7 +44,30 @@
41
44
  * asks for it.
42
45
  */
43
46
  compact?: boolean;
44
- /** Rendered above the title at one fixed distance. */
47
+ /**
48
+ * A link back to the page this one sits under, rendered above the title
49
+ * with an arrow and the parent's label ("Flows", "Settings").
50
+ *
51
+ * A page two levels down had only the sidebar and the browser to get back
52
+ * with. A third of the nested pages in one app built a breadcrumb of their
53
+ * own, half of those drew an arrow by hand, and the rest offered nothing.
54
+ * The affordance lives in the shell so every page gets it the same way.
55
+ * It is a plain anchor, so the keyboard needs nothing extra.
56
+ */
57
+ back?: { href: string; label: string };
58
+ /**
59
+ * Keep the h1 for assistive technology and render no title row at all: no
60
+ * back link, no breadcrumb, no description, no actions.
61
+ *
62
+ * For a page whose toolbar is its header. An editor that owns the viewport
63
+ * spent two rows before its canvas, the title row and then its own toolbar
64
+ * carrying the same name, with the shell's gap between them. The page
65
+ * still has exactly one h1, so a heading query and a screen reader see
66
+ * what every other page gives them; it is `sr-only`, not absent. A page
67
+ * that hides the row takes on the way back: `back` renders nothing here.
68
+ */
69
+ titleHidden?: boolean;
70
+ /** Rendered above the title at one fixed distance, after `back` when both are set. */
45
71
  breadcrumb?: Snippet;
46
72
  /** Right-aligned controls in the title row. */
47
73
  actions?: Snippet;
@@ -55,6 +81,8 @@
55
81
  width = 'default',
56
82
  fill = false,
57
83
  compact = false,
84
+ back = undefined,
85
+ titleHidden = false,
58
86
  breadcrumb,
59
87
  actions,
60
88
  class: klass = '',
@@ -91,17 +119,44 @@
91
119
 
92
120
  /** The content stack. On a fill page it also takes the leftover height. */
93
121
  const content = $derived(fill ? `${PAGE_STACK} min-h-0 flex-1` : PAGE_STACK);
122
+
123
+ /**
124
+ * The same rest, hover and focus treatment as a Breadcrumb link, so the two
125
+ * read as one row. The ring replaces the global outline rather than adding
126
+ * to it, and the padding gives the ring a shape to sit on: on bare text the
127
+ * ring hugs the glyphs and clips the descenders.
128
+ */
129
+ const BACK =
130
+ `${HIT_AREA} -ms-1 inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-xs text-muted ` +
131
+ 'outline-none transition-colors hover:text-fg ' +
132
+ 'focus-visible:ring-2 focus-visible:ring-brand';
94
133
  </script>
95
134
 
96
135
  <div class="{frame} {klass}">
97
- <!-- The breadcrumb and the title are one group, so the distance between them
136
+ {#if titleHidden}
137
+ <h1 data-testid="page-title" class="sr-only">{title}</h1>
138
+ {:else}
139
+ <!-- The breadcrumb and the title are one group, so the distance between them
98
140
  is fixed here and does not change with whether a description is set. -->
99
- <div class="flex flex-col gap-2 {headerPad}">
100
- {#if breadcrumb}{@render breadcrumb()}{/if}
101
- <!-- flush: the shell's own section stack supplies the gap below the title,
141
+ <div class="flex flex-col gap-2 {headerPad}">
142
+ <!-- The back link leads and the breadcrumb follows on one row, so a page
143
+ that has both does not stack two lines of navigation over its title. -->
144
+ {#if back || breadcrumb}
145
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
146
+ {#if back}
147
+ <a href={safeHref(back.href)} data-testid="page-back" class={BACK}>
148
+ <ArrowLeft size={14} aria-hidden="true" class="shrink-0 rtl:rotate-180" />
149
+ {back.label}
150
+ </a>
151
+ {/if}
152
+ {#if breadcrumb}{@render breadcrumb()}{/if}
153
+ </div>
154
+ {/if}
155
+ <!-- flush: the shell's own section stack supplies the gap below the title,
102
156
  so the header must not add a second one. -->
103
- <PageHeader {title} {description} {actions} {compact} flush />
104
- </div>
157
+ <PageHeader {title} {description} {actions} {compact} flush />
158
+ </div>
159
+ {/if}
105
160
 
106
161
  <div class={content}>
107
162
  {@render children()}
@@ -38,7 +38,33 @@ interface Props {
38
38
  * asks for it.
39
39
  */
40
40
  compact?: boolean;
41
- /** Rendered above the title at one fixed distance. */
41
+ /**
42
+ * A link back to the page this one sits under, rendered above the title
43
+ * with an arrow and the parent's label ("Flows", "Settings").
44
+ *
45
+ * A page two levels down had only the sidebar and the browser to get back
46
+ * with. A third of the nested pages in one app built a breadcrumb of their
47
+ * own, half of those drew an arrow by hand, and the rest offered nothing.
48
+ * The affordance lives in the shell so every page gets it the same way.
49
+ * It is a plain anchor, so the keyboard needs nothing extra.
50
+ */
51
+ back?: {
52
+ href: string;
53
+ label: string;
54
+ };
55
+ /**
56
+ * Keep the h1 for assistive technology and render no title row at all: no
57
+ * back link, no breadcrumb, no description, no actions.
58
+ *
59
+ * For a page whose toolbar is its header. An editor that owns the viewport
60
+ * spent two rows before its canvas, the title row and then its own toolbar
61
+ * carrying the same name, with the shell's gap between them. The page
62
+ * still has exactly one h1, so a heading query and a screen reader see
63
+ * what every other page gives them; it is `sr-only`, not absent. A page
64
+ * that hides the row takes on the way back: `back` renders nothing here.
65
+ */
66
+ titleHidden?: boolean;
67
+ /** Rendered above the title at one fixed distance, after `back` when both are set. */
42
68
  breadcrumb?: Snippet;
43
69
  /** Right-aligned controls in the title row. */
44
70
  actions?: Snippet;
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { safeHref } from '../internal/href.js';
3
3
  import { formatCount } from '../internal/number.js';
4
+ import { HIT_AREA } from '../internal/touch.js';
4
5
 
5
6
  interface Base {
6
7
  page: number;
@@ -179,8 +180,12 @@
179
180
  return href && enabled ? safeHref(href(target)) : undefined;
180
181
  }
181
182
 
182
- const btnBase =
183
- 'inline-flex items-center justify-center w-7 h-7 rounded text-xs font-medium transition-colors duration-150';
183
+ /**
184
+ * 28px drawn, 44px under a finger. The steps sit 2px apart, so growing the
185
+ * visual would put a seven-page pager past a phone's width; the hit box
186
+ * grows instead and the pager keeps its shape.
187
+ */
188
+ const btnBase = `${HIT_AREA} inline-flex items-center justify-center w-7 h-7 rounded text-xs font-medium transition-colors`;
184
189
 
185
190
  /**
186
191
  * A page control that is not the page you are on, in every state a pointer
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { Eye, EyeOff } from '@lucide/svelte';
3
+ import { HIT_AREA_POSITIONED } from '../internal/touch.js';
3
4
  import {
4
5
  CONTROL_BASE,
5
6
  FIELD_ERROR,
@@ -120,7 +121,7 @@
120
121
  aria-pressed={revealed}
121
122
  aria-controls={fieldId}
122
123
  aria-label={revealed ? 'Hide password' : 'Show password'}
123
- class="absolute top-1/2 end-1 flex -translate-y-1/2 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors duration-150 hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
124
+ class="{HIT_AREA_POSITIONED} absolute top-1/2 end-1 flex -translate-y-1/2 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
124
125
  >
125
126
  {#if revealed}
126
127
  <EyeOff size={15} aria-hidden="true" />
@@ -62,7 +62,7 @@
62
62
  aria-label={label}
63
63
  >
64
64
  <div
65
- class="h-full rounded-full transition-[width] duration-500
65
+ class="h-full rounded-full transition-[width] duration-progress
66
66
  {tones[tone]}
67
67
  {animated ? 'animate-pulse' : ''}"
68
68
  style="width: {pct}%"
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { CONTROL_BASE, FIELD_LABEL, FIELD_WRAP, controlBorder } from '../internal/field.js';
3
+ import { HIT_AREA_POSITIONED } from '../internal/touch.js';
3
4
 
4
5
  interface Props {
5
6
  value?: string;
@@ -84,8 +85,8 @@
84
85
  type="button"
85
86
  onclick={clear}
86
87
  aria-label="Clear search"
87
- class="absolute end-2.5 top-1/2 -mx-1.5 -translate-y-1/2 p-1.5 text-faint
88
- transition-colors duration-150 hover:text-fg"
88
+ class="{HIT_AREA_POSITIONED} absolute end-2.5 top-1/2 -mx-1.5 -translate-y-1/2 p-1.5 text-faint
89
+ transition-colors hover:text-fg"
89
90
  >
90
91
  <svg
91
92
  width="14"