@joewinke/jatui 0.5.0 → 0.5.2

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.
@@ -105,7 +105,10 @@
105
105
  submenuIndex = index;
106
106
  }
107
107
 
108
- function handleItemClick(item: ActionItem, index: number, event: MouseEvent) {
108
+ // Accelerators route through here with a KeyboardEvent; submenu items are
109
+ // handled before the call in that path, so `currentTarget` is only read
110
+ // for the (mouse-driven) submenu toggle branch.
111
+ function handleItemClick(item: ActionItem, index: number, event: MouseEvent | KeyboardEvent) {
109
112
  if (item.disabled) return;
110
113
  if (item.submenu && item.submenu.length) {
111
114
  // Submenu owner: open (or toggle) instead of firing onSelect/close.
@@ -125,7 +128,13 @@
125
128
  }
126
129
 
127
130
  function handleItemPointerEnter(item: ActionItem, index: number, event: PointerEvent) {
128
- if (item.disabled || !item.submenu?.length) return;
131
+ // Entering an item WITHOUT a submenu must close any open submenu —
132
+ // otherwise hover-scrolling past a submenu parent (e.g. "Classify
133
+ // sender") leaves its panel stuck open over the rest of the menu.
134
+ if (item.disabled || !item.submenu?.length) {
135
+ closeSubmenu();
136
+ return;
137
+ }
129
138
  const el = event.currentTarget as HTMLElement;
130
139
  openSubmenuAt(el, index);
131
140
  }
@@ -178,6 +187,13 @@
178
187
  return buttons.indexOf(activeBtn as HTMLButtonElement);
179
188
  }
180
189
 
190
+ function isEditableTarget(target: EventTarget | null): boolean {
191
+ const el = target as HTMLElement | null;
192
+ if (!el) return false;
193
+ const tag = el.tagName;
194
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
195
+ }
196
+
181
197
  function handleKeydown(e: KeyboardEvent) {
182
198
  if (e.key === 'Escape') {
183
199
  // If a submenu is open, close only the submenu on Escape; otherwise
@@ -226,6 +242,43 @@
226
242
  }
227
243
  return;
228
244
  }
245
+ // Single-character accelerators (item.shortcut). No modifiers, and
246
+ // suppressed while focus sits in an editable element (a `content`
247
+ // snippet's input) so typing never fires actions.
248
+ if (
249
+ e.key.length === 1 &&
250
+ !e.ctrlKey &&
251
+ !e.metaKey &&
252
+ !e.altKey &&
253
+ !isEditableTarget(e.target)
254
+ ) {
255
+ const pressed = e.key.toLowerCase();
256
+ const idx = items.findIndex(
257
+ (it) =>
258
+ !('divider' in it) &&
259
+ !it.disabled &&
260
+ it.shortcut?.toLowerCase() === pressed
261
+ );
262
+ if (idx >= 0) {
263
+ const item = items[idx] as ActionItem;
264
+ e.preventDefault();
265
+ e.stopPropagation();
266
+ if (item.submenu?.length) {
267
+ const buttons = menuItemButtons();
268
+ openSubmenuAt(buttons[idx], idx);
269
+ const el = menuEl;
270
+ requestAnimationFrame(() => {
271
+ const first = el?.querySelector(
272
+ ':scope > [role="menu"] > button[role="menuitem"]:not(:disabled)'
273
+ ) as HTMLButtonElement | null;
274
+ first?.focus();
275
+ });
276
+ } else {
277
+ handleItemClick(item, idx, e);
278
+ }
279
+ return;
280
+ }
281
+ }
229
282
  if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
230
283
  const buttons = menuItemButtons();
231
284
  if (!buttons.length) return;
@@ -289,7 +342,7 @@
289
342
  aria-expanded={item.submenu?.length ? submenuIndex === i : undefined}
290
343
  aria-label={item.content ? item.label : undefined}
291
344
  class="w-full flex items-center gap-2 text-left px-3 py-1.5 text-sm transition-colors
292
- {item.variant === 'danger' ? 'text-error hover:bg-error/10' : 'hover:bg-base-200'}
345
+ {item.variant === 'danger' ? 'text-error hover:bg-error/10' : 'hover:bg-base-300'}
293
346
  {item.disabled ? 'opacity-50 cursor-not-allowed' : ''}"
294
347
  disabled={item.disabled}
295
348
  onclick={(e) => handleItemClick(item, i, e)}
@@ -297,6 +350,10 @@
297
350
  onfocus={(e) => {
298
351
  if (item.submenu?.length && !item.disabled) {
299
352
  openSubmenuAt(e.currentTarget as HTMLElement, i);
353
+ } else {
354
+ // Arrow-navigating onto a submenu-less item closes any
355
+ // open submenu, same as pointer hover.
356
+ closeSubmenu();
300
357
  }
301
358
  }}
302
359
  >
@@ -305,6 +362,12 @@
305
362
  {:else}
306
363
  {#if item.icon}<span class="shrink-0">{item.icon}</span>{/if}
307
364
  <span class="truncate flex-1">{item.label}</span>
365
+ {#if item.shortcut}
366
+ <kbd
367
+ class="shrink-0 text-[10px] leading-none px-1 py-0.5 rounded border border-base-content/20 text-base-content/50"
368
+ aria-hidden="true">{item.shortcut}</kbd
369
+ >
370
+ {/if}
308
371
  {#if item.submenu?.length}
309
372
  <span class="text-base-content/50 shrink-0" aria-hidden="true">▸</span>
310
373
  {/if}
@@ -55,6 +55,16 @@ export type ActionItem = {
55
55
  content?: Snippet;
56
56
  variant?: 'default' | 'danger';
57
57
  disabled?: boolean;
58
+ /**
59
+ * Single-character keyboard accelerator, active while the menu is open.
60
+ * Pressing the key (case-insensitive, no modifiers) activates the item
61
+ * exactly like a click — including `submit` and closing the menu. A
62
+ * right-aligned <kbd> hint renders after the label. Items with a
63
+ * `submenu` open it (focus moves to its first item). Accelerators are
64
+ * suppressed while focus is inside an editable element (an `content`
65
+ * snippet's input, for example), so typing never triggers actions.
66
+ */
67
+ shortcut?: string;
58
68
  };
59
69
  export type DividerItem = {
60
70
  divider: true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",