@dorsk/tsumikit 0.67.0 → 0.68.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.
@@ -1,28 +1,43 @@
1
1
  <script lang="ts">
2
- // Horizontal route navigation: a `<nav>` landmark of `<a>` links, one marked
3
- // `aria-current="page"`. `placement="bar"` pins it to the bottom edge over
4
- // `--z-nav` and pads for the iOS home indicator with `--safe-bottom`;
5
- // `placement="inline"` is a plain row for a header. `orientation` stacks the
6
- // icon over the label (phone bar) or sits it beside (header).
2
+ // Horizontal navigation: a `<nav>` landmark of items, one marked current.
3
+ // An item with `href` is an `<a aria-current="page">`; one without is a
4
+ // `<button>` that sets the bindable `value`. When every item is a button the
5
+ // row is a `tablist` (roving tabindex, ←/→/Home/End, `aria-selected`).
6
+ // `placement="bar"` pins it to the bottom edge over `--z-nav` with safe-area
7
+ // padding; `variant="floating"` makes it an inset rounded card. `center`
8
+ // reserves a notch in the middle of the row, items split around it.
9
+ import type { Snippet } from 'svelte';
7
10
  import Icon from '../atoms/Icon.svelte';
8
11
  import type { IconName } from '../atoms/Icon.svelte';
9
12
 
10
13
  export interface NavBarItem {
11
- href: string;
14
+ /** Renders an `<a>`; without it the item is a `<button>`. */
15
+ href?: string;
16
+ /** Selects the item through the bar's `value`. */
17
+ value?: string;
12
18
  label: string;
13
19
  icon?: IconName;
14
20
  /** Rendered instead of `icon` when set. */
15
21
  emoji?: string;
16
22
  /** Count or short text pinned to the glyph. 0 / '' render nothing. */
17
23
  badge?: number | string;
24
+ /** Overrides the `value` match. */
18
25
  current?: boolean;
26
+ /** `preventDefault()` skips the `value` change. */
27
+ onclick?: (e: MouseEvent) => void;
28
+ /** Id of the panel a tab shows (`aria-controls`). */
29
+ controls?: string;
19
30
  }
20
31
 
21
32
  let {
22
33
  items,
23
34
  label,
35
+ value = $bindable(),
36
+ onchange,
37
+ center,
24
38
  placement = 'inline',
25
39
  orientation = 'stacked',
40
+ variant = 'flush',
26
41
  maxWidth,
27
42
  class: klass = '',
28
43
  style: styleProp = '',
@@ -31,11 +46,18 @@
31
46
  items: NavBarItem[];
32
47
  /** Accessible name of the `<nav>` landmark. */
33
48
  label: string;
49
+ /** `value` of the current item, for in-page views without routes. */
50
+ value?: string;
51
+ onchange?: (value: string) => void;
52
+ /** Fills a notch in the middle of the row (`--navbar-center-w`), may rise above it (`--navbar-center-raise`). */
53
+ center?: Snippet;
34
54
  /** 'bar' = fixed to the bottom edge with safe-area padding. */
35
55
  placement?: 'bar' | 'inline';
36
56
  /** Icon over label vs icon beside label. */
37
57
  orientation?: 'stacked' | 'inline';
38
- /** Caps and centres the row of links. */
58
+ /** 'floating' = rounded elevated card inset from the edges. */
59
+ variant?: 'flush' | 'floating';
60
+ /** Caps and centres the row of items. */
39
61
  maxWidth?: string;
40
62
  class?: string;
41
63
  style?: string;
@@ -44,38 +66,127 @@
44
66
 
45
67
  const hasBadge = (badge: NavBarItem['badge']) =>
46
68
  badge !== undefined && badge !== null && badge !== '' && badge !== 0;
69
+
70
+ const tabs = $derived(items.length > 0 && items.every((item) => !item.href));
71
+ const isCurrent = (item: NavBarItem) =>
72
+ item.current ?? (item.value !== undefined && item.value === value);
73
+ const focusIndex = $derived(Math.max(0, items.findIndex(isCurrent)));
74
+ const split = $derived(Math.ceil(items.length / 2));
75
+
76
+ function select(item: NavBarItem, e: MouseEvent) {
77
+ item.onclick?.(e);
78
+ if (e.defaultPrevented || item.value === undefined || item.value === value) return;
79
+ value = item.value;
80
+ onchange?.(item.value);
81
+ }
82
+
83
+ function onkeydown(e: KeyboardEvent) {
84
+ const list = e.currentTarget as HTMLElement;
85
+ const all = [...list.querySelectorAll<HTMLButtonElement>('[role="tab"]')];
86
+ const i = all.indexOf(e.target as HTMLButtonElement);
87
+ if (i < 0) return;
88
+ let next: number;
89
+ if (e.key === 'ArrowRight') next = (i + 1) % all.length;
90
+ else if (e.key === 'ArrowLeft') next = (i - 1 + all.length) % all.length;
91
+ else if (e.key === 'Home') next = 0;
92
+ else if (e.key === 'End') next = all.length - 1;
93
+ else return;
94
+ e.preventDefault();
95
+ all[next].focus();
96
+ all[next].click();
97
+ }
47
98
  </script>
48
99
 
100
+ {#snippet glyph(item: NavBarItem)}
101
+ <span class="navbar-glyph">
102
+ {#if item.emoji}
103
+ <span class="navbar-emoji" aria-hidden="true">{item.emoji}</span>
104
+ {:else if item.icon}
105
+ <Icon name={item.icon} size={20} />
106
+ {/if}
107
+ {#if hasBadge(item.badge)}
108
+ <span class="navbar-badge">{item.badge}</span>
109
+ {/if}
110
+ </span>
111
+ <span class="navbar-label">{item.label}</span>
112
+ {/snippet}
113
+
114
+ {#snippet entry(item: NavBarItem, i: number)}
115
+ {#if item.href}
116
+ <a
117
+ href={item.href}
118
+ class="navbar-link"
119
+ class:current={isCurrent(item)}
120
+ aria-current={isCurrent(item) ? 'page' : undefined}
121
+ onclick={(e) => select(item, e)}
122
+ >
123
+ {@render glyph(item)}
124
+ </a>
125
+ {:else if tabs}
126
+ <button
127
+ type="button"
128
+ role="tab"
129
+ class="navbar-link"
130
+ class:current={isCurrent(item)}
131
+ aria-selected={isCurrent(item)}
132
+ aria-controls={item.controls}
133
+ tabindex={i === focusIndex ? 0 : -1}
134
+ onclick={(e) => select(item, e)}
135
+ >
136
+ {@render glyph(item)}
137
+ </button>
138
+ {:else}
139
+ <button
140
+ type="button"
141
+ class="navbar-link"
142
+ class:current={isCurrent(item)}
143
+ aria-current={isCurrent(item) ? 'page' : undefined}
144
+ onclick={(e) => select(item, e)}
145
+ >
146
+ {@render glyph(item)}
147
+ </button>
148
+ {/if}
149
+ {/snippet}
150
+
49
151
  <nav
50
152
  data-tsu="NavBar"
51
153
  aria-label={label}
52
154
  class="navbar {klass}"
53
155
  class:bar={placement === 'bar'}
156
+ class:floating={variant === 'floating'}
54
157
  class:stacked={orientation === 'stacked'}
55
158
  style="{maxWidth ? `--navbar-max: ${maxWidth};` : ''}{styleProp}"
56
159
  {...rest}
57
160
  >
58
- <div class="navbar-inner">
59
- {#each items as item, i (`${i}:${item.href}`)}
60
- <a
61
- href={item.href}
62
- class="navbar-link"
63
- class:current={item.current}
64
- aria-current={item.current ? 'page' : undefined}
65
- >
66
- <span class="navbar-glyph">
67
- {#if item.emoji}
68
- <span class="navbar-emoji" aria-hidden="true">{item.emoji}</span>
69
- {:else if item.icon}
70
- <Icon name={item.icon} size={20} />
71
- {/if}
72
- {#if hasBadge(item.badge)}
73
- <span class="navbar-badge">{item.badge}</span>
74
- {/if}
75
- </span>
76
- <span class="navbar-label">{item.label}</span>
77
- </a>
78
- {/each}
161
+ <div class="navbar-row">
162
+ <div
163
+ class="navbar-inner"
164
+ class:split={!!center}
165
+ role={tabs ? 'tablist' : undefined}
166
+ aria-label={tabs ? label : undefined}
167
+ onkeydown={tabs ? onkeydown : undefined}
168
+ >
169
+ {#if center}
170
+ <div class="navbar-side" role="none">
171
+ {#each items.slice(0, split) as item, i (`${i}:${item.href ?? item.value ?? item.label}`)}
172
+ {@render entry(item, i)}
173
+ {/each}
174
+ </div>
175
+ <span class="navbar-notch" aria-hidden="true"></span>
176
+ <div class="navbar-side" role="none">
177
+ {#each items.slice(split) as item, j (`${split + j}:${item.href ?? item.value ?? item.label}`)}
178
+ {@render entry(item, split + j)}
179
+ {/each}
180
+ </div>
181
+ {:else}
182
+ {#each items as item, i (`${i}:${item.href ?? item.value ?? item.label}`)}
183
+ {@render entry(item, i)}
184
+ {/each}
185
+ {/if}
186
+ </div>
187
+ {#if center}
188
+ <div class="navbar-center">{@render center()}</div>
189
+ {/if}
79
190
  </div>
80
191
  </nav>
81
192
 
@@ -84,16 +195,41 @@
84
195
  --navbar-max: none;
85
196
  min-width: 0;
86
197
  }
198
+ .navbar-row {
199
+ position: relative;
200
+ min-width: 0;
201
+ max-width: var(--navbar-max);
202
+ margin-inline: auto;
203
+ }
87
204
  .navbar-inner {
88
205
  display: flex;
89
206
  align-items: stretch;
90
207
  justify-content: space-around;
91
208
  gap: var(--sp-1);
92
209
  min-width: 0;
93
- max-width: var(--navbar-max);
94
- margin-inline: auto;
95
210
  min-height: var(--nav-h);
96
211
  }
212
+ .navbar-inner.split {
213
+ display: grid;
214
+ grid-template-columns: minmax(0, 1fr) var(--navbar-center-w, 4.5rem) minmax(0, 1fr);
215
+ }
216
+ .navbar-side {
217
+ display: flex;
218
+ align-items: stretch;
219
+ gap: var(--sp-1);
220
+ min-width: 0;
221
+ }
222
+ .navbar-center {
223
+ position: absolute;
224
+ top: calc(-1 * var(--navbar-center-raise, 0px));
225
+ bottom: var(--navbar-center-raise, 0px);
226
+ left: 50%;
227
+ display: flex;
228
+ align-items: center;
229
+ justify-content: center;
230
+ width: var(--navbar-center-w, 4.5rem);
231
+ transform: translateX(-50%);
232
+ }
97
233
  .bar {
98
234
  position: fixed;
99
235
  inset-inline: 0;
@@ -105,21 +241,39 @@
105
241
  backdrop-filter: blur(12px);
106
242
  border-top: 1px solid var(--border);
107
243
  }
244
+ .floating {
245
+ padding: var(--sp-1);
246
+ border: 1px solid var(--border-strong);
247
+ border-radius: var(--r-md);
248
+ background: var(--bg-elevated);
249
+ box-shadow: var(--shadow-md);
250
+ }
251
+ .bar.floating {
252
+ left: calc(var(--sp-2) + var(--safe-left));
253
+ right: calc(var(--sp-2) + var(--safe-right));
254
+ bottom: calc(var(--sp-2) + var(--safe-bottom));
255
+ backdrop-filter: none;
256
+ }
108
257
  .navbar-link {
109
258
  position: relative;
110
259
  display: flex;
111
260
  flex: 1 1 0;
112
261
  min-width: 0;
262
+ min-height: var(--touch-target);
113
263
  flex-direction: row;
114
264
  align-items: center;
115
265
  justify-content: center;
116
266
  gap: var(--sp-2);
117
267
  padding: var(--sp-2);
268
+ border: none;
118
269
  border-radius: var(--r-md);
270
+ background: none;
119
271
  color: var(--text-muted);
272
+ font: inherit;
120
273
  font-size: var(--fs-sm);
121
274
  font-weight: var(--fw-medium);
122
275
  text-decoration: none;
276
+ cursor: pointer;
123
277
  transition:
124
278
  background 0.12s var(--ease),
125
279
  color 0.12s var(--ease);
@@ -1,28 +1,44 @@
1
+ import type { Snippet } from 'svelte';
1
2
  import type { IconName } from '../atoms/Icon.svelte';
2
3
  export interface NavBarItem {
3
- href: string;
4
+ /** Renders an `<a>`; without it the item is a `<button>`. */
5
+ href?: string;
6
+ /** Selects the item through the bar's `value`. */
7
+ value?: string;
4
8
  label: string;
5
9
  icon?: IconName;
6
10
  /** Rendered instead of `icon` when set. */
7
11
  emoji?: string;
8
12
  /** Count or short text pinned to the glyph. 0 / '' render nothing. */
9
13
  badge?: number | string;
14
+ /** Overrides the `value` match. */
10
15
  current?: boolean;
16
+ /** `preventDefault()` skips the `value` change. */
17
+ onclick?: (e: MouseEvent) => void;
18
+ /** Id of the panel a tab shows (`aria-controls`). */
19
+ controls?: string;
11
20
  }
12
21
  type $$ComponentProps = {
13
22
  items: NavBarItem[];
14
23
  /** Accessible name of the `<nav>` landmark. */
15
24
  label: string;
25
+ /** `value` of the current item, for in-page views without routes. */
26
+ value?: string;
27
+ onchange?: (value: string) => void;
28
+ /** Fills a notch in the middle of the row (`--navbar-center-w`), may rise above it (`--navbar-center-raise`). */
29
+ center?: Snippet;
16
30
  /** 'bar' = fixed to the bottom edge with safe-area padding. */
17
31
  placement?: 'bar' | 'inline';
18
32
  /** Icon over label vs icon beside label. */
19
33
  orientation?: 'stacked' | 'inline';
20
- /** Caps and centres the row of links. */
34
+ /** 'floating' = rounded elevated card inset from the edges. */
35
+ variant?: 'flush' | 'floating';
36
+ /** Caps and centres the row of items. */
21
37
  maxWidth?: string;
22
38
  class?: string;
23
39
  style?: string;
24
40
  [key: string]: unknown;
25
41
  };
26
- declare const NavBar: import("svelte").Component<$$ComponentProps, {}, "">;
42
+ declare const NavBar: import("svelte").Component<$$ComponentProps, {}, "value">;
27
43
  type NavBar = ReturnType<typeof NavBar>;
28
44
  export default NavBar;
@@ -45,7 +45,7 @@
45
45
  control?: boolean;
46
46
  /** Shared square box scale (`--box-xs/sm/md/lg`); implies `iconOnly`. */
47
47
  box?: 'xs' | 'sm' | 'md' | 'lg';
48
- /** Icon-only buttons grow a 44px hit slab on coarse pointers; `compact` opts out. */
48
+ /** Icon-only buttons grow a touch hit slab on coarse pointers; `compact` opts out. */
49
49
  hitArea?: 'auto' | 'compact';
50
50
  class?: string;
51
51
  } = $props();
@@ -158,7 +158,7 @@
158
158
  content: '';
159
159
  position: absolute;
160
160
  inset-block: min(0px, calc((100% - var(--touch-target)) / 2));
161
- inset-inline-start: min(0px, calc(100% - var(--touch-target)));
161
+ inset-inline-start: min(0px, calc((100% - var(--touch-target)) / 2));
162
162
  inset-inline-end: 0;
163
163
  }
164
164
  }
@@ -24,7 +24,7 @@ type $$ComponentProps = {
24
24
  control?: boolean;
25
25
  /** Shared square box scale (`--box-xs/sm/md/lg`); implies `iconOnly`. */
26
26
  box?: 'xs' | 'sm' | 'md' | 'lg';
27
- /** Icon-only buttons grow a 44px hit slab on coarse pointers; `compact` opts out. */
27
+ /** Icon-only buttons grow a touch hit slab on coarse pointers; `compact` opts out. */
28
28
  hitArea?: 'auto' | 'compact';
29
29
  class?: string;
30
30
  };
@@ -20,6 +20,7 @@
20
20
  import { tick } from 'svelte';
21
21
  import type { HTMLAttributes } from 'svelte/elements';
22
22
  import { browser } from '../../env';
23
+ import { slideSelect } from '../../slide-select';
23
24
 
24
25
  type Own = {
25
26
  /** Glyphs as `{ inline, stacked? }` pairs; alternative to `children`. */
@@ -78,7 +79,6 @@
78
79
  return el.parentElement;
79
80
  }
80
81
 
81
- const SLIDE_THRESHOLD = 12;
82
82
  const CONTROL = 'button, a[href], input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])';
83
83
 
84
84
  const tiles = $derived(items?.slice(0, 4) ?? []);
@@ -86,7 +86,6 @@
86
86
  let panel = $state<HTMLDivElement>();
87
87
  let open = $state(false);
88
88
  let armed = $state(-1);
89
- let origin: { x: number; y: number } | null = null;
90
89
 
91
90
  function show() {
92
91
  try {
@@ -129,11 +128,13 @@
129
128
  }
130
129
 
131
130
  /** Tile under a drag from the press point: 0 top-left, 1 top-right, 2 bottom-left, 3 bottom-right. */
132
- function corner(dx: number, dy: number): number {
133
- if (Math.hypot(dx, dy) < SLIDE_THRESHOLD) return -1;
134
- const i = (dy < 0 ? 0 : 2) + (dx < 0 ? 0 : 1);
135
- return i < tiles.length ? i : -1;
136
- }
131
+ const slide = slideSelect({
132
+ pick: ({ dx, dy }) => {
133
+ const i = (dy < 0 ? 0 : 2) + (dx < 0 ? 0 : 1);
134
+ return i < tiles.length ? i : -1;
135
+ },
136
+ onarm: (i) => (armed = i),
137
+ });
137
138
 
138
139
  async function activate(i: number) {
139
140
  const { action } = tiles[i];
@@ -153,29 +154,21 @@
153
154
  if (!expandable || expand !== 'slide' || e.button !== 0) return;
154
155
  e.preventDefault();
155
156
  e.stopPropagation();
156
- origin = { x: e.clientX, y: e.clientY };
157
- armed = -1;
158
- try {
159
- root?.setPointerCapture(e.pointerId);
160
- } catch {}
157
+ slide.down(e, root);
161
158
  show();
162
159
  }
163
160
 
164
161
  function onPointerMove(e: PointerEvent) {
165
- if (origin) armed = corner(e.clientX - origin.x, e.clientY - origin.y);
162
+ slide.move(e);
166
163
  }
167
164
 
168
165
  function onPointerUp() {
169
- if (!origin) return;
170
- origin = null;
171
- const i = armed;
172
- armed = -1;
166
+ const i = slide.up();
173
167
  if (i >= 0) activate(i);
174
168
  }
175
169
 
176
170
  function onPointerCancel() {
177
- origin = null;
178
- armed = -1;
171
+ slide.cancel();
179
172
  }
180
173
 
181
174
  // Pointer clicks on the folded cells open the panel instead of hitting the
@@ -34,7 +34,7 @@
34
34
  // Exact glyph size for SVG *and* text glyphs (px number or any CSS length),
35
35
  // no emoji multiplier. Wins over `size`.
36
36
  glyphSize?: number | string;
37
- // Opt out of the 44px coarse-pointer hit slab in dense rows.
37
+ // Opt out of the coarse-pointer hit slab in dense rows.
38
38
  hitArea?: 'auto' | 'compact';
39
39
  // Borderless, compact icon affordance (chip-remove ✕, inline edit ✎) —
40
40
  // no square box; just a muted glyph that brightens on hover. Pair with
@@ -61,7 +61,7 @@
61
61
  /** Use the shared `--control-height` toolbar/composer contract. */
62
62
  control?: boolean;
63
63
  block?: boolean;
64
- /** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
64
+ /** Icon-only triggers grow a touch hit slab on coarse pointers; `compact` opts out. */
65
65
  hitArea?: 'auto' | 'compact';
66
66
  /** Corner count indicator on the trigger, as on `Button`: hidden at
67
67
  * 0/undefined, capped at `countMax+`, the exact number joins `label`. */
@@ -449,10 +449,9 @@
449
449
  padding: var(--pop-trigger-pad, 0);
450
450
  flex: none;
451
451
  }
452
- /* Coarse pointers: icon-only triggers extend their hit area to --touch-target
453
- via an invisible slab; layout does not move. The slab grows towards the
454
- inline start only, so it never adds scrollable overflow to a scroll ancestor
455
- (see Button.svelte). */
452
+ /* Coarse pointers: icon-only triggers extend their hit area towards --touch-target
453
+ via an invisible slab; layout does not move. Same geometry as Button.svelte:
454
+ no inline-end growth, half the deficit towards the inline start. */
456
455
  @media (pointer: coarse) {
457
456
  .pop-trigger:not(.canonical, .hit-compact) {
458
457
  position: relative;
@@ -461,7 +460,7 @@
461
460
  content: '';
462
461
  position: absolute;
463
462
  inset-block: min(0px, calc((100% - var(--touch-target)) / 2));
464
- inset-inline-start: min(0px, calc(100% - var(--touch-target)));
463
+ inset-inline-start: min(0px, calc((100% - var(--touch-target)) / 2));
465
464
  inset-inline-end: 0;
466
465
  }
467
466
  }
@@ -46,7 +46,7 @@ type Own = {
46
46
  /** Use the shared `--control-height` toolbar/composer contract. */
47
47
  control?: boolean;
48
48
  block?: boolean;
49
- /** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
49
+ /** Icon-only triggers grow a touch hit slab on coarse pointers; `compact` opts out. */
50
50
  hitArea?: 'auto' | 'compact';
51
51
  /** Corner count indicator on the trigger, as on `Button`: hidden at
52
52
  * 0/undefined, capped at `countMax+`, the exact number joins `label`. */