Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(tabs)!: tabscontroller refactor #2699

Merged
merged 17 commits into from
Mar 6, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/context-with-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@patternfly/pfe-core": minor
---
**Context**: added `createContextWithRoot`. Use this when creating contexts that
are shared with child elements.
2 changes: 1 addition & 1 deletion .changeset/pf-select.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
A select list enables users to select one or more items from a list.

```html
<pf-select>
<pf-select placeholder="Choose a color">
<pf-option>Blue</pf-option>
<pf-option>Green</pf-option>
<pf-option>Magenta</pf-option>
Expand Down
4 changes: 2 additions & 2 deletions .changeset/tabs-controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
"@patternfly/core": minor
---

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

```ts
#tabs = new TabsController(this, {
#tabs = new TabsAriaController(this, {
isTab: (x: Node): x is PfTab => x instanceof PfTab,
isPanel: (x: Node): x is PfTabPanel => x instanceof PfTabPanel,
});
Expand Down
24 changes: 9 additions & 15 deletions core/pfe-core/controllers/roving-tabindex-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export class RovingTabindexController<
}
RovingTabindexController.hosts.set(host, this);
this.host.addController(this);
this.#init();
this.updateItems();
}

hostUpdated() {
Expand All @@ -151,7 +151,7 @@ export class RovingTabindexController<
if (oldContainer !== newContainer) {
oldContainer?.removeEventListener('keydown', this.#onKeydown);
RovingTabindexController.elements.delete(oldContainer!);
this.#init();
this.updateItems();
}
if (newContainer) {
this.#initContainer(newContainer);
Expand All @@ -167,12 +167,6 @@ export class RovingTabindexController<
this.#gainedInitialFocus = false;
}

#init() {
if (typeof this.#options?.getItems === 'function') {
this.updateItems(this.#options.getItems());
}
}

#initContainer(container: Element) {
RovingTabindexController.elements.set(container, this);
this.#itemsContainer = container;
Expand Down Expand Up @@ -267,23 +261,23 @@ export class RovingTabindexController<
}
}

/** @deprecated use setActiveItem */
focusOnItem(item?: Item): void {
this.setActiveItem(item);
}

/**
* Focuses next focusable item
*/
updateItems(items?: Item[]) {
this.#items = items ?? this.#options.getItems?.() ?? [];
updateItems(items: Item[] = this.#options.getItems?.() ?? []) {
this.#items = items;
const sequence = [...this.#items.slice(this.#itemIndex - 1), ...this.#items.slice(0, this.#itemIndex - 1)];
const first = sequence.find(item => this.#focusableItems.includes(item));
const [focusableItem] = this.#focusableItems;
const activeItem = focusableItem ?? first ?? this.firstItem;
this.setActiveItem(activeItem);
}

/** @deprecated use setActiveItem */
focusOnItem(item?: Item): void {
this.setActiveItem(item);
}

/**
* from array of HTML items, and sets active items
* @deprecated: use getItems and getItemContainer option functions
Expand Down
123 changes: 123 additions & 0 deletions core/pfe-core/controllers/tabs-aria-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';

import { Logger } from '@patternfly/pfe-core/controllers/logger.js';

export interface TabsAriaControllerOptions<Tab, Panel> {
/** Add an `isTab` predicate to ensure this tabs instance' state does not leak into parent tabs' state */
isTab: (node: unknown) => node is Tab;
isActiveTab: (tab: Tab) => boolean;
/** Add an `isPanel` predicate to ensure this tabs instance' state does not leak into parent tabs' state */
isPanel: (node: unknown) => node is Panel;
getHTMLElement?: () => HTMLElement;
}

export class TabsAriaController<
Tab extends HTMLElement = HTMLElement,
Panel extends HTMLElement = HTMLElement,
> implements ReactiveController {
#logger: Logger;

#host: ReactiveControllerHost;

#element: HTMLElement;

#tabPanelMap = new Map<Tab, Panel>();

#options: TabsAriaControllerOptions<Tab, Panel>;

#mo = new MutationObserver(this.#onSlotchange.bind(this));

get tabs() {
return [...this.#tabPanelMap.keys()] as Tab[];
}

get activeTab(): Tab | undefined {
return this.tabs.find(x => this.#options.isActiveTab(x));
}

/**
* @example Usage in PfTab
* ```ts
* new TabsController(this, {
* isTab: (x): x is PfTab => x instanceof PfTab,
* isPanel: (x): x is PfTabPanel => x instanceof PfTabPanel
* });
* ```
*/
constructor(
host: ReactiveControllerHost,
options: TabsAriaControllerOptions<Tab, Panel>,
) {
this.#options = options;
this.#logger = new Logger(host);
if (host instanceof HTMLElement) {
this.#element = host;
} else {
const element = options.getHTMLElement?.();
if (!element) {
throw new Error('TabsController must be instantiated with an HTMLElement or a `getHTMLElement()` option');
}
this.#element = element;
}
(this.#host = host).addController(this);
this.#element.addEventListener('slotchange', this.#onSlotchange);
zeroedin marked this conversation as resolved.
Show resolved Hide resolved
if (this.#element.isConnected) {
this.hostConnected();
}
}

hostConnected() {
this.#mo.observe(this.#element, { attributes: false, childList: true, subtree: false });
this.#onSlotchange();
}

hostUpdated() {
for (const [tab, panel] of this.#tabPanelMap) {
if (!panel.hasAttribute('aria-labelledby')) {
bennypowers marked this conversation as resolved.
Show resolved Hide resolved
panel.setAttribute('aria-labelledby', tab.id);
}
tab.setAttribute('aria-controls', panel.id);
}
}

hostDisconnected(): void {
this.#mo.disconnect();
}

/**
* zip the tabs and panels together into #tabPanelMap
*/
#onSlotchange() {
this.#tabPanelMap.clear();
const tabs = [];
const panels = [];
for (const child of this.#element.children) {
if (this.#options.isTab(child)) {
tabs.push(child);
} else if (this.#options.isPanel(child)) {
panels.push(child);
}
}
if (tabs.length > panels.length) {
this.#logger.warn('Too many tabs!');
} else if (panels.length > tabs.length) {
this.#logger.warn('Too many panels!');
}
while (tabs.length) {
this.#tabPanelMap.set(tabs.shift()!, panels.shift()!);
}
this.#host.requestUpdate();
}

panelFor(tab: Tab): Panel | undefined {
return this.#tabPanelMap.get(tab);
}

tabFor(panel: Panel): Tab | undefined {
for (const [tab, panelToCheck] of this.#tabPanelMap) {
if (panel === panelToCheck) {
return tab;
}
}
}
}
Loading
Loading