@dorsk/tsumikit 0.64.1 → 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),
@@ -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`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.64.1",
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",