Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
121 changes: 121 additions & 0 deletions core/pfe-core/controllers/tabs-aria-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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);
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) {
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