@bagelink/vue 1.15.39 → 1.15.43
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/components/Dropdown.vue.d.ts.map +1 -1
- package/dist/index.cjs +37 -37
- package/dist/index.mjs +6546 -6480
- package/package.json +1 -1
- package/src/components/Dropdown.vue +125 -10
package/package.json
CHANGED
|
@@ -63,6 +63,10 @@ const shown = defineModel('shown', { type: Boolean, default: false })
|
|
|
63
63
|
const triggerWrapRef = ref<HTMLElement | null>(null)
|
|
64
64
|
const popoverRef = ref<HTMLElement | null>(null)
|
|
65
65
|
|
|
66
|
+
// Tracks whether the popover was opened via keyboard (Enter/Space/Arrow) so we know
|
|
67
|
+
// whether to move focus into the menu on open.
|
|
68
|
+
let openedViaKeyboard = false
|
|
69
|
+
|
|
66
70
|
// Unique id for this popover instance, used to track parent–child relationships across teleported popovers
|
|
67
71
|
const bglId = `bgl-dd-${++_bglIdCounter}`
|
|
68
72
|
const teleportTarget = ref<string | Element>('body')
|
|
@@ -154,11 +158,21 @@ async function internalShow() {
|
|
|
154
158
|
popoverRef.value.showPopover()
|
|
155
159
|
await updatePosition()
|
|
156
160
|
popoverRef.value.style.opacity = ''
|
|
161
|
+
|
|
162
|
+
// For keyboard-driven opens, move focus into the menu so arrow keys work immediately.
|
|
163
|
+
if (openedViaKeyboard) {
|
|
164
|
+
focusFirstItem()
|
|
165
|
+
openedViaKeyboard = false
|
|
166
|
+
}
|
|
157
167
|
}
|
|
158
168
|
|
|
159
|
-
function internalHide() {
|
|
169
|
+
function internalHide(opts?: { restoreFocus?: boolean }) {
|
|
160
170
|
if (!isOpen()) return
|
|
161
|
-
|
|
171
|
+
// Only restore focus to the trigger for keyboard-driven closes (Esc/click).
|
|
172
|
+
// Hover-driven closes must NOT steal focus back, otherwise the trigger gets a
|
|
173
|
+
// stray :focus-visible ring after the pointer has already moved elsewhere.
|
|
174
|
+
const restoreFocus = opts?.restoreFocus ?? true
|
|
175
|
+
if (restoreFocus && !props.noAutoFocus) {
|
|
162
176
|
const focusable = triggerWrapRef.value?.querySelector<HTMLElement>(
|
|
163
177
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
164
178
|
)
|
|
@@ -167,6 +181,62 @@ function internalHide() {
|
|
|
167
181
|
popoverRef.value?.hidePopover()
|
|
168
182
|
}
|
|
169
183
|
|
|
184
|
+
// --- Keyboard accessibility helpers -------------------------------------------------
|
|
185
|
+
|
|
186
|
+
const FOCUSABLE_SELECTOR = [
|
|
187
|
+
'a[href]',
|
|
188
|
+
'button:not([disabled])',
|
|
189
|
+
'input:not([disabled])',
|
|
190
|
+
'select:not([disabled])',
|
|
191
|
+
'textarea:not([disabled])',
|
|
192
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
193
|
+
].join(',')
|
|
194
|
+
|
|
195
|
+
function getFocusableItems(): HTMLElement[] {
|
|
196
|
+
if (!popoverRef.value) return []
|
|
197
|
+
return Array.from(popoverRef.value.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
|
|
198
|
+
.filter(el => el.offsetParent !== null || el === document.activeElement)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function focusItemAt(index: number) {
|
|
202
|
+
const items = getFocusableItems()
|
|
203
|
+
if (items.length === 0) return
|
|
204
|
+
const wrapped = (index + items.length) % items.length
|
|
205
|
+
items[wrapped]?.focus()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Move focus into the menu (first focusable item) — used for keyboard-driven opens.
|
|
209
|
+
function focusFirstItem() {
|
|
210
|
+
if (props.noAutoFocus) return
|
|
211
|
+
focusItemAt(0)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Arrow-key navigation while the popover is open.
|
|
215
|
+
function onPopoverKeydown(e: KeyboardEvent) {
|
|
216
|
+
const items = getFocusableItems()
|
|
217
|
+
if (items.length === 0) return
|
|
218
|
+
const currentIndex = items.indexOf(document.activeElement as HTMLElement)
|
|
219
|
+
|
|
220
|
+
switch (e.key) {
|
|
221
|
+
case 'ArrowDown':
|
|
222
|
+
e.preventDefault()
|
|
223
|
+
focusItemAt(currentIndex < 0 ? 0 : currentIndex + 1)
|
|
224
|
+
break
|
|
225
|
+
case 'ArrowUp':
|
|
226
|
+
e.preventDefault()
|
|
227
|
+
focusItemAt(currentIndex < 0 ? items.length - 1 : currentIndex - 1)
|
|
228
|
+
break
|
|
229
|
+
case 'Home':
|
|
230
|
+
e.preventDefault()
|
|
231
|
+
focusItemAt(0)
|
|
232
|
+
break
|
|
233
|
+
case 'End':
|
|
234
|
+
e.preventDefault()
|
|
235
|
+
focusItemAt(items.length - 1)
|
|
236
|
+
break
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
170
240
|
const show = internalShow
|
|
171
241
|
function hide() { internalHide() }
|
|
172
242
|
|
|
@@ -203,7 +273,8 @@ function onDocumentPointerDown(e: PointerEvent) {
|
|
|
203
273
|
if (p === popoverRef.value) continue
|
|
204
274
|
if (p.contains(target) && myId && p.dataset.bglOwner === myId) return
|
|
205
275
|
}
|
|
206
|
-
|
|
276
|
+
// Pointer landed elsewhere — let the click move focus naturally, don't yank it back to the trigger.
|
|
277
|
+
internalHide({ restoreFocus: false })
|
|
207
278
|
}
|
|
208
279
|
|
|
209
280
|
// Esc key — LIFO via shared composable (nested layers close first)
|
|
@@ -214,10 +285,50 @@ let showTimer: ReturnType<typeof setTimeout> | undefined
|
|
|
214
285
|
let hideTimer: ReturnType<typeof setTimeout> | undefined
|
|
215
286
|
function clearTimers() { clearTimeout(showTimer); clearTimeout(hideTimer) }
|
|
216
287
|
|
|
217
|
-
|
|
218
|
-
|
|
288
|
+
// Guard against double toggles within the same tick. The <Btn> trigger handles keydown.enter/space
|
|
289
|
+
// itself AND the browser also fires a native click for Enter/Space on a <button>, which would
|
|
290
|
+
// otherwise toggle the popover twice (open → immediately close, the "opens for a second" bug).
|
|
291
|
+
let lastToggleAt = 0
|
|
292
|
+
function toggle(viaKeyboard: boolean) {
|
|
293
|
+
const now = Date.now()
|
|
294
|
+
if (now - lastToggleAt < 50) return
|
|
295
|
+
lastToggleAt = now
|
|
296
|
+
openedViaKeyboard = viaKeyboard
|
|
219
297
|
isOpen() ? internalHide() : internalShow()
|
|
220
298
|
}
|
|
299
|
+
|
|
300
|
+
function onTriggerClick(e?: MouseEvent) {
|
|
301
|
+
if (!isClickTrigger.value) return
|
|
302
|
+
// A click via keyboard (Enter/Space) reports detail === 0; a real pointer click reports >= 1.
|
|
303
|
+
toggle(!!e && e.detail === 0)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Keyboard activation on the trigger. Works for ALL trigger types (including hover-only) so the
|
|
307
|
+
// dropdown is reachable by keyboard users — WCAG requires hover-revealed content to also open on focus.
|
|
308
|
+
function onTriggerKeydown(e: KeyboardEvent) {
|
|
309
|
+
switch (e.key) {
|
|
310
|
+
case 'Enter':
|
|
311
|
+
case ' ':
|
|
312
|
+
case 'Spacebar':
|
|
313
|
+
e.preventDefault()
|
|
314
|
+
toggle(true)
|
|
315
|
+
break
|
|
316
|
+
case 'ArrowDown':
|
|
317
|
+
e.preventDefault()
|
|
318
|
+
openedViaKeyboard = true
|
|
319
|
+
// Arrow keys are a deliberate request to navigate, so move focus into the menu
|
|
320
|
+
// even when noAutoFocus is set (noAutoFocus only suppresses focus-on-open).
|
|
321
|
+
if (isOpen()) focusItemAt(0)
|
|
322
|
+
else internalShow()
|
|
323
|
+
break
|
|
324
|
+
case 'ArrowUp':
|
|
325
|
+
e.preventDefault()
|
|
326
|
+
openedViaKeyboard = true
|
|
327
|
+
if (isOpen()) focusItemAt(-1)
|
|
328
|
+
else internalShow()
|
|
329
|
+
break
|
|
330
|
+
}
|
|
331
|
+
}
|
|
221
332
|
function onTriggerMouseenter() {
|
|
222
333
|
if (!isHoverTrigger.value) return
|
|
223
334
|
clearTimeout(hideTimer)
|
|
@@ -226,7 +337,7 @@ function onTriggerMouseenter() {
|
|
|
226
337
|
function onTriggerMouseleave() {
|
|
227
338
|
if (!isHoverTrigger.value) return
|
|
228
339
|
clearTimeout(showTimer)
|
|
229
|
-
hideTimer = setTimeout(internalHide, resolvedDelay.value.hide)
|
|
340
|
+
hideTimer = setTimeout(() => internalHide({ restoreFocus: false }), resolvedDelay.value.hide)
|
|
230
341
|
}
|
|
231
342
|
function onPopoverMouseenter() {
|
|
232
343
|
if (!isPopperHoverTrigger.value) return
|
|
@@ -234,7 +345,7 @@ function onPopoverMouseenter() {
|
|
|
234
345
|
}
|
|
235
346
|
function onPopoverMouseleave() {
|
|
236
347
|
if (!isPopperHoverTrigger.value) return
|
|
237
|
-
hideTimer = setTimeout(internalHide, resolvedDelay.value.hide)
|
|
348
|
+
hideTimer = setTimeout(() => internalHide({ restoreFocus: false }), resolvedDelay.value.hide)
|
|
238
349
|
}
|
|
239
350
|
|
|
240
351
|
function onScroll() {
|
|
@@ -267,8 +378,11 @@ defineExpose({ show, hide, shown })
|
|
|
267
378
|
<div
|
|
268
379
|
v-if="!referenceEl"
|
|
269
380
|
ref="triggerWrapRef" v-bind="$attrs"
|
|
381
|
+
aria-haspopup="menu"
|
|
382
|
+
:aria-expanded="shown"
|
|
383
|
+
:aria-controls="bglId"
|
|
270
384
|
@mouseenter="onTriggerMouseenter" @mouseleave="onTriggerMouseleave" @focus="onTriggerMouseenter"
|
|
271
|
-
@blur="onTriggerMouseleave"
|
|
385
|
+
@blur="onTriggerMouseleave" @keydown="onTriggerKeydown"
|
|
272
386
|
>
|
|
273
387
|
<slot name="trigger" :show :hide :shown>
|
|
274
388
|
<Btn
|
|
@@ -281,11 +395,12 @@ defineExpose({ show, hide, shown })
|
|
|
281
395
|
<!-- Popup — teleported to closest dialog[open] or body, promoted to top layer via popover -->
|
|
282
396
|
<Teleport :to="teleportTarget">
|
|
283
397
|
<div
|
|
284
|
-
ref="popoverRef" popover="manual" class="bgl-dropdown m-0 p-0 border-none"
|
|
398
|
+
:id="bglId" ref="popoverRef" popover="manual" class="bgl-dropdown m-0 p-0 border-none"
|
|
399
|
+
role="menu"
|
|
285
400
|
style="border-radius: var(--bgl-card-border-radius);"
|
|
286
401
|
:class="{ 'bgl-dropdown--no-positioning': shouldDisablePositioning, 'bg-white shadow': props.card }"
|
|
287
402
|
@toggle="onPopoverToggle" @mouseenter="onPopoverMouseenter" @mouseleave="onPopoverMouseleave"
|
|
288
|
-
@focus="onPopoverMouseenter" @blur="onPopoverMouseleave"
|
|
403
|
+
@focus="onPopoverMouseenter" @blur="onPopoverMouseleave" @keydown="onPopoverKeydown"
|
|
289
404
|
>
|
|
290
405
|
<div class="bgl-dropdown__backdrop" />
|
|
291
406
|
<div class="bgl-dropdown__content overflow-hidden" :class="{ 'display-flex column': props.card }"
|