@insymetri/styleguide 0.1.54 → 0.1.56
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.
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {tick} from 'svelte'
|
|
3
2
|
import type {Snippet} from 'svelte'
|
|
4
3
|
import {fly} from 'svelte/transition'
|
|
5
4
|
import {IIIcon} from '../IIIcon'
|
|
@@ -25,6 +24,7 @@
|
|
|
25
24
|
renderSelected?: Snippet<[item: MenuItem]>
|
|
26
25
|
searchable?: boolean
|
|
27
26
|
searchPlaceholder?: string
|
|
27
|
+
autofocus?: boolean
|
|
28
28
|
class?: string
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
renderSelected,
|
|
44
44
|
searchable = false,
|
|
45
45
|
searchPlaceholder = 'Search...',
|
|
46
|
+
autofocus = false,
|
|
46
47
|
class: className,
|
|
47
48
|
}: Props = $props()
|
|
48
49
|
|
|
@@ -67,10 +68,36 @@
|
|
|
67
68
|
let open = $state(false)
|
|
68
69
|
let triggerEl = $state<HTMLElement | null>(null)
|
|
69
70
|
let floatingEl = $state<HTMLElement | null>(null)
|
|
71
|
+
let highlightedIndex = $state(-1)
|
|
72
|
+
|
|
73
|
+
$effect(() => {
|
|
74
|
+
if (autofocus && triggerEl) triggerEl.focus({focusVisible: true} as FocusOptions)
|
|
75
|
+
})
|
|
70
76
|
|
|
71
77
|
const selectedItem = $derived(items.find(i => i.value === value))
|
|
72
78
|
const selectedLabel = $derived(selectedItem?.label ?? placeholder)
|
|
73
79
|
|
|
80
|
+
function scrollHighlightedIntoView() {
|
|
81
|
+
requestAnimationFrame(() => {
|
|
82
|
+
const el = floatingEl?.querySelector<HTMLElement>(`[role="option"][data-index="${highlightedIndex}"]`)
|
|
83
|
+
el?.scrollIntoView({block: 'nearest'})
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function moveHighlight(direction: 1 | -1) {
|
|
88
|
+
const enabled = items.map((item, i) => (item.disabled ? -1 : i)).filter(i => i >= 0)
|
|
89
|
+
if (enabled.length === 0) return
|
|
90
|
+
const currentPos = enabled.indexOf(highlightedIndex)
|
|
91
|
+
const nextPos =
|
|
92
|
+
currentPos === -1
|
|
93
|
+
? direction === 1 ? 0 : enabled.length - 1
|
|
94
|
+
: direction === 1
|
|
95
|
+
? (currentPos >= enabled.length - 1 ? 0 : currentPos + 1)
|
|
96
|
+
: (currentPos <= 0 ? enabled.length - 1 : currentPos - 1)
|
|
97
|
+
highlightedIndex = enabled[nextPos]
|
|
98
|
+
scrollHighlightedIntoView()
|
|
99
|
+
}
|
|
100
|
+
|
|
74
101
|
const search = createMenuSearch({
|
|
75
102
|
getItems: () => items,
|
|
76
103
|
onSelect: handleSelectValue,
|
|
@@ -106,21 +133,17 @@
|
|
|
106
133
|
clearTimeout(typeaheadTimer)
|
|
107
134
|
typeaheadTimer = setTimeout(() => { typeaheadBuffer = '' }, 500)
|
|
108
135
|
|
|
109
|
-
const
|
|
136
|
+
const matchIndex = items.findIndex(
|
|
110
137
|
i => !i.disabled && i.label.toLowerCase().startsWith(typeaheadBuffer)
|
|
111
138
|
)
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
onSelect?.(match.value)
|
|
139
|
+
if (matchIndex >= 0) {
|
|
140
|
+
const match = items[matchIndex]
|
|
115
141
|
if (!wasOpen) {
|
|
116
|
-
|
|
117
|
-
|
|
142
|
+
value = match.value
|
|
143
|
+
onSelect?.(match.value)
|
|
118
144
|
} else {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
el?.focus()
|
|
122
|
-
el?.scrollIntoView({block: 'nearest'})
|
|
123
|
-
})
|
|
145
|
+
highlightedIndex = matchIndex
|
|
146
|
+
scrollHighlightedIntoView()
|
|
124
147
|
}
|
|
125
148
|
}
|
|
126
149
|
}
|
|
@@ -128,15 +151,38 @@
|
|
|
128
151
|
function handleTriggerKeydown(e: KeyboardEvent) {
|
|
129
152
|
if (disabled) return
|
|
130
153
|
|
|
131
|
-
if (e.key === '
|
|
154
|
+
if (e.key === 'Escape' && open) {
|
|
132
155
|
e.preventDefault()
|
|
133
|
-
|
|
156
|
+
close()
|
|
134
157
|
return
|
|
135
158
|
}
|
|
136
159
|
|
|
137
|
-
if (e.key === '
|
|
160
|
+
if (e.key === 'ArrowDown') {
|
|
138
161
|
e.preventDefault()
|
|
139
162
|
if (!open) open = true
|
|
163
|
+
else moveHighlight(1)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
if (e.key === 'ArrowUp') {
|
|
167
|
+
e.preventDefault()
|
|
168
|
+
if (!open) open = true
|
|
169
|
+
else moveHighlight(-1)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (e.key === 'Tab' && open) {
|
|
174
|
+
e.preventDefault()
|
|
175
|
+
moveHighlight(e.shiftKey ? -1 : 1)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
180
|
+
e.preventDefault()
|
|
181
|
+
if (!open) {
|
|
182
|
+
open = true
|
|
183
|
+
} else if (highlightedIndex >= 0 && !items[highlightedIndex]?.disabled) {
|
|
184
|
+
handleSelect(items[highlightedIndex])
|
|
185
|
+
}
|
|
140
186
|
return
|
|
141
187
|
}
|
|
142
188
|
|
|
@@ -163,66 +209,17 @@
|
|
|
163
209
|
}
|
|
164
210
|
})
|
|
165
211
|
|
|
166
|
-
//
|
|
212
|
+
// Initialize highlight to selected (or first enabled) when opened (non-searchable)
|
|
167
213
|
$effect(() => {
|
|
168
|
-
if (open &&
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
214
|
+
if (open && !searchable) {
|
|
215
|
+
const selectedIdx = items.findIndex(i => i.value === value && !i.disabled)
|
|
216
|
+
const firstIdx = items.findIndex(i => !i.disabled)
|
|
217
|
+
highlightedIndex = selectedIdx >= 0 ? selectedIdx : firstIdx
|
|
218
|
+
scrollHighlightedIntoView()
|
|
219
|
+
} else if (!open) {
|
|
220
|
+
highlightedIndex = -1
|
|
175
221
|
}
|
|
176
222
|
})
|
|
177
|
-
|
|
178
|
-
function getOptionItems(): HTMLElement[] {
|
|
179
|
-
if (!floatingEl) return []
|
|
180
|
-
return Array.from(floatingEl.querySelectorAll<HTMLElement>('[role="option"]:not([data-disabled])'))
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function focusItem(optionItems: HTMLElement[], currentIndex: number, direction: 1 | -1) {
|
|
184
|
-
if (optionItems.length === 0) return
|
|
185
|
-
const nextIndex = direction === 1
|
|
186
|
-
? (currentIndex >= optionItems.length - 1 ? 0 : currentIndex + 1)
|
|
187
|
-
: (currentIndex <= 0 ? optionItems.length - 1 : currentIndex - 1)
|
|
188
|
-
optionItems[nextIndex]?.focus()
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function handleListKeydown(e: KeyboardEvent) {
|
|
192
|
-
if (searchable) return
|
|
193
|
-
|
|
194
|
-
const optionItems = getOptionItems()
|
|
195
|
-
const currentIndex = optionItems.indexOf(document.activeElement as HTMLElement)
|
|
196
|
-
|
|
197
|
-
switch (e.key) {
|
|
198
|
-
case 'Tab':
|
|
199
|
-
e.preventDefault()
|
|
200
|
-
focusItem(optionItems, currentIndex, e.shiftKey ? -1 : 1)
|
|
201
|
-
break
|
|
202
|
-
case 'ArrowDown':
|
|
203
|
-
e.preventDefault()
|
|
204
|
-
focusItem(optionItems, currentIndex, 1)
|
|
205
|
-
break
|
|
206
|
-
case 'ArrowUp':
|
|
207
|
-
e.preventDefault()
|
|
208
|
-
focusItem(optionItems, currentIndex, -1)
|
|
209
|
-
break
|
|
210
|
-
case 'Enter':
|
|
211
|
-
e.preventDefault()
|
|
212
|
-
;(document.activeElement as HTMLElement)?.click()
|
|
213
|
-
break
|
|
214
|
-
case 'Escape':
|
|
215
|
-
e.preventDefault()
|
|
216
|
-
close()
|
|
217
|
-
break
|
|
218
|
-
default:
|
|
219
|
-
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
220
|
-
e.preventDefault()
|
|
221
|
-
typeahead(e.key)
|
|
222
|
-
}
|
|
223
|
-
break
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
223
|
</script>
|
|
227
224
|
|
|
228
225
|
<div class="flex flex-col">
|
|
@@ -262,8 +259,7 @@
|
|
|
262
259
|
bind:this={floatingEl}
|
|
263
260
|
role="listbox"
|
|
264
261
|
data-menu-content
|
|
265
|
-
class="min-w-100 bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-dropdown p-4 z-16 outline-none"
|
|
266
|
-
onkeydown={handleListKeydown}
|
|
262
|
+
class="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"
|
|
267
263
|
transition:fly={{y: -4, duration: 150}}
|
|
268
264
|
>
|
|
269
265
|
{#if searchable}
|
|
@@ -275,25 +271,29 @@
|
|
|
275
271
|
/>
|
|
276
272
|
{/if}
|
|
277
273
|
<div class="max-h-300 overflow-y-auto">
|
|
278
|
-
{#each (searchable ? search.filteredItems as MenuItem[] : items) as item (item.value)}
|
|
279
|
-
{@const index = searchable ? search.getItemIndex(item) :
|
|
274
|
+
{#each (searchable ? search.filteredItems as MenuItem[] : items) as item, listIndex (item.value)}
|
|
275
|
+
{@const index = searchable ? search.getItemIndex(item) : listIndex}
|
|
276
|
+
{@const isHighlighted = searchable
|
|
277
|
+
? index === search.highlightedIndex
|
|
278
|
+
: index === highlightedIndex}
|
|
280
279
|
<div
|
|
281
280
|
role="option"
|
|
282
281
|
tabindex="-1"
|
|
283
282
|
aria-selected={value === item.value}
|
|
284
283
|
data-disabled={item.disabled ? '' : undefined}
|
|
284
|
+
data-index={index}
|
|
285
285
|
class={cn(
|
|
286
286
|
'flex items-center justify-between gap-12 px-12 rounded-6 text-dropdown-item cursor-default outline-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed',
|
|
287
287
|
itemDensityClasses[density.value],
|
|
288
288
|
value === item.value && 'text-dropdown-item-selected',
|
|
289
|
-
|
|
290
|
-
searchable && index === search.highlightedIndex && 'bg-dropdown-item-hover'
|
|
289
|
+
isHighlighted && 'bg-dropdown-item-hover'
|
|
291
290
|
)}
|
|
292
291
|
data-search-item={searchable ? '' : undefined}
|
|
293
292
|
onclick={() => handleSelect(item)}
|
|
294
293
|
onfocus={searchable ? search.refocusInput : undefined}
|
|
295
|
-
onpointermove={searchable
|
|
296
|
-
|
|
294
|
+
onpointermove={searchable
|
|
295
|
+
? () => search.setHighlight(index)
|
|
296
|
+
: () => { if (!item.disabled) highlightedIndex = index }}
|
|
297
297
|
>
|
|
298
298
|
{#if renderItem}
|
|
299
299
|
{@render renderItem(item, value === item.value)}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import IIDropdownInput from './IIDropdownInput.svelte'
|
|
3
|
+
import IIModal from '../IIModal/IIModal.svelte'
|
|
4
|
+
import IIButton from '../IIButton/IIButton.svelte'
|
|
5
|
+
|
|
6
|
+
let open = $state(true)
|
|
7
|
+
let value = $state('pto')
|
|
8
|
+
|
|
9
|
+
const items = [
|
|
10
|
+
{label: 'PTO', value: 'pto'},
|
|
11
|
+
{label: 'Sick', value: 'sick'},
|
|
12
|
+
{label: 'Personal', value: 'personal'},
|
|
13
|
+
]
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<IIButton onclick={() => (open = true)}>Open modal</IIButton>
|
|
17
|
+
|
|
18
|
+
{#if open}
|
|
19
|
+
<IIModal bind:open title="Request time off" size="sm" onOpenChange={v => (open = v)}>
|
|
20
|
+
<div class="flex flex-col gap-16">
|
|
21
|
+
<p class="text-small text-secondary m-0">
|
|
22
|
+
Repro: bits-ui Dialog sets <code>body { pointer-events: none }</code> when open. The dropdown's
|
|
23
|
+
portaled listbox needs <code>pointer-events: auto</code> or clicks fall through to the dialog content,
|
|
24
|
+
which triggers <code>clickOutside</code> and closes the dropdown without selecting.
|
|
25
|
+
</p>
|
|
26
|
+
<div class="flex flex-col gap-8">
|
|
27
|
+
<label for="type" class="text-small-emphasis text-secondary">Type</label>
|
|
28
|
+
<IIDropdownInput {items} bind:value matchTriggerWidth autofocus />
|
|
29
|
+
</div>
|
|
30
|
+
<p class="text-small text-secondary m-0">Selected value: <strong>{value}</strong></p>
|
|
31
|
+
</div>
|
|
32
|
+
</IIModal>
|
|
33
|
+
{/if}
|