diff --git a/libs/components/src/lib/time-picker/inline-time-picker/inline-time-picker.spec.ts b/libs/components/src/lib/time-picker/inline-time-picker/inline-time-picker.spec.ts index 569cde4438..74d3835009 100644 --- a/libs/components/src/lib/time-picker/inline-time-picker/inline-time-picker.spec.ts +++ b/libs/components/src/lib/time-picker/inline-time-picker/inline-time-picker.spec.ts @@ -1,5 +1,7 @@ import { elementUpdated, fixture } from '@vivid-nx/shared'; -import { InlineTimePicker } from './inline-time-picker.ts'; +import { beforeEach } from 'vitest'; +import { TrappedFocus } from '../../../shared/patterns'; +import { InlineTimePicker } from './inline-time-picker'; import '.'; const COMPONENT_TAG = 'vwc-inline-time-picker'; @@ -24,17 +26,15 @@ describe('vwc-inline-time-picker', () => { const getLabels = (type: 'hours' | 'minutes' | 'seconds' | 'meridies') => getAllPickerItems(type).map((item) => item.innerHTML.trim()); - const pressKey = ( - key: string, - options: KeyboardEventInit = {}, - triggerElement = false - ) => { - const triggeredElement = triggerElement - ? element - : element.shadowRoot!.activeElement; - triggeredElement!.dispatchEvent( - new KeyboardEvent('keydown', { key, bubbles: true, ...options }) - ); + const pressKey = (key: string, options: KeyboardEventInit = {}) => { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + composed: true, + ...options, + }); + element.shadowRoot!.activeElement!.dispatchEvent(event); + return event; }; const isScrolledToTop = (element: HTMLElement) => @@ -596,5 +596,48 @@ describe('vwc-inline-time-picker', () => { expect(isScrolledIntoView(getPickerItem('hours', '00'))).toBe(true); }); }); + + describe('focus trap support', () => { + const originalIgnoreEvent = TrappedFocus.ignoreEvent; + beforeEach(() => { + TrappedFocus.ignoreEvent = vi.fn(); + }); + afterEach(() => { + TrappedFocus.ignoreEvent = originalIgnoreEvent; + }); + + it('should submit Tab keydown events that move focus internally to be ignored by focus traps', () => { + (element.shadowRoot!.querySelector('#hours') as HTMLElement).focus(); + + const event = pressKey('Tab'); + + expect(TrappedFocus.ignoreEvent).toHaveBeenCalledTimes(1); + expect(TrappedFocus.ignoreEvent).toHaveBeenCalledWith(event); + }); + + it('should not submit Tab keydown events that move focus out', () => { + (element.shadowRoot!.querySelector('#hours') as HTMLElement).focus(); + pressKey('Tab', { shiftKey: true }); + (element.shadowRoot!.querySelector('#minutes') as HTMLElement).focus(); + pressKey('Tab'); + + expect(TrappedFocus.ignoreEvent).not.toHaveBeenCalled(); + }); + }); + }); + + describe('focus method', () => { + it('should focus the first picker programmatically to avoid visual focus', async () => { + const firstPicker = element.shadowRoot!.querySelector( + '#hours' + ) as HTMLElement; + const focusSpy = vi.spyOn(firstPicker, 'focus'); + const options = {}; + + element.focus(options); + + expect(element.shadowRoot!.activeElement).toBe(firstPicker); + expect(focusSpy).toHaveBeenCalledWith(options); + }); }); }); diff --git a/libs/components/src/lib/time-picker/inline-time-picker/inline-time-picker.template.ts b/libs/components/src/lib/time-picker/inline-time-picker/inline-time-picker.template.ts index 399a4fe705..2f1b5a906f 100644 --- a/libs/components/src/lib/time-picker/inline-time-picker/inline-time-picker.template.ts +++ b/libs/components/src/lib/time-picker/inline-time-picker/inline-time-picker.template.ts @@ -2,6 +2,7 @@ import { html, repeat, when } from '@microsoft/fast-element'; import { classNames } from '@microsoft/fast-web-utilities'; import { type PickerOption } from '../time/picker'; import { scrollIntoView } from '../../../shared/utils/scrollIntoView'; +import { TrappedFocus } from '../../../shared/patterns'; import type { InlineTimePicker } from './inline-time-picker'; import { type Column, @@ -20,9 +21,7 @@ const onPickerOptionClick = ( column: Column, optionValue: string ) => { - x.$emit('change', column.updatedValue(x, optionValue), { - bubbles: false, - }); + emitChange(x, column.updatedValue(x, optionValue)); scrollToOption(x, column.id, optionValue, 'start'); @@ -57,9 +56,7 @@ const onPickerKeyDown = ( const newRawIndex = index === -1 ? 0 : index + offset; const newIndex = (newRawIndex + options.length) % options.length; const newValue = options[newIndex].value; - x.$emit('change', column.updatedValue(x, newValue), { - bubbles: false, - }); + emitChange(x, column.updatedValue(x, newValue)); scrollToOption(x, column.id, newValue, 'nearest'); } @@ -82,6 +79,26 @@ export const scrollToOption = ( scrollIntoView(element, element.parentElement!, position); }; +const onBaseKeyDown = (x: InlineTimePicker, event: KeyboardEvent) => { + if (event.key === 'Tab') { + const focusableElements = x.shadowRoot!.querySelectorAll('.picker'); + const terminalElement = event.shiftKey + ? focusableElements[0] + : focusableElements[focusableElements.length - 1]; + + if (x.shadowRoot!.activeElement !== terminalElement) { + // TrappedFocus needs to ignore events that will not move focus out of + // the inline time picker + TrappedFocus.ignoreEvent(event); + } + } + return true; +}; + +const emitChange = (x: InlineTimePicker, time: string) => { + x.$emit('change', time, { bubbles: false, composed: false }); +}; + /** * Renders a picker for hours/minutes/etc. using a listbox pattern. */ @@ -119,7 +136,10 @@ const renderPicker = (column: Column) => { }; export const InlineTimePickerTemplate = () => { - return html`
+ return html`
${renderPicker(HoursColumn)} ${renderPicker(MinutesColumn)} ${when(shouldDisplaySecondsPicker, renderPicker(SecondsColumn))} ${when(shouldDisplay12hClock, renderPicker(MeridiesColumn))} diff --git a/libs/components/src/lib/time-picker/inline-time-picker/inline-time-picker.ts b/libs/components/src/lib/time-picker/inline-time-picker/inline-time-picker.ts index 97f0fc2f57..e5c0f04be5 100644 --- a/libs/components/src/lib/time-picker/inline-time-picker/inline-time-picker.ts +++ b/libs/components/src/lib/time-picker/inline-time-picker/inline-time-picker.ts @@ -70,6 +70,14 @@ export class InlineTimePicker extends VividElement { ); } } + + override focus(options?: FocusOptions) { + // Override focus instead of relying on default behavior to prevent visible focus + const firstFocusableElement = this.shadowRoot!.querySelector( + '.picker' + ) as HTMLElement; + firstFocusableElement.focus(options); + } } export interface InlineTimePicker extends Localized {} diff --git a/libs/components/src/shared/patterns/trapped-focus.spec.ts b/libs/components/src/shared/patterns/trapped-focus.spec.ts index 62cdc370a2..74c7a945aa 100644 --- a/libs/components/src/shared/patterns/trapped-focus.spec.ts +++ b/libs/components/src/shared/patterns/trapped-focus.spec.ts @@ -80,4 +80,18 @@ describe('TrappedFocus', () => { expect(event.preventDefault).not.toHaveBeenCalled(); expect(element.shadowRoot!.activeElement).toBe(secondButton); }); + + describe('ignoreEvent', () => { + it('should cause the event to be ignored', () => { + lastButton.focus(); + const event = new KeyboardEvent('keydown', { key: 'Tab' }); + event.preventDefault = vi.fn(); + + TrappedFocus.ignoreEvent(event); + element.dispatchEvent(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(element.shadowRoot!.activeElement).toBe(lastButton); + }); + }); }); diff --git a/libs/components/src/shared/patterns/trapped-focus.ts b/libs/components/src/shared/patterns/trapped-focus.ts index c5be7a96b3..3e3bc24fb3 100644 --- a/libs/components/src/shared/patterns/trapped-focus.ts +++ b/libs/components/src/shared/patterns/trapped-focus.ts @@ -1,4 +1,10 @@ export class TrappedFocus { + private static ignoredEvents = new WeakSet(); + + static ignoreEvent(event: Event) { + this.ignoredEvents.add(event); + } + /** * @returns Whether focus was trapped. * @internal @@ -7,7 +13,7 @@ export class TrappedFocus { event: KeyboardEvent, getFocusableEls: () => NodeListOf ) { - if (event.key === 'Tab') { + if (!TrappedFocus.ignoredEvents.has(event) && event.key === 'Tab') { const focusableEls = getFocusableEls(); const firstFocusableEl = focusableEls[0]; const lastFocusableEl = focusableEls[focusableEls.length - 1];