@insymetri/styleguide 0.1.80 → 0.1.81

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.
@@ -188,8 +188,9 @@
188
188
  clearTimeout(typeaheadTimer)
189
189
  typeaheadTimer = setTimeout(() => { typeaheadBuffer = '' }, 500)
190
190
 
191
+ // Substring match (so "tu" finds "Alan Turing"), not just prefix.
191
192
  const matchIndex = items.findIndex(
192
- i => !i.disabled && i.label.toLowerCase().startsWith(typeaheadBuffer)
193
+ i => !i.disabled && i.label.toLowerCase().includes(typeaheadBuffer)
193
194
  )
194
195
  if (matchIndex >= 0) {
195
196
  const match = items[matchIndex]
@@ -1,9 +1,10 @@
1
1
  <script lang="ts">
2
2
  import type {Snippet} from 'svelte'
3
+ import {createAttachmentKey} from 'svelte/attachments'
3
4
  import {fly} from 'svelte/transition'
4
5
  import {cn} from '../utils/cn'
5
6
  import {IIIcon} from '../IIIcon'
6
- import {createMenuSearch, isItem, isGroup, isSub, isSeparator, menuStyles, menuItemClass, type MenuItem, type MenuEntry, type SubEntry} from '../utils/menu'
7
+ import {createMenuSearch, getSelectableItems, isItem, isGroup, isSub, isSeparator, menuStyles, menuItemClass, type MenuItem, type MenuEntry, type SubEntry} from '../utils/menu'
7
8
  import {useFloating, portal, clickOutside} from '../utils/menu'
8
9
  import MenuItemContent from '../utils/menu/MenuItemContent.svelte'
9
10
  import MenuSearchInput from '../utils/menu/MenuSearchInput.svelte'
@@ -17,6 +18,21 @@
17
18
  onSelect: (value: string) => void
18
19
  open?: boolean
19
20
  children?: Snippet
21
+ /**
22
+ * Replace the default trigger button with a custom element. Spread `props`
23
+ * onto a single focusable element — it carries the ref, click/keydown
24
+ * handlers, and menu aria attributes. Unlike `children` (which is wrapped in
25
+ * an extra button), this makes your element THE trigger: one tab stop, and
26
+ * keyboard open/navigation works.
27
+ */
28
+ trigger?: Snippet<[{props: Record<string, unknown>; open: boolean}]>
29
+ /**
30
+ * Enable select-style typeahead (non-searchable menus). Typing on the closed
31
+ * trigger picks the closest label match and fires `onSelect` (like
32
+ * `IIDropdownInput`); typing while open moves focus to the match. Off by
33
+ * default so action menus don't fire on a keypress.
34
+ */
35
+ typeahead?: boolean
20
36
  renderItem?: Snippet<[MenuItem]>
21
37
  side?: 'top' | 'right' | 'bottom' | 'left'
22
38
  align?: 'start' | 'center' | 'end'
@@ -36,6 +52,8 @@
36
52
  onSelect,
37
53
  open = $bindable(false),
38
54
  children,
55
+ trigger,
56
+ typeahead = false,
39
57
  renderItem,
40
58
  side = 'bottom',
41
59
  align = 'end',
@@ -114,6 +132,69 @@
114
132
  close()
115
133
  }
116
134
 
135
+ // Select-style typeahead (opt-in via `typeahead`, non-searchable only). Buffer
136
+ // keystrokes for 500ms and match on label substring (so "tu" finds "Alan
137
+ // Turing"). Closed → fire onSelect (set the value, like IIDropdownInput); open
138
+ // → move focus to the match.
139
+ let typeaheadBuffer = ''
140
+ let typeaheadTimer: ReturnType<typeof setTimeout> | undefined
141
+
142
+ function handleTypeahead(char: string) {
143
+ if (!typeahead || searchable) return
144
+ const wasOpen = open
145
+ typeaheadBuffer += char.toLowerCase()
146
+ clearTimeout(typeaheadTimer)
147
+ typeaheadTimer = setTimeout(() => {
148
+ typeaheadBuffer = ''
149
+ }, 500)
150
+
151
+ const match = getSelectableItems(items).find(
152
+ i => !i.disabled && i.label.toLowerCase().includes(typeaheadBuffer),
153
+ )
154
+ if (!match) return
155
+ if (wasOpen) {
156
+ floatingEl?.querySelector<HTMLElement>(`[role="menuitem"][data-value="${CSS.escape(match.value)}"]`)?.focus()
157
+ } else {
158
+ onSelect(match.value)
159
+ }
160
+ }
161
+
162
+ // Open the menu from the trigger via keyboard. Enter/Space fire a native click
163
+ // on a <button> trigger (→ toggle); Arrow keys open it too. Once open, focus
164
+ // moves to the first item (effect above) and handleMenuKeydown takes over.
165
+ function handleTriggerKeydown(e: KeyboardEvent) {
166
+ if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && !open) {
167
+ e.preventDefault()
168
+ openSubIndex = null
169
+ open = true
170
+ return
171
+ }
172
+ if (typeahead && !open && e.key.length === 1 && e.key !== ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) {
173
+ e.preventDefault()
174
+ handleTypeahead(e.key)
175
+ }
176
+ }
177
+
178
+ // Props a custom `trigger` snippet spreads onto its single element — carries the
179
+ // ref (via attachment), click/keydown, and menu aria. Stable attachment key so
180
+ // recomputing on `open` change doesn't re-run the ref attachment.
181
+ const attachTrigger = (node: Element) => {
182
+ triggerEl = node as HTMLElement
183
+ return () => {
184
+ if (triggerEl === node) triggerEl = null
185
+ }
186
+ }
187
+ const triggerAttachmentKey = createAttachmentKey()
188
+ const triggerProps = $derived({
189
+ type: 'button',
190
+ 'aria-haspopup': 'menu',
191
+ 'aria-expanded': open,
192
+ 'data-state': open ? 'open' : 'closed',
193
+ onclick: toggle,
194
+ onkeydown: handleTriggerKeydown,
195
+ [triggerAttachmentKey]: attachTrigger,
196
+ })
197
+
117
198
  function getMenuItems(): HTMLElement[] {
118
199
  if (!floatingEl) return []
119
200
  return Array.from(floatingEl.querySelectorAll<HTMLElement>(':scope > [role="menuitem"]:not([data-disabled]), :scope > [role="group"] > [role="menuitem"]:not([data-disabled])'))
@@ -179,6 +260,11 @@
179
260
  e.preventDefault()
180
261
  close()
181
262
  break
263
+ default:
264
+ if (typeahead && e.key.length === 1 && e.key !== ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) {
265
+ e.preventDefault()
266
+ handleTypeahead(e.key)
267
+ }
182
268
  }
183
269
  }
184
270
 
@@ -213,6 +299,7 @@
213
299
  <div
214
300
  role="menuitem"
215
301
  tabindex="-1"
302
+ data-value={item.value}
216
303
  data-disabled={item.disabled ? '' : undefined}
217
304
  class={menuItemClass({variant: item.variant, searchable, isHighlighted: !item.disabled && index === search.highlightedIndex})}
218
305
  data-search-item={searchable ? '' : undefined}
@@ -331,21 +418,26 @@
331
418
  {/each}
332
419
  {/snippet}
333
420
 
334
- <button
335
- bind:this={triggerEl}
336
- type="button"
337
- aria-haspopup="menu"
338
- aria-expanded={open}
339
- data-state={open ? 'open' : 'closed'}
340
- class={cn(children ? '[all:unset] cursor-default' : defaultTriggerClass, triggerClass, className)}
341
- onclick={toggle}
342
- >
343
- {#if children}
344
- {@render children()}
345
- {:else}
346
- <IIIcon iconName="dots-three-vertical" />
347
- {/if}
348
- </button>
421
+ {#if trigger}
422
+ {@render trigger({props: triggerProps, open})}
423
+ {:else}
424
+ <button
425
+ bind:this={triggerEl}
426
+ type="button"
427
+ aria-haspopup="menu"
428
+ aria-expanded={open}
429
+ data-state={open ? 'open' : 'closed'}
430
+ class={cn(children ? '[all:unset] cursor-default' : defaultTriggerClass, triggerClass, className)}
431
+ onclick={toggle}
432
+ onkeydown={handleTriggerKeydown}
433
+ >
434
+ {#if children}
435
+ {@render children()}
436
+ {:else}
437
+ <IIIcon iconName="dots-three-vertical" />
438
+ {/if}
439
+ </button>
440
+ {/if}
349
441
 
350
442
  {#if open}
351
443
  <div use:portal use:clickOutside={{onClose: close, ignore: () => triggerEl}}>
@@ -5,6 +5,24 @@ type Props = {
5
5
  onSelect: (value: string) => void;
6
6
  open?: boolean;
7
7
  children?: Snippet;
8
+ /**
9
+ * Replace the default trigger button with a custom element. Spread `props`
10
+ * onto a single focusable element — it carries the ref, click/keydown
11
+ * handlers, and menu aria attributes. Unlike `children` (which is wrapped in
12
+ * an extra button), this makes your element THE trigger: one tab stop, and
13
+ * keyboard open/navigation works.
14
+ */
15
+ trigger?: Snippet<[{
16
+ props: Record<string, unknown>;
17
+ open: boolean;
18
+ }]>;
19
+ /**
20
+ * Enable select-style typeahead (non-searchable menus). Typing on the closed
21
+ * trigger picks the closest label match and fires `onSelect` (like
22
+ * `IIDropdownInput`); typing while open moves focus to the match. Off by
23
+ * default so action menus don't fire on a keypress.
24
+ */
25
+ typeahead?: boolean;
8
26
  renderItem?: Snippet<[MenuItem]>;
9
27
  side?: 'top' | 'right' | 'bottom' | 'left';
10
28
  align?: 'start' | 'center' | 'end';
@@ -6,6 +6,16 @@
6
6
  function handleSelect(value: string) {
7
7
  console.log('Selected:', value)
8
8
  }
9
+
10
+ // Custom-trigger demo: reflect the selection in the trigger so typeahead is
11
+ // visibly proven (type "al" → the label becomes "Alan Turing").
12
+ const assigneeLabels: Record<string, string> = {
13
+ me: 'Assign to me',
14
+ none: 'Unassigned',
15
+ ada: 'Ada Lovelace',
16
+ alan: 'Alan Turing',
17
+ }
18
+ let assigneeValue = $state('')
9
19
  </script>
10
20
 
11
21
  <div class="flex flex-col gap-32">
@@ -118,4 +128,35 @@
118
128
  align="start"
119
129
  />
120
130
  </section>
131
+
132
+ <!-- Custom Trigger -->
133
+ <section>
134
+ <h2 class="text-default-emphasis text-primary mb-8">Custom Trigger</h2>
135
+ <p class="text-small text-secondary mb-12">
136
+ Use the <code>trigger</code> snippet to make any element the trigger. Spread
137
+ <code>props</code> onto a single focusable element — it carries the ref, click/keyboard
138
+ handling, and menu aria. Unlike <code>children</code> (wrapped in an extra button), this is
139
+ one tab stop and supports keyboard open + arrow navigation. With <code>typeahead</code>,
140
+ typing on the closed trigger picks the closest match and fires <code>onSelect</code> (like
141
+ <code>IIDropdownInput</code>).
142
+ </p>
143
+ <IIMenu
144
+ items={[
145
+ {label: 'Assign to me', value: 'me'},
146
+ {label: 'Unassign', value: 'none'},
147
+ {type: 'separator'},
148
+ {label: 'Ada Lovelace', value: 'ada'},
149
+ {label: 'Alan Turing', value: 'alan'},
150
+ ]}
151
+ onSelect={value => (assigneeValue = value)}
152
+ align="start"
153
+ typeahead
154
+ >
155
+ {#snippet trigger({props})}
156
+ <IIButton {...props} variant="ghost" iconName="user">
157
+ {assigneeValue ? assigneeLabels[assigneeValue] : 'Assignee'}
158
+ </IIButton>
159
+ {/snippet}
160
+ </IIMenu>
161
+ </section>
121
162
  </div>
@@ -1,18 +1,3 @@
1
- interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
- new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
- $$bindings?: Bindings;
4
- } & Exports;
5
- (internal: unknown, props: {
6
- $$events?: Events;
7
- $$slots?: Slots;
8
- }): Exports & {
9
- $set?: any;
10
- $on?: any;
11
- };
12
- z_$$bindings?: Bindings;
13
- }
14
- declare const IIMenuStories: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
- [evt: string]: CustomEvent<any>;
16
- }, {}, {}, string>;
17
- type IIMenuStories = InstanceType<typeof IIMenuStories>;
1
+ declare const IIMenuStories: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type IIMenuStories = ReturnType<typeof IIMenuStories>;
18
3
  export default IIMenuStories;
@@ -0,0 +1,41 @@
1
+ <script lang="ts">
2
+ import IIMenu from './IIMenu.svelte'
3
+ import {IIButton} from '../IIButton'
4
+
5
+ // Minimal fixture for the keyboard/tab-order/typeahead play tests: a single
6
+ // IIMenu driven by the `trigger` snippet — spread `props` onto one focusable
7
+ // element (here IIButton) so it is THE trigger (one tab stop, keyboard works).
8
+ // The consumer owns the displayed value, so we reflect the selection here.
9
+ let {onSelect}: {onSelect?: (value: string) => void} = $props()
10
+
11
+ const labels: Record<string, string> = {
12
+ me: 'Assign to me',
13
+ none: 'Unassigned',
14
+ ada: 'Ada Lovelace',
15
+ alan: 'Alan Turing',
16
+ }
17
+ let selected = $state('')
18
+
19
+ function handleSelect(value: string) {
20
+ selected = value
21
+ onSelect?.(value)
22
+ }
23
+ </script>
24
+
25
+ <IIMenu
26
+ items={[
27
+ {label: 'Assign to me', value: 'me'},
28
+ {label: 'Unassign', value: 'none'},
29
+ {type: 'separator'},
30
+ {label: 'Ada Lovelace', value: 'ada'},
31
+ {label: 'Alan Turing', value: 'alan'},
32
+ ]}
33
+ onSelect={handleSelect}
34
+ typeahead
35
+ >
36
+ {#snippet trigger({props})}
37
+ <IIButton {...props} variant="ghost" iconName="user">
38
+ {selected ? labels[selected] : 'Assignee'}
39
+ </IIButton>
40
+ {/snippet}
41
+ </IIMenu>
@@ -0,0 +1,6 @@
1
+ type $$ComponentProps = {
2
+ onSelect?: (value: string) => void;
3
+ };
4
+ declare const IIMenuTriggerProof: import("svelte").Component<$$ComponentProps, {}, "">;
5
+ type IIMenuTriggerProof = ReturnType<typeof IIMenuTriggerProof>;
6
+ export default IIMenuTriggerProof;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@insymetri/styleguide",
3
- "version": "0.1.80",
3
+ "version": "0.1.81",
4
4
  "description": "Insymetri shared UI component library built with Svelte 5",
5
5
  "type": "module",
6
6
  "scripts": {