Skip to content

Commit d4e5411

Browse files
authored
fix(tabs)!: tabscontroller refactor (#2699)
* docs(select): update changeset * fix(tabs)!: refactor controllers and tabs state * fix(tabs)!: refactor to simplify tabscontroller let tabscontroller handle the aria stuff only, for the most part. Total separation between RTIC and tabs state. Disabled tabs are still able to be focused via RTIC, just that they can't change the Tab's active tab state. Use context instead of cascade Use shadow classes instead of reflected attrs (but keep the attrs for now to avoid breaking changes) * feat(core): expose `tabs` property of tabs-aria-controller * feat(core): createContextWithRoot * chore: update dependencies * refactor(core): rtic small refacctor * refactor(tabs)!: refactor tabs controller, use context * test(tabs): deflake test * docs(tabs): no more aria-disabled * style: whitespace * refactor(tabs): rename options type * fix(tabs): warn on disabled active tab * style: whitespace * fix(tabs): aria-selected on tab * chore: update deps
1 parent ca3086c commit d4e5411

32 files changed

+755
-839
lines changed

.changeset/context-with-root.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+
**Context**: added `createContextWithRoot`. Use this when creating contexts that
5+
are shared with child elements.

.changeset/pf-select.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
A select list enables users to select one or more items from a list.
88

99
```html
10-
<pf-select>
10+
<pf-select placeholder="Choose a color">
1111
<pf-option>Blue</pf-option>
1212
<pf-option>Green</pf-option>
1313
<pf-option>Magenta</pf-option>

.changeset/tabs-controller.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
"@patternfly/core": minor
33
---
44

5-
`TabsController`: Added TabsController. This controller is used to manage the state of the tabs and panels.
5+
`TabsAriaController`: Added TabsAriaController, used to manage the accesibility tree for tabs and panels.
66

77
```ts
8-
#tabs = new TabsController(this, {
8+
#tabs = new TabsAriaController(this, {
99
isTab: (x: Node): x is PfTab => x instanceof PfTab,
1010
isPanel: (x: Node): x is PfTabPanel => x instanceof PfTabPanel,
1111
});

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

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export class RovingTabindexController<
142142
}
143143
RovingTabindexController.hosts.set(host, this);
144144
this.host.addController(this);
145-
this.#init();
145+
this.updateItems();
146146
}
147147

148148
hostUpdated() {
@@ -151,7 +151,7 @@ export class RovingTabindexController<
151151
if (oldContainer !== newContainer) {
152152
oldContainer?.removeEventListener('keydown', this.#onKeydown);
153153
RovingTabindexController.elements.delete(oldContainer!);
154-
this.#init();
154+
this.updateItems();
155155
}
156156
if (newContainer) {
157157
this.#initContainer(newContainer);
@@ -167,12 +167,6 @@ export class RovingTabindexController<
167167
this.#gainedInitialFocus = false;
168168
}
169169

170-
#init() {
171-
if (typeof this.#options?.getItems === 'function') {
172-
this.updateItems(this.#options.getItems());
173-
}
174-
}
175-
176170
#initContainer(container: Element) {
177171
RovingTabindexController.elements.set(container, this);
178172
this.#itemsContainer = container;
@@ -267,23 +261,23 @@ export class RovingTabindexController<
267261
}
268262
}
269263

270-
/** @deprecated use setActiveItem */
271-
focusOnItem(item?: Item): void {
272-
this.setActiveItem(item);
273-
}
274-
275264
/**
276265
* Focuses next focusable item
277266
*/
278-
updateItems(items?: Item[]) {
279-
this.#items = items ?? this.#options.getItems?.() ?? [];
267+
updateItems(items: Item[] = this.#options.getItems?.() ?? []) {
268+
this.#items = items;
280269
const sequence = [...this.#items.slice(this.#itemIndex - 1), ...this.#items.slice(0, this.#itemIndex - 1)];
281270
const first = sequence.find(item => this.#focusableItems.includes(item));
282271
const [focusableItem] = this.#focusableItems;
283272
const activeItem = focusableItem ?? first ?? this.firstItem;
284273
this.setActiveItem(activeItem);
285274
}
286275

276+
/** @deprecated use setActiveItem */
277+
focusOnItem(item?: Item): void {
278+
this.setActiveItem(item);
279+
}
280+
287281
/**
288282
* from array of HTML items, and sets active items
289283
* @deprecated: use getItems and getItemContainer option functions
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type { ReactiveController, ReactiveControllerHost } from 'lit';
2+
3+
import { Logger } from '@patternfly/pfe-core/controllers/logger.js';
4+
5+
export interface TabsAriaControllerOptions<Tab, Panel> {
6+
/** Add an `isTab` predicate to ensure this tabs instance' state does not leak into parent tabs' state */
7+
isTab: (node: unknown) => node is Tab;
8+
isActiveTab: (tab: Tab) => boolean;
9+
/** Add an `isPanel` predicate to ensure this tabs instance' state does not leak into parent tabs' state */
10+
isPanel: (node: unknown) => node is Panel;
11+
getHTMLElement?: () => HTMLElement;
12+
}
13+
14+
export class TabsAriaController<
15+
Tab extends HTMLElement = HTMLElement,
16+
Panel extends HTMLElement = HTMLElement,
17+
> implements ReactiveController {
18+
#logger: Logger;
19+
20+
#host: ReactiveControllerHost;
21+
22+
#element: HTMLElement;
23+
24+
#tabPanelMap = new Map<Tab, Panel>();
25+
26+
#options: TabsAriaControllerOptions<Tab, Panel>;
27+
28+
#mo = new MutationObserver(this.#onSlotchange.bind(this));
29+
30+
get tabs() {
31+
return [...this.#tabPanelMap.keys()] as Tab[];
32+
}
33+
34+
get activeTab(): Tab | undefined {
35+
return this.tabs.find(x => this.#options.isActiveTab(x));
36+
}
37+
38+
/**
39+
* @example Usage in PfTab
40+
* ```ts
41+
* new TabsController(this, {
42+
* isTab: (x): x is PfTab => x instanceof PfTab,
43+
* isPanel: (x): x is PfTabPanel => x instanceof PfTabPanel
44+
* });
45+
* ```
46+
*/
47+
constructor(
48+
host: ReactiveControllerHost,
49+
options: TabsAriaControllerOptions<Tab, Panel>,
50+
) {
51+
this.#options = options;
52+
this.#logger = new Logger(host);
53+
if (host instanceof HTMLElement) {
54+
this.#element = host;
55+
} else {
56+
const element = options.getHTMLElement?.();
57+
if (!element) {
58+
throw new Error('TabsController must be instantiated with an HTMLElement or a `getHTMLElement()` option');
59+
}
60+
this.#element = element;
61+
}
62+
(this.#host = host).addController(this);
63+
this.#element.addEventListener('slotchange', this.#onSlotchange);
64+
if (this.#element.isConnected) {
65+
this.hostConnected();
66+
}
67+
}
68+
69+
hostConnected() {
70+
this.#mo.observe(this.#element, { attributes: false, childList: true, subtree: false });
71+
this.#onSlotchange();
72+
}
73+
74+
hostUpdated() {
75+
for (const [tab, panel] of this.#tabPanelMap) {
76+
panel.setAttribute('aria-labelledby', tab.id);
77+
tab.setAttribute('aria-controls', panel.id);
78+
}
79+
}
80+
81+
hostDisconnected(): void {
82+
this.#mo.disconnect();
83+
}
84+
85+
/**
86+
* zip the tabs and panels together into #tabPanelMap
87+
*/
88+
#onSlotchange() {
89+
this.#tabPanelMap.clear();
90+
const tabs = [];
91+
const panels = [];
92+
for (const child of this.#element.children) {
93+
if (this.#options.isTab(child)) {
94+
tabs.push(child);
95+
} else if (this.#options.isPanel(child)) {
96+
panels.push(child);
97+
}
98+
}
99+
if (tabs.length > panels.length) {
100+
this.#logger.warn('Too many tabs!');
101+
} else if (panels.length > tabs.length) {
102+
this.#logger.warn('Too many panels!');
103+
}
104+
while (tabs.length) {
105+
this.#tabPanelMap.set(tabs.shift()!, panels.shift()!);
106+
}
107+
this.#host.requestUpdate();
108+
}
109+
110+
panelFor(tab: Tab): Panel | undefined {
111+
return this.#tabPanelMap.get(tab);
112+
}
113+
114+
tabFor(panel: Panel): Tab | undefined {
115+
for (const [tab, panelToCheck] of this.#tabPanelMap) {
116+
if (panel === panelToCheck) {
117+
return tab;
118+
}
119+
}
120+
}
121+
}

0 commit comments

Comments
 (0)