@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bagelink/vue",
3
3
  "type": "module",
4
- "version": "1.15.41",
4
+ "version": "1.15.43",
5
5
  "description": "Bagel core sdk packages",
6
6
  "author": {
7
7
  "name": "Bagel Studio",
@@ -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
- function onTriggerClick() {
223
- if (!isClickTrigger.value) return
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 }"