Skip to content

Commit

Permalink
Fix toolbar item focus (#88)
Browse files Browse the repository at this point in the history
Co-authored-by: Frédéric Collonval <fcollonval@users.noreply.github.com>
  • Loading branch information
fcollonval and fcollonval committed Jan 24, 2024
1 parent 90f70ed commit 0e699a7
Showing 1 changed file with 377 additions and 1 deletion.
378 changes: 377 additions & 1 deletion packages/components/src/toolbar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,390 @@
// Distributed under the terms of the Modified BSD License.

import {
FASTElement,
Observable,
attr,
observable
} from '@microsoft/fast-element';
import {
ARIAGlobalStatesAndProperties,
FoundationElement,
FoundationElementDefinition,
StartEnd,
StartEndOptions,
applyMixins,
composedParent,
Toolbar as FoundationToolbar,
getDirection,
toolbarTemplate as template
} from '@microsoft/fast-foundation';
import {
ArrowKeys,
Direction,
Orientation,
limit
} from '@microsoft/fast-web-utilities';
import { isFocusable } from 'tabbable';
import { Swatch } from '../color/swatch.js';
import { fillColor, neutralFillLayerRecipe } from '../design-tokens.js';
import { toolbarStyles as styles } from './toolbar.styles.js';

/**
* Toolbar configuration options
* @public
*/
export type ToolbarOptions = FoundationElementDefinition & StartEndOptions;

/**
* A map for directionality derived from keyboard input strings,
* visual orientation, and text direction.
*
* @internal
*/
const ToolbarArrowKeyMap = Object.freeze({
[ArrowKeys.ArrowUp]: {
[Orientation.vertical]: -1
},
[ArrowKeys.ArrowDown]: {
[Orientation.vertical]: 1
},
[ArrowKeys.ArrowLeft]: {
[Orientation.horizontal]: {
[Direction.ltr]: -1,
[Direction.rtl]: 1
}
},
[ArrowKeys.ArrowRight]: {
[Orientation.horizontal]: {
[Direction.ltr]: 1,
[Direction.rtl]: -1
}
}
});

/**
* A Toolbar Custom HTML Element.
* Implements the {@link https://w3c.github.io/aria-practices/#Toolbar|ARIA Toolbar}.
*
* @slot start - Content which can be provided before the slotted items
* @slot end - Content which can be provided after the slotted items
* @slot - The default slot for slotted items
* @slot label - The toolbar label
* @csspart positioning-region - The element containing the items, start and end slots
*
* @public
*/
export class FoundationToolbar extends FoundationElement {
/**
* The internal index of the currently focused element.
*
* @internal
*/
private _activeIndex = 0;

/**
* The index of the currently focused element, clamped between 0 and the last element.
*
* @internal
*/
get activeIndex(): number {
Observable.track(this, 'activeIndex');
return this._activeIndex;
}

set activeIndex(value: number) {
if (this.$fastController.isConnected) {
this._activeIndex = limit(0, this.focusableElements.length - 1, value);
Observable.notify(this, 'activeIndex');
}
}

/**
* The text direction of the toolbar.
*
* @internal
*/
@observable
public direction: Direction = Direction.ltr;

/**
* The collection of focusable toolbar controls.
*
* @internal
*/
private focusableElements: HTMLElement[];

/**
* The orientation of the toolbar.
*
* @public
* @remarks
* HTML Attribute: `orientation`
*/
@attr
public orientation: Orientation = Orientation.horizontal;

/**
* The elements in the default slot.
*
* @internal
*/
@observable
public slottedItems: HTMLElement[];
protected slottedItemsChanged(): void {
if (this.$fastController.isConnected) {
this.reduceFocusableElements();
}
}

/**
* The elements in the label slot.
*
* @internal
*/
@observable
public slottedLabel: HTMLElement[];

/**
* Set the activeIndex when a focusable element in the toolbar is clicked.
*
* @internal
*/
public mouseDownHandler(e: MouseEvent): boolean | void {
const activeIndex = this.focusableElements?.findIndex(x =>
x.contains(e.target as HTMLElement)
);
if (activeIndex > -1 && this.activeIndex !== activeIndex) {
this.setFocusedElement(activeIndex);
}

return true;
}

@observable
public childItems: Element[];
protected childItemsChanged(
prev: undefined | Element[],
next: Element[]
): void {
if (this.$fastController.isConnected) {
this.reduceFocusableElements();
}
}

/**
* @internal
*/
public connectedCallback() {
super.connectedCallback();
this.direction = getDirection(this);
}

/**
* When the toolbar receives focus, set the currently active element as focused.
*
* @internal
*/
public focusinHandler(e: FocusEvent): boolean | void {
const relatedTarget = e.relatedTarget as HTMLElement;
if (!relatedTarget || this.contains(relatedTarget)) {
return;
}

this.setFocusedElement();
}

/**
* Determines a value that can be used to iterate a list with the arrow keys.
*
* @param this - An element with an orientation and direction
* @param key - The event key value
* @internal
*/
private getDirectionalIncrementer(key: string): number {
return (
// @ts-expect-error ToolbarArrowKeyMap has not index
ToolbarArrowKeyMap[key]?.[this.orientation]?.[this.direction] ??
// @ts-expect-error ToolbarArrowKeyMap has not index
ToolbarArrowKeyMap[key]?.[this.orientation] ??
0
);
}

/**
* Handle keyboard events for the toolbar.
*
* @internal
*/
public keydownHandler(e: KeyboardEvent): boolean | void {
const key = e.key;

if (!(key in ArrowKeys) || e.defaultPrevented || e.shiftKey) {
return true;
}

const incrementer = this.getDirectionalIncrementer(key);
if (!incrementer) {
return !(e.target as HTMLElement).closest('[role=radiogroup]');
}

const nextIndex = this.activeIndex + incrementer;
if (this.focusableElements[nextIndex]) {
e.preventDefault();
}

this.setFocusedElement(nextIndex);

return true;
}

/**
* get all the slotted elements
* @internal
*/
protected get allSlottedItems(): (HTMLElement | Node)[] {
return [
...this.start.assignedElements(),
...this.slottedItems,
...this.end.assignedElements()
];
}

/**
* Prepare the slotted elements which can be focusable.
*
* @internal
*/
protected reduceFocusableElements(): void {
const previousFocusedElement = this.focusableElements?.[this.activeIndex];

this.focusableElements = this.allSlottedItems.reduce(
Toolbar.reduceFocusableItems,
[]
);

// If the previously active item is still focusable, adjust the active index to the
// index of that item.
const adjustedActiveIndex = this.focusableElements.indexOf(
previousFocusedElement
);
this.activeIndex = Math.max(0, adjustedActiveIndex);

this.setFocusableElements();
}

/**
* Set the activeIndex and focus the corresponding control.
*
* @param activeIndex - The new index to set
* @internal
*/
private setFocusedElement(activeIndex: number = this.activeIndex): void {
this.activeIndex = activeIndex;
this.setFocusableElements();
if (
this.focusableElements[this.activeIndex] &&
// Don't focus the toolbar element if some event handlers moved
// the focus on another element in the page.
this.contains(document.activeElement)
) {
this.focusableElements[this.activeIndex].focus();
}
}

/**
* Reduce a collection to only its focusable elements.
*
* @param elements - Collection of elements to reduce
* @param element - The current element
*
* @internal
*/
private static reduceFocusableItems(
elements: HTMLElement[],
element: FASTElement & HTMLElement
): HTMLElement[] {
const isRoleRadio = element.getAttribute('role') === 'radio';
const isFocusableFastElement =
element.$fastController?.definition.shadowOptions?.delegatesFocus;
const hasFocusableShadow = Array.from(
element.shadowRoot?.querySelectorAll('*') ?? []
).some(x => isFocusable(x));

if (
!element.hasAttribute('disabled') &&
!element.hasAttribute('hidden') &&
(isFocusable(element) ||
isRoleRadio ||
isFocusableFastElement ||
hasFocusableShadow)
) {
elements.push(element);
return elements;
}

if (element.childElementCount) {
return elements.concat(
Array.from(element.children).reduce(Toolbar.reduceFocusableItems, [])
);
}

return elements;
}

/**
* @internal
*/
private setFocusableElements(): void {
if (this.$fastController.isConnected && this.focusableElements.length > 0) {
this.focusableElements.forEach((element, index) => {
element.tabIndex = this.activeIndex === index ? 0 : -1;
});
}
}
}

/**
* Includes ARIA states and properties relating to the ARIA toolbar role
*
* @public
*/
export class DelegatesARIAToolbar {
/**
* The id of the element labeling the toolbar.
* @public
* @remarks
* HTML Attribute: aria-labelledby
*/
@attr({ attribute: 'aria-labelledby' })
public ariaLabelledby: string | null;

/**
* The label surfaced to assistive technologies.
*
* @public
* @remarks
* HTML Attribute: aria-label
*/
@attr({ attribute: 'aria-label' })
public ariaLabel: string | null;
}

/**
* Mark internal because exporting class and interface of the same name
* confuses API documenter.
* TODO: https://github.com/microsoft/fast/issues/3317
* @internal
*/
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface DelegatesARIAToolbar extends ARIAGlobalStatesAndProperties {}
applyMixins(DelegatesARIAToolbar, ARIAGlobalStatesAndProperties);

/**
* @internal
*/
export interface FoundationToolbar extends StartEnd, DelegatesARIAToolbar {}
applyMixins(FoundationToolbar, StartEnd, DelegatesARIAToolbar);

/**
* @internal
*/
Expand Down

0 comments on commit 0e699a7

Please sign in to comment.