@bagelink/vue 1.15.41 → 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 +115 -5
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,6 +158,12 @@ 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
169
|
function internalHide(opts?: { restoreFocus?: boolean }) {
|
|
@@ -171,6 +181,62 @@ function internalHide(opts?: { restoreFocus?: boolean }) {
|
|
|
171
181
|
popoverRef.value?.hidePopover()
|
|
172
182
|
}
|
|
173
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
|
+
|
|
174
240
|
const show = internalShow
|
|
175
241
|
function hide() { internalHide() }
|
|
176
242
|
|
|
@@ -219,10 +285,50 @@ let showTimer: ReturnType<typeof setTimeout> | undefined
|
|
|
219
285
|
let hideTimer: ReturnType<typeof setTimeout> | undefined
|
|
220
286
|
function clearTimers() { clearTimeout(showTimer); clearTimeout(hideTimer) }
|
|
221
287
|
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
224
297
|
isOpen() ? internalHide() : internalShow()
|
|
225
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
|
+
}
|
|
226
332
|
function onTriggerMouseenter() {
|
|
227
333
|
if (!isHoverTrigger.value) return
|
|
228
334
|
clearTimeout(hideTimer)
|
|
@@ -272,8 +378,11 @@ defineExpose({ show, hide, shown })
|
|
|
272
378
|
<div
|
|
273
379
|
v-if="!referenceEl"
|
|
274
380
|
ref="triggerWrapRef" v-bind="$attrs"
|
|
381
|
+
aria-haspopup="menu"
|
|
382
|
+
:aria-expanded="shown"
|
|
383
|
+
:aria-controls="bglId"
|
|
275
384
|
@mouseenter="onTriggerMouseenter" @mouseleave="onTriggerMouseleave" @focus="onTriggerMouseenter"
|
|
276
|
-
@blur="onTriggerMouseleave"
|
|
385
|
+
@blur="onTriggerMouseleave" @keydown="onTriggerKeydown"
|
|
277
386
|
>
|
|
278
387
|
<slot name="trigger" :show :hide :shown>
|
|
279
388
|
<Btn
|
|
@@ -286,11 +395,12 @@ defineExpose({ show, hide, shown })
|
|
|
286
395
|
<!-- Popup — teleported to closest dialog[open] or body, promoted to top layer via popover -->
|
|
287
396
|
<Teleport :to="teleportTarget">
|
|
288
397
|
<div
|
|
289
|
-
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"
|
|
290
400
|
style="border-radius: var(--bgl-card-border-radius);"
|
|
291
401
|
:class="{ 'bgl-dropdown--no-positioning': shouldDisablePositioning, 'bg-white shadow': props.card }"
|
|
292
402
|
@toggle="onPopoverToggle" @mouseenter="onPopoverMouseenter" @mouseleave="onPopoverMouseleave"
|
|
293
|
-
@focus="onPopoverMouseenter" @blur="onPopoverMouseleave"
|
|
403
|
+
@focus="onPopoverMouseenter" @blur="onPopoverMouseleave" @keydown="onPopoverKeydown"
|
|
294
404
|
>
|
|
295
405
|
<div class="bgl-dropdown__backdrop" />
|
|
296
406
|
<div class="bgl-dropdown__content overflow-hidden" :class="{ 'display-flex column': props.card }"
|