@dorsk/tsumikit 0.64.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -276,7 +276,8 @@ and an `attrs` object for `data-*`/test ids on the row; a checkable `pressed` it
276
276
  its icon leading and shows its check trailing, or uses the leading check slot when it has
277
277
  no icon; selecting closes the menu unless `closeOnSelect={false}` or the item sets `keepOpen`;
278
278
  rest props land on the trigger, `count`/`countMax` paint its corner badge and `panelStyle`
279
- sizes the panel),
279
+ sizes the panel; an item with a `control` snippet renders as a non-button row hosting a native
280
+ `<select>` or nested Popover trigger, keeping the icon column, row height and ↑/↓ navigation),
280
281
  Tabs (omit `panel` for a strip-only tablist of document tabs: `closable` + `onclose(id)`
281
282
  close through the ✕, Delete and middle-click, per-tab `leading` snippet, `title`,
282
283
  trailing `actions` snippet; `--tab-max-width` truncates long labels),
@@ -837,7 +838,11 @@ glyphs (`size` stays the SVG px, emoji ×1.35).
837
838
  On `(pointer: coarse)` every icon-only/square control (and `Popover`'s default
838
839
  trigger) carries an invisible `::after` slab that extends its hit area to
839
840
  `--touch-target` (44px, WCAG 2.5.8) without moving layout; fine pointers are
840
- untouched. `hitArea="compact"` opts a dense-table row out. `Button
841
+ untouched. The slab grows on both block sides but only towards the inline
842
+ *start*: inline-end overflow would count towards the scrollable overflow of
843
+ every scroll ancestor, so a button near the right edge of an `overflow-y: auto`
844
+ container would make it scroll sideways by a pixel or two.
845
+ `hitArea="compact"` opts a dense-table row out. `Button
841
846
  collapseLabel="mobile" | "container"` hides `<span data-label>` children below
842
847
  40rem viewport / 30rem container, drops to icon padding, and names the button
843
848
  from the hidden text unless `aria-label` is set:
@@ -508,7 +508,11 @@
508
508
 
509
509
  /* Coarse pointers: icon-only and square buttons carry an invisible slab that
510
510
  extends the hit area to --touch-target without changing layout. Buttons
511
- already at or above the target keep their own box (inset never positive). */
511
+ already at or above the target keep their own box (inset never positive).
512
+ The slab grows towards the inline start only: inline-end overflow counts
513
+ towards the scrollable overflow of every scroll ancestor, so a button near
514
+ the right edge of an `overflow-y: auto` container would make it scroll
515
+ sideways. Start-side overflow is unscrollable, so the full target survives. */
512
516
  @media (pointer: coarse) {
513
517
  .btn-icon:not(.hit-compact),
514
518
  .btn-icon-inline:not(.hit-compact),
@@ -524,7 +528,9 @@
524
528
  .btn-chip:not(.hit-compact)::after {
525
529
  content: '';
526
530
  position: absolute;
527
- inset: min(0px, calc((100% - var(--touch-target)) / 2));
531
+ inset-block: min(0px, calc((100% - var(--touch-target)) / 2));
532
+ inset-inline-start: min(0px, calc(100% - var(--touch-target)));
533
+ inset-inline-end: 0;
528
534
  }
529
535
  }
530
536
 
@@ -422,7 +422,8 @@
422
422
  .panel-content {
423
423
  flex: 1;
424
424
  min-height: 0;
425
- overflow: auto;
425
+ overflow-y: auto;
426
+ overflow-x: hidden;
426
427
  }
427
428
  /* Subtle chevron handle anchored to the panel's inner edge — never a filled
428
429
  button, never overlapping the neighbouring main content. It stays visible
@@ -157,7 +157,9 @@
157
157
  .file-btn.icon-only:not(.hit-compact)::after {
158
158
  content: '';
159
159
  position: absolute;
160
- inset: min(0px, calc((100% - var(--touch-target)) / 2));
160
+ inset-block: min(0px, calc((100% - var(--touch-target)) / 2));
161
+ inset-inline-start: min(0px, calc(100% - var(--touch-target)));
162
+ inset-inline-end: 0;
161
163
  }
162
164
  }
163
165
  /* Emoji glyph is bumped above the text size — at 1em a paperclip is hard to
@@ -1,7 +1,8 @@
1
1
  <script lang="ts" module>
2
2
  export interface MenuItem {
3
3
  label: string;
4
- onselect: () => void;
4
+ /** Required for button rows; a `control` row's hosted control acts on its own. */
5
+ onselect?: () => void;
5
6
  icon?: import('../atoms/Icon.svelte').IconName;
6
7
  danger?: boolean;
7
8
  disabled?: boolean;
@@ -18,6 +19,13 @@
18
19
  keepOpen?: boolean;
19
20
  /** Custom row content (a rename Input, a slider…) replacing icon/label/tag. */
20
21
  content?: import('svelte').Snippet<[MenuItem]>;
22
+ /**
23
+ * A row that is itself a control (a native `<select>`, a nested Popover trigger):
24
+ * rendered as a non-button row with the menu's icon column and row height, its
25
+ * first focusable element joining ↑/↓ navigation. Replaces label/tag; `onselect`,
26
+ * `pressed` and `keepOpen` do not apply.
27
+ */
28
+ control?: import('svelte').Snippet<[MenuItem, { close: () => void }]>;
21
29
  /**
22
30
  * Extra attributes for the rendered row — `data-*`, `title`, `aria-describedby`…
23
31
  * The row's own semantics (`role`, `aria-checked`, `disabled`, `class`, `onclick`)
@@ -117,18 +125,27 @@
117
125
 
118
126
  let listEl = $state<HTMLDivElement | null>(null);
119
127
 
120
- function buttons(): HTMLButtonElement[] {
121
- return listEl
122
- ? Array.from(listEl.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled), [role="menuitemcheckbox"]:not(:disabled)'))
123
- : [];
128
+ const ROW_TARGET = ':is(select, input, textarea, button):not(:disabled), a[href], [tabindex]:not([tabindex="-1"])';
129
+
130
+ function targets(): HTMLElement[] {
131
+ if (!listEl) return [];
132
+ const rows = listEl.querySelectorAll<HTMLElement>(
133
+ ':scope > [role="menuitem"]:not(:disabled), :scope > [role="menuitemcheckbox"]:not(:disabled), :scope > .menu-row:not([inert])'
134
+ );
135
+ return Array.from(rows).flatMap((row) => {
136
+ if (!row.classList.contains('menu-row')) return [row];
137
+ const control = row.querySelector<HTMLElement>(ROW_TARGET);
138
+ return control ? [control] : [];
139
+ });
124
140
  }
125
141
  function focusAt(i: number) {
126
- const b = buttons();
142
+ const b = targets();
127
143
  if (b.length) b[(i + b.length) % b.length].focus();
128
144
  }
129
145
  function onkeydown(e: KeyboardEvent) {
130
- const b = buttons();
131
- const i = b.indexOf(document.activeElement as HTMLButtonElement);
146
+ if ((e.target as Element).closest('[popover]') !== listEl?.closest('[popover]')) return;
147
+ const b = targets();
148
+ const i = b.indexOf(document.activeElement as HTMLElement);
132
149
  if (e.key === 'ArrowDown') {
133
150
  e.preventDefault();
134
151
  focusAt(i + 1);
@@ -146,7 +163,7 @@
146
163
  function select(item: MenuItem, close: () => void) {
147
164
  if (item.disabled) return;
148
165
  if (menuItemCloses(item, closeOnSelect)) close();
149
- item.onselect();
166
+ item.onselect?.();
150
167
  }
151
168
  </script>
152
169
 
@@ -186,6 +203,12 @@
186
203
  {#snippet children({ close }: { close: () => void })}
187
204
  <div bind:this={listEl} role="none" class="menu" data-tsu="Menu" tabindex="-1" {onkeydown}>
188
205
  {#each items as item (item.label)}
206
+ {#if item.control}
207
+ <div {...item.attrs as HTMLAttributes<HTMLDivElement>} role="none" class="menu-item menu-row" class:danger={item.danger} inert={item.disabled}>
208
+ {#if item.icon}<Icon name={item.icon} />{/if}
209
+ {@render item.control(item, { close })}
210
+ </div>
211
+ {:else}
189
212
  <button
190
213
  type="button"
191
214
  {...item.attrs}
@@ -216,6 +239,7 @@
216
239
  {#if glyphs.trailingCheck}<span class="menu-check menu-check-trailing"><Icon name="check" /></span>{/if}
217
240
  {/if}
218
241
  </button>
242
+ {/if}
219
243
  {/each}
220
244
  </div>
221
245
  {/snippet}
@@ -241,8 +265,12 @@
241
265
  font-size: var(--fs-sm);
242
266
  white-space: nowrap;
243
267
  }
268
+ .menu-row {
269
+ position: relative;
270
+ }
244
271
  .menu-item:hover:not(:disabled),
245
- .menu-item:focus-visible {
272
+ .menu-item:focus-visible,
273
+ .menu-row:focus-within {
246
274
  background: var(--bg-elevated-2);
247
275
  outline: none;
248
276
  }
@@ -273,7 +301,8 @@
273
301
  margin-inline-start: 0;
274
302
  padding-inline-start: 0;
275
303
  }
276
- .menu-item:disabled {
304
+ .menu-item:disabled,
305
+ .menu-row[inert] {
277
306
  opacity: 0.45;
278
307
  cursor: not-allowed;
279
308
  }
@@ -1,6 +1,7 @@
1
1
  export interface MenuItem {
2
2
  label: string;
3
- onselect: () => void;
3
+ /** Required for button rows; a `control` row's hosted control acts on its own. */
4
+ onselect?: () => void;
4
5
  icon?: import('../atoms/Icon.svelte').IconName;
5
6
  danger?: boolean;
6
7
  disabled?: boolean;
@@ -17,6 +18,15 @@ export interface MenuItem {
17
18
  keepOpen?: boolean;
18
19
  /** Custom row content (a rename Input, a slider…) replacing icon/label/tag. */
19
20
  content?: import('svelte').Snippet<[MenuItem]>;
21
+ /**
22
+ * A row that is itself a control (a native `<select>`, a nested Popover trigger):
23
+ * rendered as a non-button row with the menu's icon column and row height, its
24
+ * first focusable element joining ↑/↓ navigation. Replaces label/tag; `onselect`,
25
+ * `pressed` and `keepOpen` do not apply.
26
+ */
27
+ control?: import('svelte').Snippet<[MenuItem, {
28
+ close: () => void;
29
+ }]>;
20
30
  /**
21
31
  * Extra attributes for the rendered row — `data-*`, `title`, `aria-describedby`…
22
32
  * The row's own semantics (`role`, `aria-checked`, `disabled`, `class`, `onclick`)
@@ -450,7 +450,9 @@
450
450
  flex: none;
451
451
  }
452
452
  /* Coarse pointers: icon-only triggers extend their hit area to --touch-target
453
- via an invisible slab; layout does not move. */
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). */
454
456
  @media (pointer: coarse) {
455
457
  .pop-trigger:not(.canonical, .hit-compact) {
456
458
  position: relative;
@@ -458,7 +460,9 @@
458
460
  .pop-trigger:not(.canonical, .hit-compact)::after {
459
461
  content: '';
460
462
  position: absolute;
461
- inset: min(0px, calc((100% - var(--touch-target)) / 2));
463
+ inset-block: min(0px, calc((100% - var(--touch-target)) / 2));
464
+ inset-inline-start: min(0px, calc(100% - var(--touch-target)));
465
+ inset-inline-end: 0;
462
466
  }
463
467
  }
464
468
  .pop-trigger.trigger-tone-accent {
@@ -88,12 +88,16 @@ pre {
88
88
  border-radius: var(--r-sm);
89
89
  }
90
90
 
91
- ::-webkit-scrollbar {
92
- width: 8px;
93
- height: 8px;
94
- }
95
- ::-webkit-scrollbar-thumb {
96
- background: var(--border-strong);
97
- border-radius: var(--r-pill);
91
+ /* Slim scrollbars for mice only. On touch, a custom scrollbar replaces the
92
+ native overlay bars with classic space-taking ones. */
93
+ @media (hover: hover) and (pointer: fine) {
94
+ ::-webkit-scrollbar {
95
+ width: 8px;
96
+ height: 8px;
97
+ }
98
+ ::-webkit-scrollbar-thumb {
99
+ background: var(--border-strong);
100
+ border-radius: var(--r-pill);
101
+ }
98
102
  }
99
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.64.0",
3
+ "version": "0.65.0",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",