@insymetri/styleguide 0.1.79 → 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.
Files changed (26) hide show
  1. package/dist/IIDropdownInput/IIDropdownInput.svelte +52 -26
  2. package/dist/IIDropdownInput/IIDropdownInput.svelte.d.ts +21 -0
  3. package/dist/IIDropdownInput/IIDropdownInputStories.svelte +23 -0
  4. package/dist/{IIDropdownMenu/IIDropdownMenu.svelte → IIMenu/IIMenu.svelte} +119 -21
  5. package/dist/IIMenu/IIMenu.svelte.d.ts +41 -0
  6. package/dist/{IIDropdownMenu/IIDropdownMenuStories.svelte → IIMenu/IIMenuStories.svelte} +48 -7
  7. package/dist/IIMenu/IIMenuStories.svelte.d.ts +3 -0
  8. package/dist/{IIDropdownMenu/IIDropdownMenuSub.svelte.d.ts → IIMenu/IIMenuSub.svelte.d.ts} +3 -3
  9. package/dist/{IIDropdownMenu/IIDropdownMenuSubSimple.svelte.d.ts → IIMenu/IIMenuSubSimple.svelte.d.ts} +3 -3
  10. package/dist/IIMenu/IIMenuTriggerProof.svelte +41 -0
  11. package/dist/IIMenu/IIMenuTriggerProof.svelte.d.ts +6 -0
  12. package/dist/IIMenu/index.d.ts +1 -0
  13. package/dist/IIMenu/index.js +1 -0
  14. package/dist/IIModal/IIModal.svelte +5 -4
  15. package/dist/index.d.ts +3 -1
  16. package/dist/index.js +3 -1
  17. package/dist/style/base.css +23 -0
  18. package/dist/utils/menu/menu-styles.d.ts +3 -3
  19. package/dist/utils/menu/menu-styles.js +6 -3
  20. package/package.json +1 -1
  21. package/dist/IIDropdownMenu/IIDropdownMenu.svelte.d.ts +0 -19
  22. package/dist/IIDropdownMenu/IIDropdownMenuStories.svelte.d.ts +0 -18
  23. package/dist/IIDropdownMenu/index.d.ts +0 -1
  24. package/dist/IIDropdownMenu/index.js +0 -1
  25. /package/dist/{IIDropdownMenu/IIDropdownMenuSub.svelte → IIMenu/IIMenuSub.svelte} +0 -0
  26. /package/dist/{IIDropdownMenu/IIDropdownMenuSubSimple.svelte → IIMenu/IIMenuSubSimple.svelte} +0 -0
@@ -1,5 +1,6 @@
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 {IIIcon} from '../IIIcon'
5
6
  import {cn} from '../utils/cn'
@@ -18,15 +19,34 @@
18
19
  disabled?: boolean
19
20
  error?: boolean
20
21
  errorMessage?: string
22
+ /** Fired when the selected value changes. */
23
+ onValueChange?: (value: string) => void
24
+ /** @deprecated Use `onValueChange`. */
21
25
  onSelect?: (value: string) => void
22
26
  onblur?: () => void
23
27
  matchTriggerWidth?: boolean
24
28
  renderItem?: Snippet<[item: MenuItem, selected: boolean]>
25
29
  renderSelected?: Snippet<[item: MenuItem]>
30
+ /**
31
+ * Replace the default combobox field with a custom trigger. Spread `props`
32
+ * onto your trigger element/component — it carries the ref, click/keydown
33
+ * handlers, and combobox aria attributes. `open`, `selectedItem`, and
34
+ * `selectedLabel` are provided for rendering. When set, `class`, `label`,
35
+ * and `renderSelected` no longer affect the (now custom) trigger.
36
+ */
37
+ trigger?: Snippet<[{
38
+ props: Record<string, unknown>
39
+ open: boolean
40
+ selectedItem: MenuItem | undefined
41
+ selectedLabel: string
42
+ }]>
26
43
  searchable?: boolean
27
44
  searchPlaceholder?: string
28
45
  autofocus?: boolean
46
+ /** Classes for the trigger (root) element. */
29
47
  class?: string
48
+ /** Classes for the dropdown panel (popover surface). */
49
+ contentClass?: string
30
50
  }
31
51
 
32
52
  let {
@@ -37,15 +57,18 @@
37
57
  disabled = false,
38
58
  error = false,
39
59
  errorMessage,
60
+ onValueChange,
40
61
  onSelect,
41
62
  onblur,
42
63
  matchTriggerWidth = false,
43
64
  renderItem,
44
65
  renderSelected,
66
+ trigger,
45
67
  searchable = false,
46
68
  searchPlaceholder = 'Search...',
47
69
  autofocus = false,
48
70
  class: className,
71
+ contentClass,
49
72
  }: Props = $props()
50
73
 
51
74
  const showError = $derived(error || !!errorMessage)
@@ -79,6 +102,25 @@
79
102
  const selectedItem = $derived(items.find(i => i.value === value))
80
103
  const selectedLabel = $derived(selectedItem?.label ?? placeholder)
81
104
 
105
+ // Props a custom `trigger` snippet spreads onto its element. The attachment
106
+ // key and function are stable, so recomputing this derived object when `open`
107
+ // changes (for aria-expanded) does not re-run the ref attachment.
108
+ const attachTrigger = (node: Element) => {
109
+ triggerEl = node as HTMLElement
110
+ return () => { if (triggerEl === node) triggerEl = null }
111
+ }
112
+ const triggerAttachmentKey = createAttachmentKey()
113
+ const triggerProps = $derived({
114
+ role: 'combobox',
115
+ 'aria-haspopup': 'listbox',
116
+ 'aria-expanded': open,
117
+ type: 'button',
118
+ disabled: disabled || undefined,
119
+ onclick: toggle,
120
+ onkeydown: handleTriggerKeydown,
121
+ [triggerAttachmentKey]: attachTrigger,
122
+ })
123
+
82
124
  function scrollHighlightedIntoView() {
83
125
  requestAnimationFrame(() => {
84
126
  const el = floatingEl?.querySelector<HTMLElement>(`[role="option"][data-index="${highlightedIndex}"]`)
@@ -118,6 +160,7 @@
118
160
  if (item.disabled) return
119
161
  value = item.value
120
162
  open = false
163
+ onValueChange?.(item.value)
121
164
  onSelect?.(item.value)
122
165
  onblur?.()
123
166
  triggerEl?.focus()
@@ -145,13 +188,15 @@
145
188
  clearTimeout(typeaheadTimer)
146
189
  typeaheadTimer = setTimeout(() => { typeaheadBuffer = '' }, 500)
147
190
 
191
+ // Substring match (so "tu" finds "Alan Turing"), not just prefix.
148
192
  const matchIndex = items.findIndex(
149
- i => !i.disabled && i.label.toLowerCase().startsWith(typeaheadBuffer)
193
+ i => !i.disabled && i.label.toLowerCase().includes(typeaheadBuffer)
150
194
  )
151
195
  if (matchIndex >= 0) {
152
196
  const match = items[matchIndex]
153
197
  if (!wasOpen) {
154
198
  value = match.value
199
+ onValueChange?.(match.value)
155
200
  onSelect?.(match.value)
156
201
  } else {
157
202
  highlightedIndex = matchIndex
@@ -240,6 +285,9 @@
240
285
  {#if label}
241
286
  <span class="text-small-emphasis text-secondary mb-4">{label}</span>
242
287
  {/if}
288
+ {#if trigger}
289
+ {@render trigger({props: triggerProps, open, selectedItem, selectedLabel})}
290
+ {:else}
243
291
  <button
244
292
  bind:this={triggerEl}
245
293
  type="button"
@@ -266,6 +314,7 @@
266
314
  {/if}
267
315
  <IIIcon iconName="caret-down" />
268
316
  </button>
317
+ {/if}
269
318
 
270
319
  {#if open}
271
320
  <div use:portal use:clickOutside={{onClose: close, ignore: () => triggerEl}}>
@@ -273,7 +322,7 @@
273
322
  bind:this={floatingEl}
274
323
  role="listbox"
275
324
  data-menu-content
276
- class="flex flex-col min-w-100 bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-dropdown p-4 z-16 pointer-events-auto outline-none overflow-hidden"
325
+ class={cn('flex flex-col min-w-100 bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-dropdown p-4 z-16 pointer-events-auto outline-none overflow-hidden', contentClass)}
277
326
  transition:fly={{y: -4, duration: 150}}
278
327
  >
279
328
  {#if searchable}
@@ -284,7 +333,7 @@
284
333
  onkeydown={(e) => search.handleKeydown(e, '[data-menu-content]')}
285
334
  />
286
335
  {/if}
287
- <div class="dd-scroll min-h-0 overflow-y-auto -mr-2">
336
+ <div class="ii-menu-scroll min-h-0 overflow-y-auto -mr-2">
288
337
  {#each (searchable ? search.filteredItems as MenuItem[] : items) as item, listIndex (item.value)}
289
338
  {@const index = searchable ? search.getItemIndex(item) : listIndex}
290
339
  {@const isHighlighted = searchable
@@ -346,26 +395,3 @@
346
395
  <span class="text-tiny text-error mt-4" data-field-error>{errorMessage}</span>
347
396
  {/if}
348
397
  </div>
349
-
350
- <style>
351
- /* Opt back into ::-webkit-scrollbar styling so we can set a custom (narrow)
352
- thumb width: the global standard scrollbar-width/color (base.css) otherwise
353
- disables the WebKit pseudo-elements in Chromium. */
354
- .dd-scroll {
355
- scrollbar-width: auto;
356
- scrollbar-color: auto;
357
- }
358
- .dd-scroll::-webkit-scrollbar {
359
- width: 4px;
360
- }
361
- .dd-scroll::-webkit-scrollbar-track {
362
- background: transparent;
363
- }
364
- .dd-scroll::-webkit-scrollbar-thumb {
365
- background-color: var(--ii-gray-300);
366
- border-radius: 9999px;
367
- }
368
- .dd-scroll::-webkit-scrollbar-thumb:hover {
369
- background-color: var(--ii-gray-400);
370
- }
371
- </style>
@@ -8,15 +8,36 @@ type Props = {
8
8
  disabled?: boolean;
9
9
  error?: boolean;
10
10
  errorMessage?: string;
11
+ /** Fired when the selected value changes. */
12
+ onValueChange?: (value: string) => void;
13
+ /** @deprecated Use `onValueChange`. */
11
14
  onSelect?: (value: string) => void;
12
15
  onblur?: () => void;
13
16
  matchTriggerWidth?: boolean;
14
17
  renderItem?: Snippet<[item: MenuItem, selected: boolean]>;
15
18
  renderSelected?: Snippet<[item: MenuItem]>;
19
+ /**
20
+ * Replace the default combobox field with a custom trigger. Spread `props`
21
+ * onto your trigger element/component — it carries the ref, click/keydown
22
+ * handlers, and combobox aria attributes. `open`, `selectedItem`, and
23
+ * `selectedLabel` are provided for rendering. When set, `class`, `label`,
24
+ * and `renderSelected` no longer affect the (now custom) trigger.
25
+ */
26
+ trigger?: Snippet<[
27
+ {
28
+ props: Record<string, unknown>;
29
+ open: boolean;
30
+ selectedItem: MenuItem | undefined;
31
+ selectedLabel: string;
32
+ }
33
+ ]>;
16
34
  searchable?: boolean;
17
35
  searchPlaceholder?: string;
18
36
  autofocus?: boolean;
37
+ /** Classes for the trigger (root) element. */
19
38
  class?: string;
39
+ /** Classes for the dropdown panel (popover surface). */
40
+ contentClass?: string;
20
41
  };
21
42
  declare const IIDropdownInput: import("svelte").Component<Props, {}, "value">;
22
43
  type IIDropdownInput = ReturnType<typeof IIDropdownInput>;
@@ -1,10 +1,12 @@
1
1
  <script lang="ts">
2
2
  import IIDropdownInput from './IIDropdownInput.svelte'
3
3
  import {IIIcon} from '../IIIcon'
4
+ import {cn} from '../utils/cn'
4
5
  import type {IconName} from '../icons'
5
6
 
6
7
  let basicValue = $state<string | undefined>(undefined)
7
8
  let disabledItemValue = $state<string | undefined>(undefined)
9
+ let customTriggerValue = $state<string | undefined>(undefined)
8
10
 
9
11
  const statusItems = [
10
12
  {label: 'Active', value: 'active'},
@@ -85,4 +87,25 @@
85
87
  </IIDropdownInput>
86
88
  </div>
87
89
  </section>
90
+
91
+ <!-- Custom Trigger -->
92
+ <section>
93
+ <h2 class="text-default-emphasis text-primary mb-8">Custom Trigger</h2>
94
+ <p class="text-small text-secondary mb-12">Use the <code>trigger</code> snippet to replace the default field with any element. Spread <code>props</code> to wire up the ref, click/keyboard handling, and combobox aria attributes.</p>
95
+ <IIDropdownInput items={statusItems} bind:value={customTriggerValue}>
96
+ {#snippet trigger({props, open, selectedItem, selectedLabel})}
97
+ <button
98
+ {...props}
99
+ class={cn(
100
+ 'inline-flex items-center gap-6 py-6 px-12 rounded-control border border-button-secondary bg-input-bg text-small text-button-secondary cursor-default outline-none transition-colors hover:border-button-secondary-hover focus-visible:ring-3 focus-visible:ring-primary',
101
+ open && 'border-button-secondary-hover'
102
+ )}
103
+ >
104
+ <IIIcon iconName="funnel" class="w-14 h-14" />
105
+ <span>{selectedItem ? selectedLabel : 'Filter'}</span>
106
+ <IIIcon iconName="caret-down" class="w-12 h-12" />
107
+ </button>
108
+ {/snippet}
109
+ </IIDropdownInput>
110
+ </section>
88
111
  </div>
@@ -1,30 +1,50 @@
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'
10
11
  import MenuNoResults from '../utils/menu/MenuNoResults.svelte'
11
12
  import MenuItemTooltip from '../utils/menu/MenuItemTooltip.svelte'
12
- import IIDropdownMenuSub from './IIDropdownMenuSub.svelte'
13
- import IIDropdownMenuSubSimple from './IIDropdownMenuSubSimple.svelte'
13
+ import IIMenuSub from './IIMenuSub.svelte'
14
+ import IIMenuSubSimple from './IIMenuSubSimple.svelte'
14
15
 
15
16
  type Props = {
16
17
  items: MenuEntry[]
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'
23
39
  collisionPadding?: number
40
+ /** @deprecated Use `class` — it now targets the trigger. */
24
41
  triggerClass?: string
25
42
  searchable?: boolean
26
43
  searchPlaceholder?: string
44
+ /** Classes for the trigger (root) element. */
27
45
  class?: string
46
+ /** Classes for the menu panel (popover surface). */
47
+ contentClass?: string
28
48
  }
29
49
 
30
50
  let {
@@ -32,6 +52,8 @@
32
52
  onSelect,
33
53
  open = $bindable(false),
34
54
  children,
55
+ trigger,
56
+ typeahead = false,
35
57
  renderItem,
36
58
  side = 'bottom',
37
59
  align = 'end',
@@ -40,6 +62,7 @@
40
62
  searchable = false,
41
63
  searchPlaceholder = 'Search...',
42
64
  class: className,
65
+ contentClass,
43
66
  }: Props = $props()
44
67
 
45
68
  let triggerEl = $state<HTMLElement | null>(null)
@@ -64,6 +87,7 @@
64
87
  placement: placement as any,
65
88
  offset: 4,
66
89
  shift: {padding: collisionPadding},
90
+ constrainHeight: true,
67
91
  })
68
92
 
69
93
  const search = createMenuSearch({
@@ -108,6 +132,69 @@
108
132
  close()
109
133
  }
110
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
+
111
198
  function getMenuItems(): HTMLElement[] {
112
199
  if (!floatingEl) return []
113
200
  return Array.from(floatingEl.querySelectorAll<HTMLElement>(':scope > [role="menuitem"]:not([data-disabled]), :scope > [role="group"] > [role="menuitem"]:not([data-disabled])'))
@@ -173,6 +260,11 @@
173
260
  e.preventDefault()
174
261
  close()
175
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
+ }
176
268
  }
177
269
  }
178
270
 
@@ -207,6 +299,7 @@
207
299
  <div
208
300
  role="menuitem"
209
301
  tabindex="-1"
302
+ data-value={item.value}
210
303
  data-disabled={item.disabled ? '' : undefined}
211
304
  class={menuItemClass({variant: item.variant, searchable, isHighlighted: !item.disabled && index === search.highlightedIndex})}
212
305
  data-search-item={searchable ? '' : undefined}
@@ -270,7 +363,7 @@
270
363
  </div>
271
364
  {#if isOpen}
272
365
  {#if entry.searchable}
273
- <IIDropdownMenuSub
366
+ <IIMenuSub
274
367
  items={entry.items}
275
368
  onSelect={handleSelect}
276
369
  onClose={() => {
@@ -282,7 +375,7 @@
282
375
  renderItemContent={itemContent}
283
376
  />
284
377
  {:else}
285
- <IIDropdownMenuSubSimple
378
+ <IIMenuSubSimple
286
379
  items={entry.items}
287
380
  onSelect={handleSelect}
288
381
  onClose={() => {
@@ -325,21 +418,26 @@
325
418
  {/each}
326
419
  {/snippet}
327
420
 
328
- <button
329
- bind:this={triggerEl}
330
- type="button"
331
- aria-haspopup="menu"
332
- aria-expanded={open}
333
- data-state={open ? 'open' : 'closed'}
334
- class={cn(children ? cn('[all:unset] cursor-default', triggerClass) : defaultTriggerClass)}
335
- onclick={toggle}
336
- >
337
- {#if children}
338
- {@render children()}
339
- {:else}
340
- <IIIcon iconName="dots-three-vertical" />
341
- {/if}
342
- </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}
343
441
 
344
442
  {#if open}
345
443
  <div use:portal use:clickOutside={{onClose: close, ignore: () => triggerEl}}>
@@ -347,7 +445,7 @@
347
445
  bind:this={floatingEl}
348
446
  role="menu"
349
447
  data-menu-content
350
- class={cn(menuStyles.content, className)}
448
+ class={cn(menuStyles.content, contentClass)}
351
449
  onkeydown={handleMenuKeydown}
352
450
  transition:fly={{y: -4, duration: 150}}
353
451
  >
@@ -0,0 +1,41 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type MenuItem, type MenuEntry } from '../utils/menu';
3
+ type Props = {
4
+ items: MenuEntry[];
5
+ onSelect: (value: string) => void;
6
+ open?: boolean;
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;
26
+ renderItem?: Snippet<[MenuItem]>;
27
+ side?: 'top' | 'right' | 'bottom' | 'left';
28
+ align?: 'start' | 'center' | 'end';
29
+ collisionPadding?: number;
30
+ /** @deprecated Use `class` — it now targets the trigger. */
31
+ triggerClass?: string;
32
+ searchable?: boolean;
33
+ searchPlaceholder?: string;
34
+ /** Classes for the trigger (root) element. */
35
+ class?: string;
36
+ /** Classes for the menu panel (popover surface). */
37
+ contentClass?: string;
38
+ };
39
+ declare const IIMenu: import("svelte").Component<Props, {}, "open">;
40
+ type IIMenu = ReturnType<typeof IIMenu>;
41
+ export default IIMenu;
@@ -1,11 +1,21 @@
1
1
  <script lang="ts">
2
- import IIDropdownMenu from './IIDropdownMenu.svelte'
2
+ import IIMenu from './IIMenu.svelte'
3
3
  import {IIButton} from '../IIButton'
4
4
  import {IIIcon} from '../IIIcon'
5
5
 
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">
@@ -13,7 +23,7 @@
13
23
  <section>
14
24
  <h2 class="text-default-emphasis text-primary mb-8">With Separators</h2>
15
25
  <p class="text-small text-secondary mb-12">Menu entries can include separator dividers.</p>
16
- <IIDropdownMenu
26
+ <IIMenu
17
27
  items={[
18
28
  {label: 'Edit', value: 'edit'},
19
29
  {label: 'Duplicate', value: 'duplicate'},
@@ -29,7 +39,7 @@
29
39
  <section>
30
40
  <h2 class="text-default-emphasis text-primary mb-8">With Groups</h2>
31
41
  <p class="text-small text-secondary mb-12">Items organized into labeled groups with headings.</p>
32
- <IIDropdownMenu
42
+ <IIMenu
33
43
  items={[
34
44
  {
35
45
  type: 'group',
@@ -57,7 +67,7 @@
57
67
  <section>
58
68
  <h2 class="text-default-emphasis text-primary mb-8">Custom Trigger</h2>
59
69
  <p class="text-small text-secondary mb-12">Using the children snippet with triggerClass for custom trigger styling.</p>
60
- <IIDropdownMenu
70
+ <IIMenu
61
71
  items={[
62
72
  {label: 'Profile', value: 'profile'},
63
73
  {label: 'Settings', value: 'settings'},
@@ -72,14 +82,14 @@
72
82
  <span>Account</span>
73
83
  <IIIcon iconName="caret-down" class="w-12 h-12" />
74
84
  {/snippet}
75
- </IIDropdownMenu>
85
+ </IIMenu>
76
86
  </section>
77
87
 
78
88
  <!-- Searchable -->
79
89
  <section>
80
90
  <h2 class="text-default-emphasis text-primary mb-8">Searchable</h2>
81
91
  <p class="text-small text-secondary mb-12">A search input at the top filters menu items as you type.</p>
82
- <IIDropdownMenu
92
+ <IIMenu
83
93
  items={[
84
94
  {label: 'Edit', value: 'edit'},
85
95
  {label: 'Duplicate', value: 'duplicate'},
@@ -98,7 +108,7 @@
98
108
  <!-- Mixed Entries -->
99
109
  <section>
100
110
  <h2 class="text-default-emphasis text-primary mb-8">Mixed: Items + Separators + Groups</h2>
101
- <IIDropdownMenu
111
+ <IIMenu
102
112
  items={[
103
113
  {label: 'Quick Action', value: 'quick'},
104
114
  {type: 'separator'},
@@ -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>
@@ -0,0 +1,3 @@
1
+ declare const IIMenuStories: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type IIMenuStories = ReturnType<typeof IIMenuStories>;
3
+ export default IIMenuStories;
@@ -8,6 +8,6 @@ type Props = {
8
8
  searchPlaceholder?: string;
9
9
  renderItemContent: Snippet<[MenuItem]>;
10
10
  };
11
- declare const IIDropdownMenuSub: import("svelte").Component<Props, {}, "">;
12
- type IIDropdownMenuSub = ReturnType<typeof IIDropdownMenuSub>;
13
- export default IIDropdownMenuSub;
11
+ declare const IIMenuSub: import("svelte").Component<Props, {}, "">;
12
+ type IIMenuSub = ReturnType<typeof IIMenuSub>;
13
+ export default IIMenuSub;
@@ -8,6 +8,6 @@ type Props = {
8
8
  renderItemContent: Snippet<[MenuItem]>;
9
9
  renderSubMenu: Snippet<[SubEntry]>;
10
10
  };
11
- declare const IIDropdownMenuSubSimple: import("svelte").Component<Props, {}, "">;
12
- type IIDropdownMenuSubSimple = ReturnType<typeof IIDropdownMenuSubSimple>;
13
- export default IIDropdownMenuSubSimple;
11
+ declare const IIMenuSubSimple: import("svelte").Component<Props, {}, "">;
12
+ type IIMenuSubSimple = ReturnType<typeof IIMenuSubSimple>;
13
+ export default IIMenuSubSimple;
@@ -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;
@@ -0,0 +1 @@
1
+ export { default as IIMenu } from './IIMenu.svelte';
@@ -0,0 +1 @@
1
+ export { default as IIMenu } from './IIMenu.svelte';
@@ -49,7 +49,7 @@
49
49
  onOpenChange?.(newOpen)
50
50
  }
51
51
 
52
- // Portaled menu/listbox content (IIDropdownMenu, bits-ui DropdownMenu/Popover/Select, etc.)
52
+ // Portaled menu/listbox content (IIMenu, bits-ui DropdownMenu/Popover/Select, etc.)
53
53
  // lives in document.body outside Dialog.Content, so bits-ui's DismissibleLayer would treat
54
54
  // interactions with them as "outside" the modal and close it. Preserve those events.
55
55
  function isInsidePortaledMenu(target: Element | null): boolean {
@@ -89,12 +89,13 @@
89
89
  // property — both compose with the open/close slide animation, which owns `transform`
90
90
  // (translateY). A transform-based center would be clobbered by the animation and
91
91
  // shift the modal for the animation's duration.
92
- 'fixed left-0 right-0 mx-auto bg-surface border border-strong rounded-12 w-[calc(100%-20px)] flex flex-col z-15 data-[state=open]:animate-modal-in data-[state=closed]:animate-modal-out motion-reduce:animate-none focus:outline-none',
92
+ 'fixed left-0 right-0 mx-auto bg-surface border border-primary rounded-12 w-[calc(100%-20px)] flex flex-col z-15 data-[state=open]:animate-modal-in data-[state=closed]:animate-modal-out motion-reduce:animate-none focus:outline-none',
93
93
  // Vertical: anchored 15% from the top (default) vs. centered. When anchored, height
94
94
  // is capped so a tall modal still clears the bottom (15% + 78vh leaves ~7vh below);
95
95
  // centered can safely use the full 90vh since -50% keeps it within the viewport.
96
96
  centered ? 'top-1/2 [translate:0_-50%] max-h-[90vh]' : 'top-[15%] max-h-[78vh]',
97
- overlay === 'none' ? 'shadow-floating' : 'shadow-modal',
97
+ // Same edge for every overlay: shadow-modal (previously no-overlay used shadow-floating).
98
+ 'shadow-modal',
98
99
  size === 'sm' && 'max-w-400',
99
100
  size === 'md' && 'max-w-500',
100
101
  size === 'lg' && 'max-w-700',
@@ -107,7 +108,7 @@
107
108
  <div class="flex items-start justify-between gap-12 px-24 pt-24 shrink-0">
108
109
  <div class="flex flex-col min-w-0 flex-1">
109
110
  <div class="flex items-center gap-12 min-w-0">
110
- <Dialog.Title class="text-h2 text-body m-0 shrink-0">{title}</Dialog.Title>
111
+ <Dialog.Title class="text-h3 text-body m-0 shrink-0">{title}</Dialog.Title>
111
112
  {#if titleAccessory}
112
113
  {@render titleAccessory()}
113
114
  {/if}
package/dist/index.d.ts CHANGED
@@ -16,7 +16,9 @@ export { IICombobox } from './IICombobox';
16
16
  export { IIContextMenu } from './IIContextMenu';
17
17
  export { IIDateInput } from './IIDateInput';
18
18
  export { IIDropdownInput } from './IIDropdownInput';
19
- export { IIDropdownMenu } from './IIDropdownMenu';
19
+ export { IIMenu } from './IIMenu';
20
+ /** @deprecated Renamed to `IIMenu` (it is an action menu, not a select). Use `IIMenu`. */
21
+ export { IIMenu as IIDropdownMenu } from './IIMenu';
20
22
  export { IIEditableBadges } from './IIEditableBadges';
21
23
  export { IIEditableText } from './IIEditableText';
22
24
  export { IIEmptyState } from './IIEmptyState';
package/dist/index.js CHANGED
@@ -20,7 +20,9 @@ export { IICombobox } from './IICombobox';
20
20
  export { IIContextMenu } from './IIContextMenu';
21
21
  export { IIDateInput } from './IIDateInput';
22
22
  export { IIDropdownInput } from './IIDropdownInput';
23
- export { IIDropdownMenu } from './IIDropdownMenu';
23
+ export { IIMenu } from './IIMenu';
24
+ /** @deprecated Renamed to `IIMenu` (it is an action menu, not a select). Use `IIMenu`. */
25
+ export { IIMenu as IIDropdownMenu } from './IIMenu';
24
26
  export { IIEditableBadges } from './IIEditableBadges';
25
27
  export { IIEditableText } from './IIEditableText';
26
28
  export { IIEmptyState } from './IIEmptyState';
@@ -77,3 +77,26 @@
77
77
  background-color: var(--ii-gray-400);
78
78
  }
79
79
  }
80
+
81
+ /* Shared narrow, rounded scrollbar for menu/dropdown scroll areas. Unlayered so
82
+ it outranks the @layer base `*` rules above. Setting scrollbar-width/color to
83
+ auto opts this element back into ::-webkit-scrollbar styling — the global
84
+ `scrollbar-width: thin` otherwise disables the WebKit pseudo-elements in
85
+ Chromium, so a custom (narrow) thumb width can't be applied. */
86
+ .ii-menu-scroll {
87
+ scrollbar-width: auto;
88
+ scrollbar-color: auto;
89
+ }
90
+ .ii-menu-scroll::-webkit-scrollbar {
91
+ width: 4px;
92
+ }
93
+ .ii-menu-scroll::-webkit-scrollbar-track {
94
+ background: transparent;
95
+ }
96
+ .ii-menu-scroll::-webkit-scrollbar-thumb {
97
+ background-color: var(--ii-gray-300);
98
+ border-radius: 9999px;
99
+ }
100
+ .ii-menu-scroll::-webkit-scrollbar-thumb:hover {
101
+ background-color: var(--ii-gray-400);
102
+ }
@@ -2,10 +2,10 @@ export declare const menuStyles: {
2
2
  readonly item: "flex items-center gap-8 px-12 py-8 rounded-4 text-small cursor-default select-none outline-none data-[disabled]:opacity-50 motion-reduce:transition-none";
3
3
  readonly itemDefault: "text-dropdown-item hover:bg-dropdown-item-hover focus:bg-dropdown-item-hover data-[highlighted]:bg-dropdown-item-hover data-[highlighted]:outline-none data-[state=open]:bg-dropdown-item-hover data-[disabled]:hover:bg-transparent data-[disabled]:focus:bg-transparent";
4
4
  readonly itemDestructive: "text-error hover:bg-error-bg focus:bg-error-bg data-[highlighted]:bg-error-bg data-[highlighted]:outline-none data-[disabled]:hover:bg-transparent data-[disabled]:focus:bg-transparent";
5
- readonly content: "min-w-48 max-h-300 overflow-y-auto bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-dropdown p-4 z-12 pointer-events-auto outline-none";
6
- readonly subContent: "min-w-48 max-h-300 overflow-y-auto bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-submenu p-4 z-12 outline-none animate-fade-in motion-reduce:animate-none";
5
+ readonly content: "ii-menu-scroll min-w-48 overflow-y-auto bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-dropdown p-4 z-12 pointer-events-auto outline-none";
6
+ readonly subContent: "ii-menu-scroll min-w-48 max-h-300 overflow-y-auto bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-submenu p-4 z-12 outline-none animate-fade-in motion-reduce:animate-none";
7
7
  readonly searchableSubContent: "min-w-48 bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-submenu p-4 z-12 pointer-events-auto outline-none overflow-hidden animate-fade-in motion-reduce:animate-none";
8
- readonly scrollableItems: "max-h-250 overflow-y-auto overflow-x-hidden";
8
+ readonly scrollableItems: "ii-menu-scroll max-h-250 overflow-y-auto overflow-x-hidden";
9
9
  readonly separator: "h-1 bg-muted -mx-4 my-4";
10
10
  readonly groupHeading: "text-tiny-emphasis text-secondary px-12 py-4 uppercase select-none";
11
11
  };
@@ -3,10 +3,13 @@ export const menuStyles = {
3
3
  item: 'flex items-center gap-8 px-12 py-8 rounded-4 text-small cursor-default select-none outline-none data-[disabled]:opacity-50 motion-reduce:transition-none',
4
4
  itemDefault: 'text-dropdown-item hover:bg-dropdown-item-hover focus:bg-dropdown-item-hover data-[highlighted]:bg-dropdown-item-hover data-[highlighted]:outline-none data-[state=open]:bg-dropdown-item-hover data-[disabled]:hover:bg-transparent data-[disabled]:focus:bg-transparent',
5
5
  itemDestructive: 'text-error hover:bg-error-bg focus:bg-error-bg data-[highlighted]:bg-error-bg data-[highlighted]:outline-none data-[disabled]:hover:bg-transparent data-[disabled]:focus:bg-transparent',
6
- content: 'min-w-48 max-h-300 overflow-y-auto bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-dropdown p-4 z-12 pointer-events-auto outline-none',
7
- subContent: 'min-w-48 max-h-300 overflow-y-auto bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-submenu p-4 z-12 outline-none animate-fade-in motion-reduce:animate-none',
6
+ // content: no fixed max-height IIMenu passes constrainHeight to
7
+ // useFloating, which sizes maxHeight to the available viewport space so the
8
+ // menu grows to fit its items and only scrolls near the screen edge.
9
+ content: 'ii-menu-scroll min-w-48 overflow-y-auto bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-dropdown p-4 z-12 pointer-events-auto outline-none',
10
+ subContent: 'ii-menu-scroll min-w-48 max-h-300 overflow-y-auto bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-submenu p-4 z-12 outline-none animate-fade-in motion-reduce:animate-none',
8
11
  searchableSubContent: 'min-w-48 bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-submenu p-4 z-12 pointer-events-auto outline-none overflow-hidden animate-fade-in motion-reduce:animate-none',
9
- scrollableItems: 'max-h-250 overflow-y-auto overflow-x-hidden',
12
+ scrollableItems: 'ii-menu-scroll max-h-250 overflow-y-auto overflow-x-hidden',
10
13
  separator: 'h-1 bg-muted -mx-4 my-4',
11
14
  groupHeading: 'text-tiny-emphasis text-secondary px-12 py-4 uppercase select-none',
12
15
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@insymetri/styleguide",
3
- "version": "0.1.79",
3
+ "version": "0.1.81",
4
4
  "description": "Insymetri shared UI component library built with Svelte 5",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,19 +0,0 @@
1
- import type { Snippet } from 'svelte';
2
- import { type MenuItem, type MenuEntry } from '../utils/menu';
3
- type Props = {
4
- items: MenuEntry[];
5
- onSelect: (value: string) => void;
6
- open?: boolean;
7
- children?: Snippet;
8
- renderItem?: Snippet<[MenuItem]>;
9
- side?: 'top' | 'right' | 'bottom' | 'left';
10
- align?: 'start' | 'center' | 'end';
11
- collisionPadding?: number;
12
- triggerClass?: string;
13
- searchable?: boolean;
14
- searchPlaceholder?: string;
15
- class?: string;
16
- };
17
- declare const IIDropdownMenu: import("svelte").Component<Props, {}, "open">;
18
- type IIDropdownMenu = ReturnType<typeof IIDropdownMenu>;
19
- export default IIDropdownMenu;
@@ -1,18 +0,0 @@
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 IIDropdownMenuStories: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
- [evt: string]: CustomEvent<any>;
16
- }, {}, {}, string>;
17
- type IIDropdownMenuStories = InstanceType<typeof IIDropdownMenuStories>;
18
- export default IIDropdownMenuStories;
@@ -1 +0,0 @@
1
- export { default as IIDropdownMenu } from './IIDropdownMenu.svelte';
@@ -1 +0,0 @@
1
- export { default as IIDropdownMenu } from './IIDropdownMenu.svelte';