Skip to content

Commit 0e699a7

Browse files
authored
Fix toolbar item focus (#88)
Co-authored-by: Frédéric Collonval <fcollonval@users.noreply.github.com>
1 parent 90f70ed commit 0e699a7

File tree

1 file changed

+377
-1
lines changed

1 file changed

+377
-1
lines changed

packages/components/src/toolbar/index.ts

Lines changed: 377 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,390 @@
33
// Distributed under the terms of the Modified BSD License.
44

55
import {
6+
FASTElement,
7+
Observable,
8+
attr,
9+
observable
10+
} from '@microsoft/fast-element';
11+
import {
12+
ARIAGlobalStatesAndProperties,
13+
FoundationElement,
14+
FoundationElementDefinition,
15+
StartEnd,
16+
StartEndOptions,
17+
applyMixins,
618
composedParent,
7-
Toolbar as FoundationToolbar,
19+
getDirection,
820
toolbarTemplate as template
921
} from '@microsoft/fast-foundation';
22+
import {
23+
ArrowKeys,
24+
Direction,
25+
Orientation,
26+
limit
27+
} from '@microsoft/fast-web-utilities';
28+
import { isFocusable } from 'tabbable';
1029
import { Swatch } from '../color/swatch.js';
1130
import { fillColor, neutralFillLayerRecipe } from '../design-tokens.js';
1231
import { toolbarStyles as styles } from './toolbar.styles.js';
1332

33+
/**
34+
* Toolbar configuration options
35+
* @public
36+
*/
37+
export type ToolbarOptions = FoundationElementDefinition & StartEndOptions;
38+
39+
/**
40+
* A map for directionality derived from keyboard input strings,
41+
* visual orientation, and text direction.
42+
*
43+
* @internal
44+
*/
45+
const ToolbarArrowKeyMap = Object.freeze({
46+
[ArrowKeys.ArrowUp]: {
47+
[Orientation.vertical]: -1
48+
},
49+
[ArrowKeys.ArrowDown]: {
50+
[Orientation.vertical]: 1
51+
},
52+
[ArrowKeys.ArrowLeft]: {
53+
[Orientation.horizontal]: {
54+
[Direction.ltr]: -1,
55+
[Direction.rtl]: 1
56+
}
57+
},
58+
[ArrowKeys.ArrowRight]: {
59+
[Orientation.horizontal]: {
60+
[Direction.ltr]: 1,
61+
[Direction.rtl]: -1
62+
}
63+
}
64+
});
65+
66+
/**
67+
* A Toolbar Custom HTML Element.
68+
* Implements the {@link https://w3c.github.io/aria-practices/#Toolbar|ARIA Toolbar}.
69+
*
70+
* @slot start - Content which can be provided before the slotted items
71+
* @slot end - Content which can be provided after the slotted items
72+
* @slot - The default slot for slotted items
73+
* @slot label - The toolbar label
74+
* @csspart positioning-region - The element containing the items, start and end slots
75+
*
76+
* @public
77+
*/
78+
export class FoundationToolbar extends FoundationElement {
79+
/**
80+
* The internal index of the currently focused element.
81+
*
82+
* @internal
83+
*/
84+
private _activeIndex = 0;
85+
86+
/**
87+
* The index of the currently focused element, clamped between 0 and the last element.
88+
*
89+
* @internal
90+
*/
91+
get activeIndex(): number {
92+
Observable.track(this, 'activeIndex');
93+
return this._activeIndex;
94+
}
95+
96+
set activeIndex(value: number) {
97+
if (this.$fastController.isConnected) {
98+
this._activeIndex = limit(0, this.focusableElements.length - 1, value);
99+
Observable.notify(this, 'activeIndex');
100+
}
101+
}
102+
103+
/**
104+
* The text direction of the toolbar.
105+
*
106+
* @internal
107+
*/
108+
@observable
109+
public direction: Direction = Direction.ltr;
110+
111+
/**
112+
* The collection of focusable toolbar controls.
113+
*
114+
* @internal
115+
*/
116+
private focusableElements: HTMLElement[];
117+
118+
/**
119+
* The orientation of the toolbar.
120+
*
121+
* @public
122+
* @remarks
123+
* HTML Attribute: `orientation`
124+
*/
125+
@attr
126+
public orientation: Orientation = Orientation.horizontal;
127+
128+
/**
129+
* The elements in the default slot.
130+
*
131+
* @internal
132+
*/
133+
@observable
134+
public slottedItems: HTMLElement[];
135+
protected slottedItemsChanged(): void {
136+
if (this.$fastController.isConnected) {
137+
this.reduceFocusableElements();
138+
}
139+
}
140+
141+
/**
142+
* The elements in the label slot.
143+
*
144+
* @internal
145+
*/
146+
@observable
147+
public slottedLabel: HTMLElement[];
148+
149+
/**
150+
* Set the activeIndex when a focusable element in the toolbar is clicked.
151+
*
152+
* @internal
153+
*/
154+
public mouseDownHandler(e: MouseEvent): boolean | void {
155+
const activeIndex = this.focusableElements?.findIndex(x =>
156+
x.contains(e.target as HTMLElement)
157+
);
158+
if (activeIndex > -1 && this.activeIndex !== activeIndex) {
159+
this.setFocusedElement(activeIndex);
160+
}
161+
162+
return true;
163+
}
164+
165+
@observable
166+
public childItems: Element[];
167+
protected childItemsChanged(
168+
prev: undefined | Element[],
169+
next: Element[]
170+
): void {
171+
if (this.$fastController.isConnected) {
172+
this.reduceFocusableElements();
173+
}
174+
}
175+
176+
/**
177+
* @internal
178+
*/
179+
public connectedCallback() {
180+
super.connectedCallback();
181+
this.direction = getDirection(this);
182+
}
183+
184+
/**
185+
* When the toolbar receives focus, set the currently active element as focused.
186+
*
187+
* @internal
188+
*/
189+
public focusinHandler(e: FocusEvent): boolean | void {
190+
const relatedTarget = e.relatedTarget as HTMLElement;
191+
if (!relatedTarget || this.contains(relatedTarget)) {
192+
return;
193+
}
194+
195+
this.setFocusedElement();
196+
}
197+
198+
/**
199+
* Determines a value that can be used to iterate a list with the arrow keys.
200+
*
201+
* @param this - An element with an orientation and direction
202+
* @param key - The event key value
203+
* @internal
204+
*/
205+
private getDirectionalIncrementer(key: string): number {
206+
return (
207+
// @ts-expect-error ToolbarArrowKeyMap has not index
208+
ToolbarArrowKeyMap[key]?.[this.orientation]?.[this.direction] ??
209+
// @ts-expect-error ToolbarArrowKeyMap has not index
210+
ToolbarArrowKeyMap[key]?.[this.orientation] ??
211+
0
212+
);
213+
}
214+
215+
/**
216+
* Handle keyboard events for the toolbar.
217+
*
218+
* @internal
219+
*/
220+
public keydownHandler(e: KeyboardEvent): boolean | void {
221+
const key = e.key;
222+
223+
if (!(key in ArrowKeys) || e.defaultPrevented || e.shiftKey) {
224+
return true;
225+
}
226+
227+
const incrementer = this.getDirectionalIncrementer(key);
228+
if (!incrementer) {
229+
return !(e.target as HTMLElement).closest('[role=radiogroup]');
230+
}
231+
232+
const nextIndex = this.activeIndex + incrementer;
233+
if (this.focusableElements[nextIndex]) {
234+
e.preventDefault();
235+
}
236+
237+
this.setFocusedElement(nextIndex);
238+
239+
return true;
240+
}
241+
242+
/**
243+
* get all the slotted elements
244+
* @internal
245+
*/
246+
protected get allSlottedItems(): (HTMLElement | Node)[] {
247+
return [
248+
...this.start.assignedElements(),
249+
...this.slottedItems,
250+
...this.end.assignedElements()
251+
];
252+
}
253+
254+
/**
255+
* Prepare the slotted elements which can be focusable.
256+
*
257+
* @internal
258+
*/
259+
protected reduceFocusableElements(): void {
260+
const previousFocusedElement = this.focusableElements?.[this.activeIndex];
261+
262+
this.focusableElements = this.allSlottedItems.reduce(
263+
Toolbar.reduceFocusableItems,
264+
[]
265+
);
266+
267+
// If the previously active item is still focusable, adjust the active index to the
268+
// index of that item.
269+
const adjustedActiveIndex = this.focusableElements.indexOf(
270+
previousFocusedElement
271+
);
272+
this.activeIndex = Math.max(0, adjustedActiveIndex);
273+
274+
this.setFocusableElements();
275+
}
276+
277+
/**
278+
* Set the activeIndex and focus the corresponding control.
279+
*
280+
* @param activeIndex - The new index to set
281+
* @internal
282+
*/
283+
private setFocusedElement(activeIndex: number = this.activeIndex): void {
284+
this.activeIndex = activeIndex;
285+
this.setFocusableElements();
286+
if (
287+
this.focusableElements[this.activeIndex] &&
288+
// Don't focus the toolbar element if some event handlers moved
289+
// the focus on another element in the page.
290+
this.contains(document.activeElement)
291+
) {
292+
this.focusableElements[this.activeIndex].focus();
293+
}
294+
}
295+
296+
/**
297+
* Reduce a collection to only its focusable elements.
298+
*
299+
* @param elements - Collection of elements to reduce
300+
* @param element - The current element
301+
*
302+
* @internal
303+
*/
304+
private static reduceFocusableItems(
305+
elements: HTMLElement[],
306+
element: FASTElement & HTMLElement
307+
): HTMLElement[] {
308+
const isRoleRadio = element.getAttribute('role') === 'radio';
309+
const isFocusableFastElement =
310+
element.$fastController?.definition.shadowOptions?.delegatesFocus;
311+
const hasFocusableShadow = Array.from(
312+
element.shadowRoot?.querySelectorAll('*') ?? []
313+
).some(x => isFocusable(x));
314+
315+
if (
316+
!element.hasAttribute('disabled') &&
317+
!element.hasAttribute('hidden') &&
318+
(isFocusable(element) ||
319+
isRoleRadio ||
320+
isFocusableFastElement ||
321+
hasFocusableShadow)
322+
) {
323+
elements.push(element);
324+
return elements;
325+
}
326+
327+
if (element.childElementCount) {
328+
return elements.concat(
329+
Array.from(element.children).reduce(Toolbar.reduceFocusableItems, [])
330+
);
331+
}
332+
333+
return elements;
334+
}
335+
336+
/**
337+
* @internal
338+
*/
339+
private setFocusableElements(): void {
340+
if (this.$fastController.isConnected && this.focusableElements.length > 0) {
341+
this.focusableElements.forEach((element, index) => {
342+
element.tabIndex = this.activeIndex === index ? 0 : -1;
343+
});
344+
}
345+
}
346+
}
347+
348+
/**
349+
* Includes ARIA states and properties relating to the ARIA toolbar role
350+
*
351+
* @public
352+
*/
353+
export class DelegatesARIAToolbar {
354+
/**
355+
* The id of the element labeling the toolbar.
356+
* @public
357+
* @remarks
358+
* HTML Attribute: aria-labelledby
359+
*/
360+
@attr({ attribute: 'aria-labelledby' })
361+
public ariaLabelledby: string | null;
362+
363+
/**
364+
* The label surfaced to assistive technologies.
365+
*
366+
* @public
367+
* @remarks
368+
* HTML Attribute: aria-label
369+
*/
370+
@attr({ attribute: 'aria-label' })
371+
public ariaLabel: string | null;
372+
}
373+
374+
/**
375+
* Mark internal because exporting class and interface of the same name
376+
* confuses API documenter.
377+
* TODO: https://github.com/microsoft/fast/issues/3317
378+
* @internal
379+
*/
380+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
381+
export interface DelegatesARIAToolbar extends ARIAGlobalStatesAndProperties {}
382+
applyMixins(DelegatesARIAToolbar, ARIAGlobalStatesAndProperties);
383+
384+
/**
385+
* @internal
386+
*/
387+
export interface FoundationToolbar extends StartEnd, DelegatesARIAToolbar {}
388+
applyMixins(FoundationToolbar, StartEnd, DelegatesARIAToolbar);
389+
14390
/**
15391
* @internal
16392
*/

0 commit comments

Comments
 (0)