From 6f22c6975ae16f6f3430ba519e74f9cc639fd2bc Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Sat, 7 Dec 2024 14:49:45 +0300 Subject: [PATCH] feat(roving-focus): added Roving Focus Direcrivces --- .commitlintrc.cjs | 1 + packages/primitives/roving-focus/README.md | 3 + packages/primitives/roving-focus/index.ts | 2 + .../primitives/roving-focus/ng-package.json | 5 + .../src/roving-focus-group.directive.ts | 143 ++++++++++++ .../src/roving-focus-item.directive.ts | 103 +++++++++ packages/primitives/roving-focus/src/utils.ts | 52 +++++ .../stories/roving-focus-events.component.ts | 30 +++ .../stories/roving-focus.docs.mdx | 33 +++ .../stories/roving-focus.stories.ts | 205 ++++++++++++++++++ 10 files changed, 577 insertions(+) create mode 100644 packages/primitives/roving-focus/README.md create mode 100644 packages/primitives/roving-focus/index.ts create mode 100644 packages/primitives/roving-focus/ng-package.json create mode 100644 packages/primitives/roving-focus/src/roving-focus-group.directive.ts create mode 100644 packages/primitives/roving-focus/src/roving-focus-item.directive.ts create mode 100644 packages/primitives/roving-focus/src/utils.ts create mode 100644 packages/primitives/roving-focus/stories/roving-focus-events.component.ts create mode 100644 packages/primitives/roving-focus/stories/roving-focus.docs.mdx create mode 100644 packages/primitives/roving-focus/stories/roving-focus.stories.ts diff --git a/.commitlintrc.cjs b/.commitlintrc.cjs index 49bb150f..b786beb1 100644 --- a/.commitlintrc.cjs +++ b/.commitlintrc.cjs @@ -49,6 +49,7 @@ const config = { 'kbd', 'radix-docs', 'radix-ssr-testing', + 'roving-focus', 'showcase-taxonomy', 'progress', 'shadcn', diff --git a/packages/primitives/roving-focus/README.md b/packages/primitives/roving-focus/README.md new file mode 100644 index 00000000..443ee5d7 --- /dev/null +++ b/packages/primitives/roving-focus/README.md @@ -0,0 +1,3 @@ +# @radix-ng/primitives/roving-focus + +Secondary entry point of `@radix-ng/primitives`. diff --git a/packages/primitives/roving-focus/index.ts b/packages/primitives/roving-focus/index.ts new file mode 100644 index 00000000..a1d68880 --- /dev/null +++ b/packages/primitives/roving-focus/index.ts @@ -0,0 +1,2 @@ +export * from './src/roving-focus-group.directive'; +export * from './src/roving-focus-item.directive'; diff --git a/packages/primitives/roving-focus/ng-package.json b/packages/primitives/roving-focus/ng-package.json new file mode 100644 index 00000000..bebf62dc --- /dev/null +++ b/packages/primitives/roving-focus/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "index.ts" + } +} diff --git a/packages/primitives/roving-focus/src/roving-focus-group.directive.ts b/packages/primitives/roving-focus/src/roving-focus-group.directive.ts new file mode 100644 index 00000000..da19cc59 --- /dev/null +++ b/packages/primitives/roving-focus/src/roving-focus-group.directive.ts @@ -0,0 +1,143 @@ +import { + booleanAttribute, + Directive, + ElementRef, + EventEmitter, + inject, + Input, + NgZone, + Output, + signal +} from '@angular/core'; +import { Direction, ENTRY_FOCUS, EVENT_OPTIONS, focusFirst, Orientation } from './utils'; + +@Directive({ + selector: '[rdxRovingFocusGroup]', + standalone: true, + host: { + '[attr.data-orientation]': 'dataOrientation', + '[attr.tabindex]': 'tabIndex', + '(focus)': 'handleFocus($event)', + '(mouseup)': 'handleMouseUp()', + '(mousedown)': 'handleMouseDown()', + style: 'outline: none;' + } +}) +export class RdxRovingFocusGroupDirective { + private readonly ngZone = inject(NgZone); + private readonly elementRef = inject(ElementRef); + + @Input() orientation: Orientation | undefined; + @Input() dir: Direction = 'ltr'; + @Input({ transform: booleanAttribute }) loop: boolean = false; + @Input({ transform: booleanAttribute }) preventScrollOnEntryFocus: boolean = false; + + @Output() entryFocus = new EventEmitter(); + @Output() currentTabStopIdChange = new EventEmitter(); + + /** @ignore */ + readonly currentTabStopId = signal(null); + + /** @ignore */ + readonly focusableItems = signal([]); + + private readonly isClickFocus = signal(false); + private readonly isTabbingBackOut = signal(false); + private readonly focusableItemsCount = signal(0); + + /** @ignore */ + get dataOrientation() { + return this.orientation || 'horizontal'; + } + + /** @ignore */ + get tabIndex() { + return this.isTabbingBackOut() || this.getFocusableItemsCount() === 0 ? -1 : 0; + } + + /** @ignore */ + handleMouseUp() { + // reset `isClickFocus` after 1 tick because handleFocus might not triggered due to focused element + this.ngZone.runOutsideAngular(() => { + // eslint-disable-next-line promise/catch-or-return,promise/always-return + Promise.resolve().then(() => { + this.ngZone.run(() => { + this.isClickFocus.set(false); + }); + }); + }); + } + + /** @ignore */ + handleFocus(event: FocusEvent) { + // We normally wouldn't need this check, because we already check + // that the focus is on the current target and not bubbling to it. + // We do this because Safari doesn't focus buttons when clicked, and + // instead, the wrapper will get focused and not through a bubbling event. + const isKeyboardFocus = !this.isClickFocus(); + + if ( + event.currentTarget === this.elementRef.nativeElement && + event.target === event.currentTarget && + isKeyboardFocus && + !this.isTabbingBackOut() + ) { + const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS); + this.elementRef.nativeElement.dispatchEvent(entryFocusEvent); + this.entryFocus.emit(entryFocusEvent); + + if (!entryFocusEvent.defaultPrevented) { + const items = this.focusableItems().filter((item) => item.dataset['disabled'] !== ''); + const activeItem = items.find((item) => item.getAttribute('data-active') === 'true'); + const currentItem = items.find((item) => item.id === this.currentTabStopId()); + const candidateItems = [activeItem, currentItem, ...items].filter(Boolean) as HTMLElement[]; + + focusFirst(candidateItems, this.preventScrollOnEntryFocus); + } + } + this.isClickFocus.set(false); + } + + /** @ignore */ + handleMouseDown() { + this.isClickFocus.set(true); + } + + /** @ignore */ + onItemFocus(tabStopId: string) { + this.currentTabStopId.set(tabStopId); + this.currentTabStopIdChange.emit(tabStopId); + } + + /** @ignore */ + onItemShiftTab() { + this.isTabbingBackOut.set(true); + } + + /** @ignore */ + onFocusableItemAdd() { + this.focusableItemsCount.update((count) => count + 1); + } + + /** @ignore */ + onFocusableItemRemove() { + this.focusableItemsCount.update((count) => Math.max(0, count - 1)); + } + + /** @ignore */ + registerItem(item: HTMLElement) { + const currentItems = this.focusableItems(); + this.focusableItems.set([...currentItems, item]); + } + + /** @ignore */ + unregisterItem(item: HTMLElement) { + const currentItems = this.focusableItems(); + this.focusableItems.set(currentItems.filter((el) => el !== item)); + } + + /** @ignore */ + getFocusableItemsCount() { + return this.focusableItemsCount(); + } +} diff --git a/packages/primitives/roving-focus/src/roving-focus-item.directive.ts b/packages/primitives/roving-focus/src/roving-focus-item.directive.ts new file mode 100644 index 00000000..00efa694 --- /dev/null +++ b/packages/primitives/roving-focus/src/roving-focus-item.directive.ts @@ -0,0 +1,103 @@ +import { booleanAttribute, computed, Directive, ElementRef, inject, Input, OnDestroy, OnInit } from '@angular/core'; +import { RdxRovingFocusGroupDirective } from './roving-focus-group.directive'; +import { focusFirst, generateId, getFocusIntent, wrapArray } from './utils'; + +@Directive({ + selector: '[rdxRovingFocusItem]', + standalone: true, + host: { + '[attr.tabindex]': 'tabIndex', + + '(mousedown)': 'handleMouseDown($event)', + '(keydown)': 'handleKeydown($event)', + '(focus)': 'onFocus()' + } +}) +export class RdxRovingFocusItemDirective implements OnInit, OnDestroy { + private readonly elementRef = inject(ElementRef); + private readonly parent = inject(RdxRovingFocusGroupDirective); + + @Input({ transform: booleanAttribute }) focusable: boolean = true; + @Input({ transform: booleanAttribute }) active: boolean = true; + @Input() tabStopId: string | undefined; + @Input({ transform: booleanAttribute }) allowShiftKey: boolean = false; + + private readonly id = computed(() => this.tabStopId || generateId()); + + /** @ignore */ + readonly isCurrentTabStop = computed(() => this.parent.currentTabStopId() === this.id()); + + /** @ignore */ + ngOnInit() { + if (this.focusable) { + this.parent.registerItem(this.elementRef.nativeElement); + this.parent.onFocusableItemAdd(); + } + } + + /** @ignore */ + ngOnDestroy() { + if (this.focusable) { + this.parent.unregisterItem(this.elementRef.nativeElement); + this.parent.onFocusableItemRemove(); + } + } + + /** @ignore */ + get tabIndex() { + return this.isCurrentTabStop() ? 0 : -1; + } + + /** @ignore */ + handleMouseDown(event: MouseEvent) { + if (!this.focusable) { + // We prevent focusing non-focusable items on `mousedown`. + // Even though the item has tabIndex={-1}, that only means take it out of the tab order. + event.preventDefault(); + } else { + // Safari doesn't focus a button when clicked so we run our logic on mousedown also + this.parent.onItemFocus(this.id()); + } + } + + /** @ignore */ + onFocus() { + if (this.focusable) { + this.parent.onItemFocus(this.id()); + } + } + + /** @ignore */ + handleKeydown(event: KeyboardEvent) { + if (event.key === 'Tab' && event.shiftKey) { + this.parent.onItemShiftTab(); + return; + } + + if (event.target !== this.elementRef.nativeElement) return; + + const focusIntent = getFocusIntent(event, this.parent.orientation, this.parent.dir); + if (focusIntent !== undefined) { + if (event.metaKey || event.ctrlKey || event.altKey || (this.allowShiftKey ? false : event.shiftKey)) { + return; + } + + event.preventDefault(); + + let candidateNodes = this.parent.focusableItems().filter((item) => item.dataset['disabled'] !== ''); + + if (focusIntent === 'last') { + candidateNodes.reverse(); + } else if (focusIntent === 'prev' || focusIntent === 'next') { + if (focusIntent === 'prev') candidateNodes.reverse(); + const currentIndex = candidateNodes.indexOf(this.elementRef.nativeElement); + + candidateNodes = this.parent.loop + ? wrapArray(candidateNodes, currentIndex + 1) + : candidateNodes.slice(currentIndex + 1); + } + + focusFirst(candidateNodes, false); + } + } +} diff --git a/packages/primitives/roving-focus/src/utils.ts b/packages/primitives/roving-focus/src/utils.ts new file mode 100644 index 00000000..2ade1d17 --- /dev/null +++ b/packages/primitives/roving-focus/src/utils.ts @@ -0,0 +1,52 @@ +export type Orientation = 'horizontal' | 'vertical'; +export type Direction = 'ltr' | 'rtl'; + +export const ENTRY_FOCUS = 'rovingFocusGroup.onEntryFocus'; +export const EVENT_OPTIONS = { bubbles: false, cancelable: true }; + +export const MAP_KEY_TO_FOCUS_INTENT: Record = { + ArrowLeft: 'prev', + ArrowUp: 'prev', + ArrowRight: 'next', + ArrowDown: 'next', + PageUp: 'first', + Home: 'first', + PageDown: 'last', + End: 'last' +}; + +export function getDirectionAwareKey(key: string, dir?: Direction) { + if (dir !== 'rtl') return key; + return key === 'ArrowLeft' ? 'ArrowRight' : key === 'ArrowRight' ? 'ArrowLeft' : key; +} + +type FocusIntent = 'first' | 'last' | 'prev' | 'next'; + +export function getFocusIntent(event: KeyboardEvent, orientation?: Orientation, dir?: Direction) { + const key = getDirectionAwareKey(event.key, dir); + if (orientation === 'vertical' && ['ArrowLeft', 'ArrowRight'].includes(key)) return undefined; + if (orientation === 'horizontal' && ['ArrowUp', 'ArrowDown'].includes(key)) return undefined; + return MAP_KEY_TO_FOCUS_INTENT[key]; +} + +export function focusFirst(candidates: HTMLElement[], preventScroll = false, rootNode?: Document | ShadowRoot) { + const PREVIOUSLY_FOCUSED_ELEMENT = rootNode?.activeElement ?? document.activeElement; + for (const candidate of candidates) { + // if focus is already where we want to go, we don't want to keep going through the candidates + if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return; + candidate.focus({ preventScroll }); + if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return; + } +} + +/** + * Wraps an array around itself at a given start index + * Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']` + */ +export function wrapArray(array: T[], startIndex: number) { + return array.map((_, index) => array[(startIndex + index) % array.length]); +} + +export function generateId(): string { + return `rf-item-${Math.random().toString(36).slice(2, 11)}`; +} diff --git a/packages/primitives/roving-focus/stories/roving-focus-events.component.ts b/packages/primitives/roving-focus/stories/roving-focus-events.component.ts new file mode 100644 index 00000000..922918c5 --- /dev/null +++ b/packages/primitives/roving-focus/stories/roving-focus-events.component.ts @@ -0,0 +1,30 @@ +import { Component } from '@angular/core'; +import { RdxRovingFocusGroupDirective, RdxRovingFocusItemDirective } from '@radix-ng/primitives/roving-focus'; + +@Component({ + selector: 'rvg-events', + standalone: true, + imports: [RdxRovingFocusItemDirective, RdxRovingFocusGroupDirective], + template: ` +
+ + + +
+ ` +}) +export class RovingFocusEventsComponent { + onEntryFocus(event: Event) { + console.log('Entry focus triggered:', event); + } + + onTabStopChange(tabStopId: string | null) { + console.log('Current tab stop changed to:', tabStopId); + } +} diff --git a/packages/primitives/roving-focus/stories/roving-focus.docs.mdx b/packages/primitives/roving-focus/stories/roving-focus.docs.mdx new file mode 100644 index 00000000..b49fd6ac --- /dev/null +++ b/packages/primitives/roving-focus/stories/roving-focus.docs.mdx @@ -0,0 +1,33 @@ +import { ArgTypes, Canvas, Meta } from '@storybook/blocks'; +import * as Stories from './roving-focus.stories'; +import { RdxRovingFocusGroupDirective } from '../src/roving-focus-group.directive'; +import { RdxRovingFocusItemDirective } from '../src/roving-focus-item.directive'; + + + + +# Roving Focus + + + + + +## Anatomy + +```html +
+ + + +
+``` + +## API Reference + +### RdxRovingFocusGroupDirective + + + +### RdxRovingFocusItemDirective + + diff --git a/packages/primitives/roving-focus/stories/roving-focus.stories.ts b/packages/primitives/roving-focus/stories/roving-focus.stories.ts new file mode 100644 index 00000000..ed862350 --- /dev/null +++ b/packages/primitives/roving-focus/stories/roving-focus.stories.ts @@ -0,0 +1,205 @@ +import { componentWrapperDecorator, Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { RdxRovingFocusGroupDirective } from '../src/roving-focus-group.directive'; +import { RdxRovingFocusItemDirective } from '../src/roving-focus-item.directive'; +import { RovingFocusEventsComponent } from './roving-focus-events.component'; + +const html = String.raw; + +export default { + title: 'Primitives/RovingFocus', + decorators: [ + moduleMetadata({ + imports: [ + RdxRovingFocusGroupDirective, + RdxRovingFocusItemDirective, + RovingFocusEventsComponent + ] + }), + componentWrapperDecorator( + (story) => html` +
+ ${story} + + +
+ ` + ) + ] +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ({ + template: html` +
+

Horizontal Navigation with Looping

+

+ Use the ArrowLeft and ArrowRight keys to navigate between buttons. Ensure that when reaching the end + of the group, the focus cycles back to the first item (and vice versa). +

+
+ + + +
+
+ ` + }) +}; + +export const HorizontalRTL: Story = { + render: () => ({ + template: html` +
+

Horizontal Navigation in RTL Direction

+

+ Use the ArrowLeft and ArrowRight keys. In RTL direction, the keys should behave inversely + (ArrowRight moves to the previous item, and ArrowLeft moves to the next item). +

+
+ + + +
+
+ ` + }) +}; + +export const WithHomeAndEnd: Story = { + render: () => ({ + template: html` +
+

Navigation with "Home" and "End" Keys

+

+ Press the Home key to move focus to the first item. Press the End key to move focus to the last + item. +

+
+ + + +
+
+ ` + }) +}; + +export const MixedActiveAndInactive: Story = { + render: () => ({ + template: html` +
+

Mixed Active and Inactive States

+

Try navigating with arrow keys. Ensure that the inactive item (Disabled) is skipped.

+
+ + + +
+
+ ` + }) +}; + +export const VerticalWithoutLooping: Story = { + render: () => ({ + template: html` +
+

Vertical Navigation without Looping

+

+ Use the ArrowLeft and ArrowRight keys to navigate between buttons. Ensure that when reaching the end + of the group, the focus cycles back to the first item (and vice versa). +

+
+ + + +
+
+ ` + }) +}; + +export const IgnoreShiftKey: Story = { + render: () => ({ + template: html` +
+

Ignore Shift Key (allowShiftKey)

+

+ Use the ArrowLeft and ArrowRight keys to navigate between buttons. Holding the + Shift + key should not affect focus behavior. +

+
+ + + +
+
+ ` + }) +}; + +export const EventHandling: Story = { + render: () => ({ + template: html` +

Event Handling

+

+ Verify that the + entryFocus + and + currentTabStopIdChange + events are triggered during the appropriate actions. +

+ + ` + }) +};