|
| 1 | +import { getTabbableElements, getFirstFocusCandidate } from './focustrap_utils.js'; |
| 2 | +import { FOCUSTRAP_DEFAULTS, FOCUSTRAP_STATE_KEY } from './focustrap_constants.js'; |
| 3 | + |
| 4 | +/** |
| 5 | + * v-dt-focustrap directive — trap Tab/Shift+Tab within a container element. |
| 6 | + * |
| 7 | + * Manages initial focus, Tab boundary wrapping, and focus restoration. |
| 8 | + * Does NOT handle Escape or click-outside — that's the component's responsibility. |
| 9 | + * |
| 10 | + * @example |
| 11 | + * // Boolean binding — activate when truthy |
| 12 | + * <div role="dialog" v-dt-focustrap="isOpen" aria-label="Settings"> |
| 13 | + * |
| 14 | + * // Object binding — full configuration |
| 15 | + * <div role="dialog" v-dt-focustrap="{ active: isOpen, initialFocus: '#name-input' }"> |
| 16 | + * |
| 17 | + * // Always active (no binding value) |
| 18 | + * <div role="alertdialog" v-dt-focustrap aria-label="Confirm"> |
| 19 | + * |
| 20 | + * @see https://dialtone.dialpad.com/vue/next/?path=/docs/directives-focustrap--docs |
| 21 | + */ |
| 22 | +export const DtFocustrapDirective = { |
| 23 | + name: 'dt-focustrap-directive', |
| 24 | + |
| 25 | + install (app) { |
| 26 | + app.directive('dt-focustrap', { |
| 27 | + mounted (el, binding) { |
| 28 | + const config = resolveConfig(binding.value); |
| 29 | + el[FOCUSTRAP_STATE_KEY] = createState(); |
| 30 | + |
| 31 | + if (config.active) { |
| 32 | + activate(el, config); |
| 33 | + } |
| 34 | + }, |
| 35 | + |
| 36 | + updated (el, binding) { |
| 37 | + const prev = resolveConfig(binding.oldValue); |
| 38 | + const next = resolveConfig(binding.value); |
| 39 | + const state = el[FOCUSTRAP_STATE_KEY]; |
| 40 | + |
| 41 | + if (!state) return; |
| 42 | + |
| 43 | + if (!prev.active && next.active) { |
| 44 | + activate(el, next); |
| 45 | + } else if (prev.active && !next.active) { |
| 46 | + deactivate(el); |
| 47 | + } |
| 48 | + }, |
| 49 | + |
| 50 | + unmounted (el) { |
| 51 | + const state = el[FOCUSTRAP_STATE_KEY]; |
| 52 | + if (state?.active) { |
| 53 | + deactivate(el); |
| 54 | + } |
| 55 | + cleanup(el); |
| 56 | + delete el[FOCUSTRAP_STATE_KEY]; |
| 57 | + }, |
| 58 | + }); |
| 59 | + }, |
| 60 | +}; |
| 61 | + |
| 62 | +// ── Config resolution ─────────────────────────────────────── |
| 63 | + |
| 64 | +function resolveConfig (value) { |
| 65 | + if (value == null || value === true) { |
| 66 | + return { ...FOCUSTRAP_DEFAULTS, active: true }; |
| 67 | + } |
| 68 | + if (value === false) { |
| 69 | + return { ...FOCUSTRAP_DEFAULTS, active: false }; |
| 70 | + } |
| 71 | + if (typeof value === 'object') { |
| 72 | + return { ...FOCUSTRAP_DEFAULTS, ...value }; |
| 73 | + } |
| 74 | + return { ...FOCUSTRAP_DEFAULTS, active: Boolean(value) }; |
| 75 | +} |
| 76 | + |
| 77 | +// ── State management ──────────────────────────────────────── |
| 78 | + |
| 79 | +function createState () { |
| 80 | + return { |
| 81 | + active: false, |
| 82 | + onKeydown: null, |
| 83 | + previousActiveElement: null, |
| 84 | + restoreFocus: true, |
| 85 | + addedTabindex: false, |
| 86 | + }; |
| 87 | +} |
| 88 | + |
| 89 | +// ── Activate / Deactivate ─────────────────────────────────── |
| 90 | + |
| 91 | +function activate (el, config) { |
| 92 | + const state = el[FOCUSTRAP_STATE_KEY]; |
| 93 | + if (!state || state.active) return; |
| 94 | + |
| 95 | + state.active = true; |
| 96 | + state.restoreFocus = config.restoreFocus; |
| 97 | + state.previousActiveElement = document.activeElement; |
| 98 | + |
| 99 | + // Bind Tab keydown handler |
| 100 | + state.onKeydown = (event) => handleKeydown(event, el); |
| 101 | + el.addEventListener('keydown', state.onKeydown); |
| 102 | + |
| 103 | + // Set initial focus |
| 104 | + setInitialFocus(el, config); |
| 105 | +} |
| 106 | + |
| 107 | +function deactivate (el) { |
| 108 | + const state = el[FOCUSTRAP_STATE_KEY]; |
| 109 | + if (!state || !state.active) return; |
| 110 | + |
| 111 | + state.active = false; |
| 112 | + cleanup(el); |
| 113 | + |
| 114 | + // Restore focus using the config captured at activation time |
| 115 | + if (state.restoreFocus && state.previousActiveElement) { |
| 116 | + try { |
| 117 | + state.previousActiveElement.focus({ preventScroll: true }); |
| 118 | + } catch { |
| 119 | + // Element no longer in DOM or not focusable |
| 120 | + } |
| 121 | + } |
| 122 | + state.previousActiveElement = null; |
| 123 | +} |
| 124 | + |
| 125 | +function cleanup (el) { |
| 126 | + const state = el[FOCUSTRAP_STATE_KEY]; |
| 127 | + if (!state) return; |
| 128 | + if (state.onKeydown) { |
| 129 | + el.removeEventListener('keydown', state.onKeydown); |
| 130 | + state.onKeydown = null; |
| 131 | + } |
| 132 | + if (state.addedTabindex) { |
| 133 | + el.removeAttribute('tabindex'); |
| 134 | + state.addedTabindex = false; |
| 135 | + } |
| 136 | +} |
| 137 | + |
| 138 | +// ── Initial focus ─────────────────────────────────────────── |
| 139 | + |
| 140 | +function resolveInitialFocusTarget (el, initialFocus) { |
| 141 | + if (initialFocus === 'auto' || initialFocus == null) { |
| 142 | + const elements = getTabbableElements(el, { includeNegativeTabIndex: true }); |
| 143 | + return getFirstFocusCandidate(elements); |
| 144 | + } |
| 145 | + if (typeof initialFocus === 'string') return el.querySelector(initialFocus); |
| 146 | + if (initialFocus instanceof HTMLElement) return initialFocus; |
| 147 | + return null; |
| 148 | +} |
| 149 | + |
| 150 | +function focusOrFallback (el, target) { |
| 151 | + if (target) { |
| 152 | + target.focus({ preventScroll: true }); |
| 153 | + return; |
| 154 | + } |
| 155 | + if (!el.hasAttribute('tabindex')) { |
| 156 | + el.setAttribute('tabindex', '-1'); |
| 157 | + const state = el[FOCUSTRAP_STATE_KEY]; |
| 158 | + if (state) state.addedTabindex = true; |
| 159 | + } |
| 160 | + el.focus({ preventScroll: true }); |
| 161 | +} |
| 162 | + |
| 163 | +function setInitialFocus (el, config) { |
| 164 | + if (config.initialFocus === false) return; |
| 165 | + |
| 166 | + // Delay to next microtask to avoid breaking transitions and unwanted scrolling |
| 167 | + Promise.resolve().then(() => { |
| 168 | + const state = el[FOCUSTRAP_STATE_KEY]; |
| 169 | + if (!state?.active) return; |
| 170 | + focusOrFallback(el, resolveInitialFocusTarget(el, config.initialFocus)); |
| 171 | + }); |
| 172 | +} |
| 173 | + |
| 174 | +// ── Tab trapping ──────────────────────────────────────────── |
| 175 | + |
| 176 | +function handleKeydown (event, el) { |
| 177 | + if (event.key !== 'Tab') return; |
| 178 | + |
| 179 | + const elements = getTabbableElements(el); |
| 180 | + |
| 181 | + if (!elements.length) { |
| 182 | + event.preventDefault(); |
| 183 | + return; |
| 184 | + } |
| 185 | + |
| 186 | + // Tab boundaries use DOM order (elements[0] / elements[last]), |
| 187 | + // NOT getFirstFocusCandidate() — the radio-preference logic is for |
| 188 | + // initial focus only, not for Tab wrapping. |
| 189 | + const first = elements[0]; |
| 190 | + const last = elements[elements.length - 1]; |
| 191 | + |
| 192 | + if (event.shiftKey) { |
| 193 | + if (document.activeElement === first) { |
| 194 | + last.focus({ preventScroll: true }); |
| 195 | + event.preventDefault(); |
| 196 | + } |
| 197 | + } else { |
| 198 | + if (document.activeElement === last) { |
| 199 | + first.focus({ preventScroll: true }); |
| 200 | + event.preventDefault(); |
| 201 | + } |
| 202 | + } |
| 203 | +} |
| 204 | + |
| 205 | +export default DtFocustrapDirective; |
0 commit comments