Skip to content

Commit 83024fe

Browse files
fix(core): add requestUpdate to roving-tabindex-controller (#2446)
* fix(tools): add change callback to roving tab index controller * fix(tabs): impleement RTI callback, remove keybindings * chore: add changeset * chore: update changeset * refactor(tools): remove callback use requestUpdate instead * fix(tabs): update rti active item on attribute change * feat(core): make RovingTabIndex generic to type of item * chore: update changeset * refactor(tabs): remove type assertions --------- Co-authored-by: Benny Powers <[email protected]>
1 parent c4bcf84 commit 83024fe

File tree

5 files changed

+59
-81
lines changed

5 files changed

+59
-81
lines changed

.changeset/fair-melons-cough.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@patternfly/elements": patch
3+
---
4+
5+
`<pf-tabs>`: improved keyboard navigation so it correctly activates the focused tab

.changeset/rti-focus-update.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@patternfly/pfe-core": minor
3+
---
4+
5+
`roving-tabindex-controller`: notify the host when the focused item changes.

.changeset/rti-generic.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
"@patternfly/pfe-core": minor
3+
---
4+
`roving-tabindex-controller`: allow component authors to specify the type of items.

core/pfe-core/controllers/roving-tabindex-controller.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,22 @@ const isFocusableElement = (el: Element): el is HTMLElement =>
1111
* Components Using a Roving
1212
* tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex)
1313
*/
14-
export class RovingTabindexController implements ReactiveController {
14+
export class RovingTabindexController<
15+
ItemType extends HTMLElement = HTMLElement,
16+
> implements ReactiveController {
1517
/** active focusable element */
16-
#activeItem?: HTMLElement;
18+
#activeItem?: ItemType;
1719

1820
/** closest ancestor containing items */
1921
#itemsContainer?: HTMLElement;
2022

2123
/** array of all focusable elements */
22-
#items: HTMLElement[] = [];
24+
#items: ItemType[] = [];
2325

2426
/**
2527
* finds focusable items from a group of items
2628
*/
27-
get #focusableItems(): HTMLElement[] {
29+
get #focusableItems(): ItemType[] {
2830
return this.#items.filter(isFocusableElement);
2931
}
3032

@@ -45,38 +47,38 @@ export class RovingTabindexController implements ReactiveController {
4547
/**
4648
* active item of array of items
4749
*/
48-
get activeItem(): HTMLElement | undefined {
50+
get activeItem(): ItemType | undefined {
4951
return this.#activeItem;
5052
}
5153

5254
/**
5355
* first item in array of focusable items
5456
*/
55-
get firstItem(): HTMLElement | undefined {
57+
get firstItem(): ItemType | undefined {
5658
return this.#focusableItems[0];
5759
}
5860

5961
/**
6062
* last item in array of focusable items
6163
*/
62-
get lastItem(): HTMLElement | undefined {
64+
get lastItem(): ItemType | undefined {
6365
return this.#focusableItems.at(-1);
6466
}
6567

6668
/**
6769
* next item after active item in array of focusable items
6870
*/
69-
get nextItem(): HTMLElement | undefined {
71+
get nextItem(): ItemType | undefined {
7072
return (
71-
this.#activeIndex < this.#focusableItems.length - 1 ? this.#focusableItems[this.#activeIndex + 1]
72-
: this.firstItem
73+
this.#activeIndex >= this.#focusableItems.length - 1 ? this.firstItem
74+
: this.#focusableItems[this.#activeIndex + 1]
7375
);
7476
}
7577

7678
/**
7779
* previous item after active item in array of focusable items
7880
*/
79-
get prevItem(): HTMLElement | undefined {
81+
get prevItem(): ItemType | undefined {
8082
return (
8183
this.#activeIndex > 0 ? this.#focusableItems[this.#activeIndex - 1]
8284
: this.lastItem
@@ -161,7 +163,7 @@ export class RovingTabindexController implements ReactiveController {
161163
/**
162164
* sets tabindex of item based on whether or not it is active
163165
*/
164-
updateActiveItem(item?: HTMLElement): void {
166+
updateActiveItem(item?: ItemType): void {
165167
if (item) {
166168
if (!!this.#activeItem && item !== this.#activeItem) {
167169
this.#activeItem.tabIndex = -1;
@@ -174,15 +176,16 @@ export class RovingTabindexController implements ReactiveController {
174176
/**
175177
* focuses on an item and sets it as active
176178
*/
177-
focusOnItem(item?: HTMLElement): void {
179+
focusOnItem(item?: ItemType): void {
178180
this.updateActiveItem(item || this.firstItem);
179181
this.#activeItem?.focus();
182+
this.host.requestUpdate();
180183
}
181184

182185
/**
183186
* Focuses next focusable item
184187
*/
185-
updateItems(items: HTMLElement[]) {
188+
updateItems(items: ItemType[]) {
186189
const sequence = [...items.slice(this.#itemIndex), ...items.slice(0, this.#itemIndex)];
187190
const first = sequence.find(item => this.#focusableItems.includes(item));
188191
this.focusOnItem(first || this.firstItem);
@@ -191,7 +194,7 @@ export class RovingTabindexController implements ReactiveController {
191194
/**
192195
* from array of HTML items, and sets active items
193196
*/
194-
initItems(items: HTMLElement[], itemsContainer: HTMLElement = this.host) {
197+
initItems(items: ItemType[], itemsContainer: HTMLElement = this.host) {
195198
this.#items = items ?? [];
196199
const focusableItems = this.#focusableItems;
197200
const [focusableItem] = focusableItems;
@@ -210,14 +213,14 @@ export class RovingTabindexController implements ReactiveController {
210213
}
211214

212215
/**
213-
* adds event listners to items container
216+
* adds event listeners to items container
214217
*/
215218
hostConnected() {
216219
this.#itemsContainer?.addEventListener('keydown', this.#onKeydown);
217220
}
218221

219222
/**
220-
* removes event listners from items container
223+
* removes event listeners from items container
221224
*/
222225
hostDisconnected() {
223226
this.#itemsContainer?.removeEventListener('keydown', this.#onKeydown);

elements/pf-tabs/BaseTabs.ts

Lines changed: 25 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export abstract class BaseTabs extends LitElement {
6161

6262
@query('[part="tabs"]') private tabList!: HTMLElement;
6363

64-
#tabindex = new RovingTabindexController(this);
64+
#tabindex = new RovingTabindexController<BaseTab>(this);
6565

6666
#overflow = new OverflowController(this);
6767

@@ -104,7 +104,7 @@ export abstract class BaseTabs extends LitElement {
104104

105105
if (index === -1) {
106106
this.#logger.warn(`No active tab found, setting first focusable tab to active`);
107-
const first = this.#tabindex.firstItem as BaseTab;
107+
const first = this.#tabindex.firstItem;
108108
this.#tabindex.updateActiveItem(first);
109109
index = this.#activeItemIndex;
110110
}
@@ -140,7 +140,6 @@ export abstract class BaseTabs extends LitElement {
140140
override connectedCallback() {
141141
super.connectedCallback();
142142
this.addEventListener('expand', this.#onTabExpand);
143-
this.addEventListener('keydown', this.#onKeydown);
144143
BaseTabs.#instances.add(this);
145144
}
146145

@@ -149,6 +148,17 @@ export abstract class BaseTabs extends LitElement {
149148
BaseTabs.#instances.delete(this);
150149
}
151150

151+
override willUpdate(): void {
152+
const { activeItem } = this.#tabindex;
153+
// If RTI has an activeItem, update the roving tabindex controller
154+
if (!this.manual &&
155+
activeItem &&
156+
activeItem !== this.#activeTab &&
157+
activeItem.ariaDisabled !== 'true') {
158+
activeItem.active = true;
159+
}
160+
}
161+
152162
async firstUpdated() {
153163
this.tabList.addEventListener('scroll', this.#overflow.onScroll.bind(this));
154164
}
@@ -210,13 +220,14 @@ export abstract class BaseTabs extends LitElement {
210220

211221
#onTabExpand = (event: Event): void => {
212222
if (!(event instanceof TabExpandEvent) ||
213-
this.#allTabs.length === 0 || this.#allPanels.length === 0) {
223+
!this.#allTabs.length ||
224+
!this.#allPanels.length) {
214225
return;
215226
}
216227

217-
const target = event as TabExpandEvent;
218-
if (target.active) {
219-
this.activeIndex = this.#allTabs.findIndex(tab => tab === target.tab);
228+
if (event.active) {
229+
this.activeIndex = this.#allTabs.findIndex(tab => tab === event.tab);
230+
this.#tabindex.updateActiveItem(this.#activeTab);
220231
}
221232
};
222233

@@ -225,77 +236,27 @@ export abstract class BaseTabs extends LitElement {
225236
this.#allPanels.forEach((panel, i) => panel.hidden = i !== index);
226237
}
227238

228-
get #firstFocusable(): BaseTab {
229-
return this.#tabindex.firstItem as BaseTab;
239+
get #firstFocusable(): BaseTab | undefined {
240+
return this.#tabindex.firstItem;
230241
}
231242

232-
get #lastFocusable(): BaseTab {
233-
return this.#tabindex.lastItem as BaseTab;
234-
}
235-
236-
get #firstTab(): BaseTab {
243+
get #firstTab(): BaseTab | undefined {
237244
const [tab] = this.#allTabs;
238245
return tab;
239246
}
240247

241-
get #lastTab(): BaseTab {
242-
return this.#allTabs.at(-1) as BaseTab;
248+
get #lastTab(): BaseTab | undefined {
249+
return this.#allTabs.at(-1);
243250
}
244251

245252
get #activeItemIndex() {
246253
const { activeItem } = this.#tabindex;
247254
return this.#allTabs.findIndex(t => t === activeItem);
248255
}
249256

250-
#activate(selectedTab: BaseTab): void {
251-
if (selectedTab.ariaDisabled !== 'true') {
252-
selectedTab.active = true;
253-
}
254-
}
255-
256-
async #select(selectedTab: BaseTab): Promise<void> {
257-
if (!this.manual) {
258-
this.#activate(selectedTab);
259-
}
260-
}
261-
262-
// RTI: will handle key events
263-
#onKeydown = (event: KeyboardEvent): void => {
264-
const foundTab = this.#allTabs.find(tab => tab === event.target);
265-
if (!foundTab) {
266-
return;
267-
}
268-
switch (event.key) {
269-
case 'ArrowUp':
270-
case 'ArrowLeft':
271-
event.preventDefault();
272-
this.#select(this.#tabindex.activeItem as BaseTab);
273-
break;
274-
275-
case 'ArrowDown':
276-
case 'ArrowRight':
277-
event.preventDefault();
278-
this.#select(this.#tabindex.activeItem as BaseTab);
279-
break;
280-
281-
case 'Home':
282-
event.preventDefault();
283-
this.#select(this.#firstFocusable);
284-
break;
285-
286-
case 'End':
287-
event.preventDefault();
288-
this.#select(this.#lastFocusable);
289-
break;
290-
291-
default:
292-
return;
293-
}
294-
};
295-
296257
#firstLastClasses() {
297-
this.#firstTab.classList.add('first');
298-
this.#lastTab.classList.add('last');
258+
this.#firstTab?.classList.add('first');
259+
this.#lastTab?.classList.add('last');
299260
}
300261

301262
#scrollLeft() {

0 commit comments

Comments
 (0)