@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bagelink/vue",
3
3
  "type": "module",
4
- "version": "1.15.39",
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,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
- if (!props.noAutoFocus) {
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
- internalHide()
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
- function onTriggerClick() {
218
- 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
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 }"