@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.
- package/dist/IIDropdownInput/IIDropdownInput.svelte +2 -1
- package/dist/IIMenu/IIMenu.svelte +108 -16
- package/dist/IIMenu/IIMenu.svelte.d.ts +18 -0
- package/dist/IIMenu/IIMenuStories.svelte +41 -0
- package/dist/IIMenu/IIMenuStories.svelte.d.ts +2 -17
- package/dist/IIMenu/IIMenuTriggerProof.svelte +41 -0
- package/dist/IIMenu/IIMenuTriggerProof.svelte.d.ts +6 -0
- package/package.json +1 -1
|
@@ -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().
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
{
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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>
|