diff --git a/.changeset/hip-bags-live.md b/.changeset/hip-bags-live.md new file mode 100644 index 0000000000..bf24471e6a --- /dev/null +++ b/.changeset/hip-bags-live.md @@ -0,0 +1,5 @@ +--- +"@patternfly/elements": minor +--- + +``: improved overflow handling, added dynamic tab creation support diff --git a/.changeset/logger-host-loose.md b/.changeset/logger-host-loose.md new file mode 100644 index 0000000000..f5b0cd4ea8 --- /dev/null +++ b/.changeset/logger-host-loose.md @@ -0,0 +1,4 @@ +--- +"@patternfly/pfe-core": minor +--- +`Logger`: loosen the type of allowed controller hosts diff --git a/.changeset/overflow-controller.md b/.changeset/overflow-controller.md new file mode 100644 index 0000000000..18bafed8cc --- /dev/null +++ b/.changeset/overflow-controller.md @@ -0,0 +1,5 @@ +--- +"@patternfly/core": minor +--- + +`OverflowController`: recalculate overflow when the window size changes and when tabs are dynamically created. diff --git a/.changeset/tabs-controller.md b/.changeset/tabs-controller.md new file mode 100644 index 0000000000..b26d83e190 --- /dev/null +++ b/.changeset/tabs-controller.md @@ -0,0 +1,15 @@ +--- +"@patternfly/core": minor +--- + +`TabsController`: Added TabsController. This controller is used to manage the state of the tabs and panels. + +```ts +#tabs = new TabsController(this, { + isTab: (x: Node): x is PfTab => x instanceof PfTab, + isPanel: (x: Node): x is PfTabPanel => x instanceof PfTabPanel, +}); +``` + +Please review the [Tabs 2.4 to 3.0 migration guide](https://patternflyelements.org/migration/3.0/tabs) for more +information. diff --git a/core/pfe-core/controllers/logger.ts b/core/pfe-core/controllers/logger.ts index 3492a51613..3e66694834 100644 --- a/core/pfe-core/controllers/logger.ts +++ b/core/pfe-core/controllers/logger.ts @@ -1,12 +1,16 @@ -import type { ReactiveController, ReactiveElement } from 'lit'; +import type { ReactiveController, ReactiveControllerHost } from 'lit'; export class Logger implements ReactiveController { private static logDebug: boolean; - private static instances: WeakMap = new WeakMap(); + private static instances: WeakMap = new WeakMap(); private get prefix() { - return `[${this.host.localName}${this.host.id ? `#${this.host.id}` : ''}]`; + if (this.host instanceof HTMLElement) { + return `[${this.host.localName}${this.host.id ? `#${this.host.id}` : ''}]`; + } else { + return `[${this.host.constructor.name}]`; + } } /** @@ -86,7 +90,7 @@ export class Logger implements ReactiveController { Logger.error(this.prefix, ...msgs); } - constructor(private host: ReactiveElement) { + constructor(private host: ReactiveControllerHost) { // We only need one logger instance per host if (Logger.instances.get(host)) { return Logger.instances.get(host) as Logger; diff --git a/core/pfe-core/controllers/overflow-controller.ts b/core/pfe-core/controllers/overflow-controller.ts index 62899f0f35..22d7e7f5f1 100644 --- a/core/pfe-core/controllers/overflow-controller.ts +++ b/core/pfe-core/controllers/overflow-controller.ts @@ -1,22 +1,44 @@ -import type { ReactiveController, ReactiveControllerHost } from 'lit'; +import type { ReactiveController, ReactiveElement } from 'lit'; import { isElementInView } from '@patternfly/pfe-core/functions/isElementInView.js'; export interface Options { + /** + * Force hide the scroll buttons regardless of overflow + */ hideOverflowButtons?: boolean; + /** + * Delay in ms to wait before checking for overflow + */ + scrollTimeoutDelay?: number; } export class OverflowController implements ReactiveController { + static #instances = new Set(); + + static { + // on resize check for overflows to add or remove scroll buttons + window.addEventListener('resize', () => { + for (const instance of this.#instances) { + instance.onScroll(); + } + }, { capture: false }); + } + + #host: ReactiveElement; /** Overflow container */ #container?: HTMLElement; /** Children that can overflow */ #items: HTMLElement[] = []; - #scrollTimeoutDelay = 0; + #scrollTimeoutDelay: number; #scrollTimeout?: ReturnType; /** Default state */ - #hideOverflowButtons = false; + #hideOverflowButtons: boolean; + + #mo = new MutationObserver(this.#mutationsCallback.bind(this)); + showScrollButtons = false; overflowLeft = false; overflowRight = false; @@ -29,10 +51,22 @@ export class OverflowController implements ReactiveController { return this.#items.at(-1); } - constructor(public host: ReactiveControllerHost & Element, private options?: Options) { - this.host.addController(this); - if (options?.hideOverflowButtons) { - this.#hideOverflowButtons = options?.hideOverflowButtons; + constructor(public host: ReactiveElement, private options?: Options) { + this.#hideOverflowButtons = options?.hideOverflowButtons ?? false; + this.#scrollTimeoutDelay = options?.scrollTimeoutDelay ?? 0; + if (host.isConnected) { + OverflowController.#instances.add(this); + } + (this.#host = host).addController(this); + this.#mo.observe(host, { attributes: false, childList: true, subtree: true }); + } + + async #mutationsCallback(mutations: MutationRecord[]): Promise { + for (const mutation of mutations) { + if (mutation.type === 'childList') { + this.#setOverflowState(); + this.#host.requestUpdate(); + } } } @@ -40,6 +74,9 @@ export class OverflowController implements ReactiveController { if (!this.firstItem || !this.lastItem || !this.#container) { return; } + const prevLeft = this.overflowLeft; + const prevRight = this.overflowRight; + this.overflowLeft = !this.#hideOverflowButtons && !isElementInView(this.#container, this.firstItem); this.overflowRight = !this.#hideOverflowButtons && !isElementInView(this.#container, this.lastItem); let scrollButtonsWidth = 0; @@ -48,7 +85,11 @@ export class OverflowController implements ReactiveController { } this.showScrollButtons = !this.#hideOverflowButtons && this.#container.scrollWidth > (this.#container.clientWidth + scrollButtonsWidth); - this.host.requestUpdate(); + + // only request update if there has been a change + if ((prevLeft !== this.overflowLeft) || (prevRight !== this.overflowRight)) { + this.host.requestUpdate(); + } } init(container: HTMLElement, items: HTMLElement[]) { diff --git a/core/pfe-core/controllers/tabs-controller.ts b/core/pfe-core/controllers/tabs-controller.ts new file mode 100644 index 0000000000..efca501ffd --- /dev/null +++ b/core/pfe-core/controllers/tabs-controller.ts @@ -0,0 +1,308 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; +import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; + +export interface Tab extends HTMLElement { + active: boolean; + disabled: boolean; +} + +export type Panel = HTMLElement + +export interface TabsControllerOptions { + /** Add an `isTab` predicate to ensure this tabs instance' state does not leak into parent tabs' state */ + isTab: (node: unknown) => node is Tab; + /** Add an `isPanel` predicate to ensure this tabs instance' state does not leak into parent tabs' state */ + isPanel: (node: unknown) => node is Panel; + /** If the controller host is not an element, pass a getter to supply the tabs container element */ + getHTMLElement?: () => HTMLElement; +} + +function isReactiveControllerHost(element: HTMLElement): element is HTMLElement & ReactiveControllerHost { + return 'addController' in element; +} + +export class TabExpandEvent extends Event { + constructor( + public tab: Tab, + ) { + super('expand', { bubbles: true, cancelable: true }); + } +} + +export class TabDisabledEvent extends Event { + constructor( + public tab: Tab, + ) { + super('disabled', { bubbles: true, cancelable: true }); + } +} + +export class TabsController implements ReactiveController { + static #instances = new Set(); + + static #tabsClasses = new WeakSet(); + + static { + window.addEventListener('expand', event => { + if (event instanceof TabExpandEvent) { + for (const instance of this.#instances) { + if (instance.#isTab(event.tab) && instance.#tabPanelMap.has(event.tab)) { + instance.#onTabExpand(event.tab); + } + } + } + }); + + window.addEventListener('disabled', event => { + if (event instanceof TabDisabledEvent) { + for (const instance of this.#instances) { + if (instance.#isTab(event.tab) && instance.#tabPanelMap.has(event.tab)) { + instance.#onTabDisabled(); + } + } + } + }); + } + + #logger: Logger; + + #host: ReactiveControllerHost; + + #element: HTMLElement; + + #tabPanelMap = new Map(); + + #isTab: TabsControllerOptions['isTab']; + + #isPanel: TabsControllerOptions['isPanel']; + + #slottedTabs: Tab[] = []; + + #slottedPanels: Panel[] = []; + + #tabindex: RovingTabindexController; + + #mo = new MutationObserver(this.#mutationsCallback.bind(this)); + + #rebuilding: Promise | null = null; + + #init = 0; + + get activeIndex() { + if (!this.#activeTab) { + return -1; + } + return this._tabs.indexOf(this.#activeTab); + } + + /** + * Sets the active index of the tabs element which will set the active tab. + * document.querySelector('tabs-element-selector').activeIndex = 0 + */ + set activeIndex(index: number) { + const firstFocusableTab = this.#tabindex.firstItem; + if (!firstFocusableTab) { + this.#logger.error(`No focusable tabs found.`); + return; + } + + let error = false; + if (this._tabs[index] === undefined) { + error = true; + this.#logger.warn(`The index provided is out of bounds: ${index} in 0 - ${this._tabs.length - 1}. Setting first focusable tab active.`); + } else if (this._tabs[index].disabled || this._tabs[index].hasAttribute('aria-disabled')) { + error = true; + this.#logger.warn(`The tab at index ${index} is disabled. Setting first focusable tab active.`); + } + if (error) { + index = this._tabs.indexOf(firstFocusableTab); + } + this._tabs[index].active = true; + } + + get activeTab(): Tab | undefined { + return this.#activeTab; + } + + set activeTab(tab: Tab | undefined) { + if (tab === undefined || !this.#tabPanelMap.has(tab)) { + this.#logger.warn(`The tab provided is not a valid tab.`); + return; + } + // get tab index + const index = this._tabs.indexOf(tab); + this.activeIndex = index; + } + + protected get _tabs() { + return [...this.#tabPanelMap.keys()] as Tab[]; + } + + get #activeTab(): Tab | undefined { + return this._tabs.find(tab => tab.active); + } + + /** + * @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: TabsControllerOptions) { + this.#logger = new Logger(host); + if (host instanceof HTMLElement) { + this.#element = host; + this.#tabindex = new RovingTabindexController(host); + } else { + const element = options.getHTMLElement?.(); + if (!element) { + throw new Error('TabsController must be instantiated with an HTMLElement of a `getHTMLElement` function'); + } + // TODO(bennypowers): remove after #2570, by forwarding the `getHTMLElement` options + if (!isReactiveControllerHost(element)) { + throw new Error('TabsController\'s host HTMLElement must be a controller host as well'); + } + this.#element = element; + this.#tabindex = new RovingTabindexController(element); + } + this.#isTab = options.isTab; + this.#isPanel = options.isPanel; + TabsController.#tabsClasses.add(host.constructor); + if (this.#element.isConnected) { + TabsController.#instances.add(this); + } + (this.#host = host).addController(this); + this.#mo.observe(this.#element, { attributes: false, childList: true, subtree: false }); + this.#element.addEventListener('slotchange', this.#onSlotchange); + } + + hostConnected() { + TabsController.#instances.add(this); + this.#onSlotchange(); + } + + hostDisconnected() { + TabsController.#instances.delete(this); + } + + async hostUpdate() { + if (this.#init <= 1) { + this.#rebuilding ??= await this.#rebuild(); + this.#host.requestUpdate(); + } + this.#init++; + } + + async #mutationsCallback(mutations: MutationRecord[]): Promise { + for (const mutation of mutations) { + if ([...mutation.addedNodes.values()].some(node => this.#isTab(node))) { + this.#rebuild(); + break; + } + + if ([...mutation.removedNodes.values()].some(node => this.#isTab(node))) { + this.#rebuild(); + break; + } + } + } + + async #rebuild() { + const tabSlot = this.#element.shadowRoot?.querySelector('slot[name=tab]'); + const panelSlot = this.#element.shadowRoot?.querySelector('slot:not([name])'); + this.#slottedPanels = panelSlot?.assignedElements().filter(this.#isPanel) ?? []; + this.#slottedTabs = tabSlot?.assignedElements().filter(this.#isTab) ?? []; + + this.#tabPanelMap.clear(); + await this.#registerSlottedTabs(); + + if (this._tabs.length > 0) { + this.#updateAccessibility(); + // TODO(bennypowers): adjust to fit, in or after #2570 + this.#tabindex.initItems(this._tabs, this.#element); + this.#setActiveTab(); + } + + return null; + } + + async #onSlotchange() { + this.#host.requestUpdate(); + } + + #onTabExpand(tab: Tab) { + this.#tabindex.updateActiveItem(tab); + this.#deactivateExcept(this._tabs.indexOf(tab)); + } + + async #onTabDisabled() { + await this.#rebuild(); + } + + async #registerSlottedTabs(): Promise { + for (const [index, slotted] of this.#slottedTabs.entries()) { + this.#addPairForTab(index, slotted); + } + return null; + } + + #addPairForTab(index: number, tab: Tab) { + const panel = this.#slottedPanels[index]; + if (this.#isPanel(panel)) { + this.#tabPanelMap.set(tab, panel); + } else { + this.#logger.warn(`Tab and panel do not match`, tab, panel); + } + } + + #deactivateExcept(indexToKeep: number) { + [...this.#tabPanelMap].forEach(([tab, panel], currentIndex) => { + tab.active = currentIndex === indexToKeep; + panel.hidden = currentIndex !== indexToKeep; + }); + } + + #setActiveTab() { + // check for an active tab, if not set one + if (!this.#activeTab) { + this.#logger.warn('No active tab found. Setting to first focusable tab.'); + this.#setFirstFocusableTabActive(); + return; + } + if (this.#activeTab.disabled) { + this.#logger.warn('Active tab is disabled. Setting to first focusable tab.'); + this.#setFirstFocusableTabActive(); + return; + } + + // update RTI with active tab and deactivate others + this.#tabindex.updateActiveItem(this.#activeTab); + this.#deactivateExcept(this._tabs.indexOf(this.#activeTab)); + } + + #setFirstFocusableTabActive() { + const first = this.#tabindex.firstItem; + if (!first) { + this.#logger.warn('No focusable tab found.'); + return; + } + const firstFocusable = this._tabs.find(tab => tab === first); + if (firstFocusable) { + firstFocusable.active = true; + } + } + + #updateAccessibility(): void { + for (const [tab, panel] of this.#tabPanelMap) { + if (!panel.hasAttribute('aria-labelledby')) { + panel.setAttribute('aria-labelledby', tab.id); + } + tab.setAttribute('aria-controls', panel.id); + } + } +} diff --git a/core/pfe-core/package.json b/core/pfe-core/package.json index 3180309240..c1f3c6db6b 100644 --- a/core/pfe-core/package.json +++ b/core/pfe-core/package.json @@ -29,6 +29,7 @@ "./controllers/slot-controller.js": "./controllers/slot-controller.js", "./controllers/style-controller.js": "./controllers/style-controller.js", "./controllers/timestamp-controller.js": "./controllers/timestamp-controller.js", + "./controllers/tabs-controller.js": "./controllers/tabs-controller.js", "./decorators/bound.js": "./decorators/bound.js", "./decorators/cascades.js": "./decorators/cascades.js", "./decorators/deprecation.js": "./decorators/deprecation.js", diff --git a/elements/pf-tabs/BaseTabs.css b/elements/pf-tabs/BaseTabs.css index c927f41437..fae4ed59ce 100644 --- a/elements/pf-tabs/BaseTabs.css +++ b/elements/pf-tabs/BaseTabs.css @@ -84,4 +84,3 @@ button:nth-of-type(2) { button:disabled { pointer-events: none; } - diff --git a/elements/pf-tabs/demo/active-tab-disabled.html b/elements/pf-tabs/demo/active-tab-disabled.html new file mode 100644 index 0000000000..4f77ecff65 --- /dev/null +++ b/elements/pf-tabs/demo/active-tab-disabled.html @@ -0,0 +1,31 @@ + + Users + Users + Containers + Containers + Database + Database + Disabled + Disabled + Aria Disabled + Aria Disabled + + + + + + + + diff --git a/elements/pf-tabs/demo/box.html b/elements/pf-tabs/demo/box.html new file mode 100644 index 0000000000..6d32bd2da0 --- /dev/null +++ b/elements/pf-tabs/demo/box.html @@ -0,0 +1,43 @@ + + Users + Users + Containers + Containers + Database + Database + Disabled + Disabled + Aria Disabled + Aria Disabled + + +
+ + +
+ + + + diff --git a/elements/pf-tabs/demo/demo.css b/elements/pf-tabs/demo/demo.css deleted file mode 100644 index 4debbdf06a..0000000000 --- a/elements/pf-tabs/demo/demo.css +++ /dev/null @@ -1,62 +0,0 @@ -main { - display: block; - grid: unset; -} - -.example { - margin: 0 auto; - padding: 0 16px 32px; - width: 100%; - max-width: 1008px; -} - -.example:last-of-type { - padding-block-end: 64px; -} - -.input { - margin-block-start: 16px; -} - -@media screen and (min-width: 768px) { - .example { - padding: 0 32px 32px; - } -} - -/* - Inset Examples set class on host::part(tabs-container) -*/ -.inset-sm::part(tabs-container) { - --pf-c-tabs--inset: var(--pf-global--spacer--sm, 0.5rem); - --pf-c-tabs--m-vertical--inset: var(--pf-global--spacer--sm, 0.5rem); - --pf-c-tabs--m-vertical--m-box--inset: var(--pf-global--spacer--sm, 0.5rem); -} - -.inset-md::part(tabs-container) { - --pf-c-tabs--inset: var(--pf-global--spacer--md, 1rem); - --pf-c-tabs--m-vertical--inset: var(--pf-global--spacer--md, 1rem); - --pf-c-tabs--m-vertical--m-box--inset: var(--pf-global--spacer--md, 1rem); -} - -.inset-lg::part(tabs-container) { - --pf-c-tabs--inset: var(--pf-global--spacer--lg, 1.5rem); - --pf-c-tabs--m-vertical--inset: var(--pf-global--spacer--lg, 1.5rem); - --pf-c-tabs--m-vertical--m-box--inset: var(--pf-global--spacer--lg, 1.5rem); -} - -.inset-xl::part(tabs-container) { - --pf-c-tabs--inset: var(--pf-global--spacer--xl, 2rem); - --pf-c-tabs--m-vertical--inset: var(--pf-global--spacer--xl, 2rem); - --pf-c-tabs--m-vertical--m-box--inset: var(--pf-global--spacer--xl, 2rem); -} - -.inset-2xl::part(tabs-container) { - --pf-c-tabs--inset: var(--pf-global--spacer--2xl, 3rem); - --pf-c-tabs--m-vertical--inset: var(--pf-global--spacer--2xl, 3rem); - --pf-c-tabs--m-vertical--m-box--inset: var(--pf-global--spacer--2xl, 3rem); -} - -.inset-page::part(tabs-container) { - --pf-c-tabs--inset: var(--pf-c-tabs--m-page-insets--inset, var(--pf-global--spacer--md, 1rem)); -} diff --git a/elements/pf-tabs/demo/dynamic-tabs.html b/elements/pf-tabs/demo/dynamic-tabs.html new file mode 100644 index 0000000000..7ee1944f99 --- /dev/null +++ b/elements/pf-tabs/demo/dynamic-tabs.html @@ -0,0 +1,69 @@ +
+ + Add Tab + + + + + Delete Tab + +
+ + + Users + Users + Containers + Containers Focusable element + Database + Database + Disabled + Disabled + Aria Disabled + Aria Disabled + + + + + + + diff --git a/elements/pf-tabs/demo/filled-with-icons.html b/elements/pf-tabs/demo/filled-with-icons.html new file mode 100644 index 0000000000..498285bb49 --- /dev/null +++ b/elements/pf-tabs/demo/filled-with-icons.html @@ -0,0 +1,30 @@ + + Users + Containers + Database + Users + Containers + Database + + + + + + + + diff --git a/elements/pf-tabs/demo/filled.html b/elements/pf-tabs/demo/filled.html new file mode 100644 index 0000000000..709f9b389c --- /dev/null +++ b/elements/pf-tabs/demo/filled.html @@ -0,0 +1,30 @@ + + Users + Users + Containers + Containers + Database + Database + + + + + + + + diff --git a/elements/pf-tabs/demo/icons-and-text.html b/elements/pf-tabs/demo/icons-and-text.html new file mode 100644 index 0000000000..a779efcbd2 --- /dev/null +++ b/elements/pf-tabs/demo/icons-and-text.html @@ -0,0 +1,36 @@ + + Users + Containers + Database + Server + System + Network + Users + Containers + Database + Server + System + Network + + + + + + + diff --git a/elements/pf-tabs/demo/inset.html b/elements/pf-tabs/demo/inset.html new file mode 100644 index 0000000000..039a6c3889 --- /dev/null +++ b/elements/pf-tabs/demo/inset.html @@ -0,0 +1,82 @@ + + Users + Users + Containers + Containers + Database + Database + Disabled + Disabled + Aria Disabled + Aria Disabled + + +
+

Inset size:

+ + + + + + +
+ + + + + diff --git a/elements/pf-tabs/demo/manual-activation.html b/elements/pf-tabs/demo/manual-activation.html new file mode 100644 index 0000000000..f76936cda0 --- /dev/null +++ b/elements/pf-tabs/demo/manual-activation.html @@ -0,0 +1,34 @@ + + Users + Containers + Database + Disabled + Aria Disabled + Users + Containers + Database + Disabled + Aria Disabled + + + + + + + + diff --git a/elements/pf-tabs/demo/nested.html b/elements/pf-tabs/demo/nested.html new file mode 100644 index 0000000000..1b9e83554b --- /dev/null +++ b/elements/pf-tabs/demo/nested.html @@ -0,0 +1,45 @@ + + Users + Users + Containers + Containers Focusable element + Database + + + Users 2 + Users 2 + Containers 2 + Containers 2Focusable element + Database 2 + Database 2 + Disabled 2 + Disabled 2 + Aria Disabled 2 + Aria Disabled 2 + + + Disabled + Disabled + Aria Disabled + Aria Disabled + + + + + + diff --git a/elements/pf-tabs/demo/overflow.html b/elements/pf-tabs/demo/overflow.html new file mode 100644 index 0000000000..ec75811334 --- /dev/null +++ b/elements/pf-tabs/demo/overflow.html @@ -0,0 +1,49 @@ + + Users + Users + Containers + Containers + Database + Database + Server + Server + System + System + Network + Network + + +
+ + +
+ + + + + diff --git a/elements/pf-tabs/demo/pf-tabs.html b/elements/pf-tabs/demo/pf-tabs.html index e257a1ab36..f666039a68 100644 --- a/elements/pf-tabs/demo/pf-tabs.html +++ b/elements/pf-tabs/demo/pf-tabs.html @@ -1,206 +1,34 @@ - - + + Users + Users + Containers + Containers Focusable element + Database + Database + Disabled + Disabled + Aria Disabled + Aria Disabled + + + + + -
-

Default

- - Users - Users - Containers - Containers Focusable element - Database - Database - Disabled - Disabled - Aria Disabled - Aria Disabled - -
-
-

Box

- - Users - Users - Containers - Containers - Database - Database - Disabled - Disabled - Aria Disabled - Aria Disabled - -
- Box Type: - - - - -
- -
-

Default overflow

- - Users - Users - Containers - Containers - Database - Database - Server - Server - System - System - Network - Network - - -
- Box variant: - - - - -
- -
-

Vertical

- - Users - Users - Containers - Containers - Database - Database - Disabled - Disabled - Aria Disabled - Aria Disabled - - -
- Box variant: - - - - -
- -
-

Inset

- - Users - Users - Containers - Containers - Database - Database - Disabled - Disabled - Aria Disabled - Aria Disabled - - -
-

Inset size:

- - - - - - -
-
- -
-

Icons and text

- - Users - Containers - Database - Server - System - Network - Users - Containers - Database - Server - System - Network - -
- -
-

Filled

- - Users - Users - Containers - Containers - Database - Database - -
- -
-

Filled with icons

- - Users - Containers - Database - Users - Containers - Database - -
- -
-

Active Tab is Disabled

- - Users - Users - Containers - Containers - Database - Database - Disabled - Disabled - Aria Disabled - Aria Disabled - -
- -
-

Tabs first in markup

- - Users - Containers - Database - Disabled - Aria Disabled - Users - Containers - Database - Disabled - Aria Disabled - -
- - -
-

Manual Activation

- - Users - Containers - Database - Disabled - Aria Disabled - Users - Containers - Database - Disabled - Aria Disabled - -
diff --git a/elements/pf-tabs/demo/pf-tabs.js b/elements/pf-tabs/demo/pf-tabs.js deleted file mode 100644 index 55ae934a57..0000000000 --- a/elements/pf-tabs/demo/pf-tabs.js +++ /dev/null @@ -1,43 +0,0 @@ -import '@patternfly/elements/pf-icon/pf-icon.js'; -import '@patternfly/elements/pf-switch/pf-switch.js'; -import '@patternfly/elements/pf-tabs/pf-tabs.js'; - -const toggleVariant = document.getElementById('toggle-variant'); -const resize = document.getElementById('overflow'); -const verticalInput = document.getElementById('toggle-vertical'); -const resizeInput = document.getElementById('toggle-resize'); -const verticalVariant = document.querySelector('pf-tabs[vertical]'); -const boxVariant = document.querySelector('pf-tabs[box]'); -const inset = document.querySelector('#inset'); - -function variantToggle() { - boxVariant.setAttribute('box', toggleVariant.checked ? 'dark' : 'light'); -} - -function verticalToggle() { - if (verticalInput.checked) { - verticalVariant.setAttribute('box', 'dark'); - } else { - verticalVariant.removeAttribute('box'); - } -} - -function resizeToggle() { - if (resizeInput.checked) { - resize.setAttribute('box', 'dark'); - } else { - resize.removeAttribute('box'); - } -} - -function insetToggle(event) { - inset.classList = event.target.value; -} - -for (const input of document.querySelectorAll('input[name="toggle-inset"]')) { - input.addEventListener('change', insetToggle); -} - -toggleVariant.addEventListener('change', variantToggle); -resizeInput.addEventListener('change', resizeToggle); -verticalInput.addEventListener('change', verticalToggle); diff --git a/elements/pf-tabs/demo/tabs-first-in-markup.html b/elements/pf-tabs/demo/tabs-first-in-markup.html new file mode 100644 index 0000000000..9290d43d1e --- /dev/null +++ b/elements/pf-tabs/demo/tabs-first-in-markup.html @@ -0,0 +1,34 @@ + + Users + Containers + Database + Disabled + Aria Disabled + Users + Containers + Database + Disabled + Aria Disabled + + + + + + + + diff --git a/elements/pf-tabs/demo/vertical.html b/elements/pf-tabs/demo/vertical.html new file mode 100644 index 0000000000..ee5490dfc7 --- /dev/null +++ b/elements/pf-tabs/demo/vertical.html @@ -0,0 +1,48 @@ + + Users + Users + Containers + Containers + Database + Database + Disabled + Disabled + Aria Disabled + Aria Disabled + + +
+ + +
+ + + + + + diff --git a/elements/pf-tabs/pf-tab-panel.css b/elements/pf-tabs/pf-tab-panel.css index a478c3422f..e7da6b0e29 100644 --- a/elements/pf-tabs/pf-tab-panel.css +++ b/elements/pf-tabs/pf-tab-panel.css @@ -1,3 +1,11 @@ +:host { + display: block; +} + +:host([hidden]) { + display: none; +} + :host([box="light"]) { background-color: var(--pf-c-tab-content--m-light-300, var(--pf-global--BackgroundColor--light-300, #f0f0f0)); } diff --git a/elements/pf-tabs/pf-tab-panel.ts b/elements/pf-tabs/pf-tab-panel.ts index b272e571aa..d448a1cdf0 100644 --- a/elements/pf-tabs/pf-tab-panel.ts +++ b/elements/pf-tabs/pf-tab-panel.ts @@ -1,8 +1,9 @@ +import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import styles from './pf-tab-panel.css'; -import { BaseTabPanel } from './BaseTabPanel.js'; +import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; /** * @slot - Tab panel content @@ -12,8 +13,34 @@ import { BaseTabPanel } from './BaseTabPanel.js'; * @csspart container - container for the panel content */ @customElement('pf-tab-panel') -export class PfTabPanel extends BaseTabPanel { - static readonly styles = [...BaseTabPanel.styles, styles]; +export class PfTabPanel extends LitElement { + static readonly styles = [styles]; + + #internals = this.attachInternals(); + + render() { + return html` + + `; + } + + override connectedCallback() { + super.connectedCallback(); + this.id ||= getRandomId('pf-tab-panel'); + this.hidden ??= true; + this.#internals.role = 'tabpanel'; + + /* + To make it easy for screen reader users to navigate from a tab + to the beginning of content in the active tabpanel, the tabpanel + element has tabindex="0" to include the panel in the page Tab sequence. + It is recommended that all tabpanel elements in a tab set are focusable + if there are any panels in the set that contain content where the first + element in the panel is not focusable. + https://www.w3.org/WAI/ARIA/apg/example-index/tabs/tabs-automatic + */ + this.tabIndex = 0; + } } declare global { diff --git a/elements/pf-tabs/pf-tab.css b/elements/pf-tabs/pf-tab.css index d36f3990f6..e9c19d2995 100644 --- a/elements/pf-tabs/pf-tab.css +++ b/elements/pf-tabs/pf-tab.css @@ -1,4 +1,6 @@ :host { + display: flex; + flex: none; scroll-snap-align: var(--pf-c-tabs__item--ScrollSnapAlign, end); } @@ -13,11 +15,29 @@ --pf-c-tabs__link--before--BorderBottomColor: var(--pf-c-tabs__link--BackgroundColor, transparent); } -:host(.first[box][active]) #current::before { - left: calc(var(--pf-c-tabs__link--before--border-width--base, var(--pf-global--BorderWidth--sm, 1px)) * -1); +:host([vertical]) [part="text"] { + max-width: 100%; + overflow-wrap: break-word; +} + +[hidden] { + display: none !important; +} + +slot[name="icon"] { + display: block; } button { + margin: 0; + font-family: inherit; + font-size: 100%; + border: 0; + position: relative; + display: flex; + flex: 1; + text-decoration: none; + cursor: pointer; align-items: center; gap: var(--pf-c-tabs__link--child--MarginRight, var(--pf-global--spacer--md, 1rem)); line-height: var(--pf-global--LineHeight--md, 1.5); @@ -34,6 +54,21 @@ button { background-color: var(--pf-c-tabs__link--BackgroundColor, transparent); } +button::before, +button::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + content: ""; + border-style: solid; + padding: 0; + margin: 0; + background-color: transparent; + pointer-events: none; +} + button::before { border-block-start-width: var(--pf-c-tabs__link--before--BorderTopWidth, 0); border-inline-end-width: var(--pf-c-tabs__link--before--BorderRightWidth, 0); @@ -72,6 +107,19 @@ button:active { --pf-c-tabs__link--after--BorderWidth: var(--pf-c-tabs__link--active--after--BorderWidth, var(--pf-global--BorderWidth--lg, 3px)); } +:host([fill]) button { + flex-basis: 100%; + justify-content: center; +} + +:host(:disabled) button { + pointer-events: none; +} + +:host([aria-disabled="true"]) button { + cursor: default; +} + :host([box]) button { --pf-c-tabs__link--after--BorderTopWidth: var(--pf-c-tabs__link--after--BorderWidth, 0); } @@ -93,7 +141,7 @@ button:active { top: calc(var(--pf-c-tabs__link--before--border-width--base, var(--pf-global--BorderWidth--sm, 1px)) * -1); } -:host(.first[box][vertical]) button::after, +:host([box][vertical]:first-of-type) button::after, :host([box][vertical][active]) button::after { top: 0; } @@ -104,12 +152,12 @@ button:active { --pf-c-tabs__link--before--BorderBottomColor: var(--pf-c-tabs__link--before--border-color--base, var(--pf-global--BorderColor--100, #d2d2d2)); } -:host(.first[box][active]) button::before { +:host([box][active]:first-of-type) button::before { border-block-start-width: var(--pf-c-tabs--m-box__item--m-current--first-child__link--before--BorderTopWidth, var(--pf-c-tabs__link--before--border-width--base, var(--pf-global--BorderWidth--sm, 1px))); border-inline-start-width: var(--pf-c-tabs--m-box__item--m-current--first-child__link--before--BorderLeftWidth, var(--pf-c-tabs__link--before--border-width--base, var(--pf-global--BorderWidth--sm, 1px))); } -:host(.last[box][active]) button::before { +:host([box][active]:last-of-type) button::before { border-inline-end-width: var(--pf-c-tabs--m-box__item--m-current--last-child__link--before--BorderRightWidth, var(--pf-c-tabs--before--border-width--base, var(--pf-global--BorderWidth--sm, 1px))); } diff --git a/elements/pf-tabs/pf-tab.ts b/elements/pf-tabs/pf-tab.ts index a111a445b4..efbac05978 100644 --- a/elements/pf-tabs/pf-tab.ts +++ b/elements/pf-tabs/pf-tab.ts @@ -1,9 +1,13 @@ +import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; +import { queryAssignedElements } from 'lit/decorators/query-assigned-elements.js'; +import { query } from 'lit/decorators/query.js'; import { observed } from '@patternfly/pfe-core/decorators.js'; +import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; -import { BaseTab } from './BaseTab.js'; +import { TabExpandEvent, TabDisabledEvent } from '@patternfly/pfe-core/controllers/tabs-controller.js'; import styles from './pf-tab.css'; @@ -68,14 +72,82 @@ import styles from './pf-tab.css'; * @fires { TabExpandEvent } expand - when a tab expands */ @customElement('pf-tab') -export class PfTab extends BaseTab { - static readonly styles = [...BaseTab.styles, styles]; +export class PfTab extends LitElement { + static readonly styles = [styles]; + + static override shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, delegatesFocus: true }; + + @query('button') private button!: HTMLButtonElement; + + @queryAssignedElements({ slot: 'icon', flatten: true }) + private icons!: Array; + + @property({ type: Boolean }) manual = false; @observed @property({ reflect: true, type: Boolean }) active = false; @observed @property({ reflect: true, type: Boolean }) disabled = false; + + #internals = this.attachInternals(); + + override connectedCallback() { + super.connectedCallback(); + this.id ||= getRandomId(this.localName); + this.#internals.role = 'tab'; + this.#internals.ariaDisabled = this.#setInternalsAriaDisabled(); + this.addEventListener('click', this.#onClick); + this.addEventListener('focus', this.#onFocus); + } + + render() { + return html` + + `; + } + + #onClick() { + if (this.disabled || this.#internals.ariaDisabled === 'true') { + return; + } + this.#activate(); + this.button.focus(); + } + + #onFocus() { + if (this.manual || this.#internals.ariaDisabled === 'true') { + return; + } + this.#activate(); + } + + #activate() { + this.active = true; + } + + private _activeChanged(oldVal: boolean, newVal: boolean) { + if (oldVal !== newVal && newVal === true) { + this.dispatchEvent(new TabExpandEvent(this)); + } + } + + private _disabledChanged() { + this.dispatchEvent(new TabDisabledEvent(this)); + } + + #setInternalsAriaDisabled() { + if (this.disabled) { + return 'true'; + } + return this.ariaDisabled ?? 'false'; + } } declare global { diff --git a/elements/pf-tabs/pf-tabs.css b/elements/pf-tabs/pf-tabs.css index 89573c3ea1..95e5a1aaaf 100644 --- a/elements/pf-tabs/pf-tabs.css +++ b/elements/pf-tabs/pf-tabs.css @@ -1,3 +1,90 @@ +:host { + display: block; +} + +[part="tabs-container"] { + position: relative; + display: flex; + overflow: hidden; +} + +[part="tabs-container"]::before { + position: absolute; + right: 0; + bottom: 0; + left: 0; + border-style: solid; +} + +:host button { + opacity: 1; +} + +:host button:nth-of-type(1) { + margin-inline-end: 0; + translate: 0 0; +} + +:host button:nth-of-type(2) { + margin-inline-start: 0; + translate: 0 0; +} + +[part="tabs"], +[part="panels"] { + display: block; +} + +[part="tabs"] { + scrollbar-width: none; + position: relative; + max-width: 100%; + overflow-x: auto; +} + +[part="tabs-container"]::before, +[part="tabs"]::before, +button::before { + position: absolute; + right: 0; + bottom: 0; + left: 0; + content: ""; + border-style: solid; +} + +[part="tabs"]::before, +button::before { + top: 0; +} + +button, +[part="tabs"]::before { + border: 0; +} + +button { + flex: none; + line-height: 1; + opacity: 0; +} + +button::before { + border-block-start-width: 0; +} + +button:nth-of-type(1) { + translate: -100% 0; +} + +button:nth-of-type(2) { + translate: 100% 0; +} + +button:disabled { + pointer-events: none; +} + [part="tabs-container"] { width: var(--pf-c-tabs--Width, auto); padding-inline-end: var(--pf-c-tabs--inset, 0); @@ -12,16 +99,6 @@ border-inline-start-width: var(--pf-c-tabs--before--BorderLeftWidth, 0); } -/* workaround to disable scroll right button when last tab is aria-disabled */ -:host(:not([vertical])) ::slotted(pf-tab[aria-disabled=true]:last-of-type) { - translate: calc(-1 * var(--pf-c-tabs__link--disabled--before--BorderRightWidth, 1px)) 0; -} - -/* workaround to disable scroll left button when first tab is aria-disabled */ -:host(:not([vertical])) ::slotted(pf-tab[aria-disabled=true]:first-of-type) { - translate: var(--pf-c-tabs__link--disabled--before--BorderRightWidth, 1px) 0; -} - :host([box]) [part="tabs-container"] { --pf-c-tabs__link--BackgroundColor: var(--pf-c-tabs--m-box__link--BackgroundColor, var(--pf-global--BackgroundColor--200, #f0f0f0)); --pf-c-tabs__link--disabled--BackgroundColor: var(--pf-c-tabs--m-box__link--disabled--BackgroundColor, var(--pf-global--disabled-color--200, #d2d2d2)); diff --git a/elements/pf-tabs/pf-tabs.ts b/elements/pf-tabs/pf-tabs.ts index ef79f6c08f..0a06b0be01 100644 --- a/elements/pf-tabs/pf-tabs.ts +++ b/elements/pf-tabs/pf-tabs.ts @@ -1,20 +1,26 @@ +import { html, LitElement } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; +import { query } from 'lit/decorators/query.js'; +import { queryAssignedElements } from 'lit/decorators/query-assigned-elements.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { OverflowController } from '@patternfly/pfe-core/controllers/overflow-controller.js'; +import { TabExpandEvent, TabsController } from '@patternfly/pfe-core/controllers/tabs-controller.js'; + +import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; import { cascades } from '@patternfly/pfe-core/decorators.js'; -import { BaseTabs } from './BaseTabs.js'; -import { TabExpandEvent } from './BaseTab.js'; import { PfTab } from './pf-tab.js'; import { PfTabPanel } from './pf-tab-panel.js'; +import '@patternfly/elements/pf-icon/pf-icon.js'; + import styles from './pf-tabs.css'; /** * **Tabs** allow users to navigate between views within the same page or context. * - * @attr {number} active-key - DOM Property: `activeKey` {@default `0`} - * * @csspart container - outer container * @csspart tabs-container - tabs container * @csspart tabs - tablist @@ -61,37 +67,145 @@ import styles from './pf-tabs.css'; * @cssprop {} --pf-c-tabs__scroll-button--disabled--Color {@default `#d2d2d2`} */ @customElement('pf-tabs') -export class PfTabs extends BaseTabs { - static readonly styles = [...BaseTabs.styles, styles]; +export class PfTabs extends LitElement { + static readonly styles = [styles]; protected static readonly scrollTimeoutDelay = 150; - static isTab(element: HTMLElement): element is PfTab { - return element instanceof PfTab; - } - - static isPanel(element: HTMLElement): element is PfTabPanel { - return element instanceof PfTabPanel; - } - static isExpandEvent(event: Event): event is TabExpandEvent { return event instanceof TabExpandEvent; } + /** + * Box styling on tabs. Defaults to null + */ @cascades('pf-tab', 'pf-tab-panel') @property({ reflect: true }) box: 'light' | 'dark' | null = null; + /** + * Set to true to enable vertical tab styling. + */ @cascades('pf-tab', 'pf-tab-panel') @property({ reflect: true, type: Boolean }) vertical = false; + /** + * Set to true to enable filled tab styling. + */ @cascades('pf-tab') @property({ reflect: true, type: Boolean }) fill = false; + /** + * Border bottom tab styling on tabs. To remove the bottom border, set this prop to false. + */ @cascades('pf-tab') @property({ attribute: 'border-bottom' }) borderBottom: 'true' | 'false' = 'true'; - protected get canShowScrollButtons(): boolean { - return !this.vertical; + /** + * Set's the tabs to be manually activated. This means that the tabs will not automatically select + * unless a user clicks on them or uses the keyboard space or enter key to select them. Roving + * tabindex will still update allowing user to keyboard navigate through the tabs with arrow keys. + */ + @cascades('pf-tab') + @property({ reflect: true, type: Boolean }) manual = false; + + /** + * Aria Label for the left scroll button + */ + @property({ reflect: false, attribute: 'label-scroll-left' }) labelScrollLeft = 'Scroll left'; + + /** + * Aria Label for the right scroll button + */ + @property({ reflect: false, attribute: 'label-scroll-right' }) labelScrollRight = 'Scroll left'; + + /** + * The index of the active tab + */ + @property({ attribute: 'active-index', reflect: true, type: Number }) + get activeIndex() { + return this.#tabs.activeIndex; + } + + set activeIndex(index: number) { + this.#tabs.activeIndex = index; + } + + get activeTab(): PfTab { + return this.#tabs.activeTab as PfTab; + } + + set activeTab(tab: PfTab) { + this.#tabs.activeTab = tab; + } + + @query('#tabs') private tabsContainer!: HTMLElement; + + @queryAssignedElements({ slot: 'tab' }) private tabs?: PfTab[]; + + #overflow = new OverflowController(this, { scrollTimeoutDelay: 200 }); + + #tabs = new TabsController(this, { + isTab: (x): x is PfTab => x instanceof PfTab, + isPanel: (x): x is PfTabPanel => x instanceof PfTabPanel, + }); + + override connectedCallback() { + super.connectedCallback(); + this.id ||= getRandomId(this.localName); + } + + override willUpdate(): void { + this.#overflow.update(); + } + + render() { + return html` +
+
${!this.#overflow.showScrollButtons ? '' : html` + `} + ${!this.#overflow.showScrollButtons ? '' : html` + `} +
+ +
+ `; + } + + #scrollLeft() { + this.#overflow.scrollLeft(); + } + + #scrollRight() { + this.#overflow.scrollRight(); + } + + #onSlotChange() { + if (this.tabs) { + this.#overflow.init(this.tabsContainer, this.tabs); + } + } + + select(option: PfTab | number) { + if (typeof option === 'number') { + this.#tabs.activeIndex = option; + } else { + this.activeTab = option; + } } } diff --git a/elements/pf-tabs/test/pf-tabs.spec.ts b/elements/pf-tabs/test/pf-tabs.spec.ts index a60ba9d728..375cceecc5 100644 --- a/elements/pf-tabs/test/pf-tabs.spec.ts +++ b/elements/pf-tabs/test/pf-tabs.spec.ts @@ -1,9 +1,10 @@ import { expect, html, nextFrame, aTimeout } from '@open-wc/testing'; import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; -import { a11ySnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; +import { a11ySnapshot, type A11yTreeSnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; import { setViewport, sendKeys } from '@web/test-runner-commands'; -import { BaseTab } from '../BaseTab.js'; +import { allUpdates } from '@patternfly/pfe-tools/test/utils.js'; + import { PfTabs } from '../pf-tabs.js'; import { PfTab } from '../pf-tab.js'; import { PfTabPanel } from '../pf-tab-panel.js'; @@ -12,16 +13,12 @@ import '@patternfly/pfe-tools/test/stub-logger.js'; const TEMPLATE = html` - Users + Users Users Containers Containers Database Database - Disabled - Disabled - Aria Disabled - Aria Disabled `; @@ -41,6 +38,10 @@ const DISABLED = html` `; describe('', function() { + let element: PfTabs; + let tab: PfTab; + let panel: PfTabPanel; + it('instantiates imperatively', function() { expect(document.createElement('pf-tabs')).to.be.an.instanceof(PfTabs); expect(document.createElement('pf-tab')).to.be.an.instanceof(PfTab); @@ -55,145 +56,184 @@ describe('', function() { .to.be.an.instanceOf(PfTabs); }); - it('should apply correct aria attributes', async function() { - const el = await createFixture(TEMPLATE); - await nextFrame(); - const tabs = el.querySelectorAll('pf-tab'); - const tabPanels = el.querySelectorAll('pf-tab-panels'); - - tabs.forEach(function(tab: Element, index: number) { - const tabId = tab.getAttribute('id'); - const tabControls = tab.getAttribute('aria-controls'); - tabPanels.forEach(function(panel: Element, pindex: number) { - if (index === pindex) { - expect(panel.getAttribute('aria-labelledby')).to.equal(tabId); - expect(panel.id).to.equal(tabControls); - } + describe('default behavior', function() { + let tabs: PfTab[]; + let tab: PfTab; + let panels: PfTabPanel[]; + let panel: PfTabPanel; + + beforeEach(async function() { + element = await createFixture(TEMPLATE); + await allUpdates(element); + tabs = Array.from(element.querySelectorAll('pf-tab')!); + [tab] = tabs; + panels = Array.from(element.querySelectorAll('pf-tab-panel')!); + [panel] = panels; + }); + + it('should apply aria attributes on initialization', function() { + tabs.forEach(function(tab: Element, index: number) { + const tabId = tab.getAttribute('id'); + const tabControls = tab.getAttribute('aria-controls'); + panels.forEach(function(panel: Element, pindex: number) { + if (index === pindex) { + expect(panel.getAttribute('aria-labelledby')).to.equal(tabId); + expect(panel.id).to.equal(tabControls); + } + }); }); }); - }); - it('should activate the first focusable tab when first tab is disabled and no active is given', async function() { - const el = await createFixture(DISABLED); - await nextFrame(); - const secondTab = el.querySelector('pf-tab:nth-of-type(2)'); - expect(secondTab!.hasAttribute('active')).to.equal(true); - }); + it('should activate the first focusable tab', function() { + expect(tab.hasAttribute('active')).to.equal(true); + }); - it('should activate tab when given an active attribute', async function() { - const el = await createFixture(TEMPLATE); - const tab = el.querySelector('pf-tab:nth-of-type(3)'); - tab!.setAttribute('active', ''); - await nextFrame(); - expect(tab!.hasAttribute('active'), 'active attr').to.equal(true); - const panel = (await a11ySnapshot()).children.find(x => x.role === 'tabpanel'); - expect(panel?.name, 'active panel').to.equal('Database'); + it('should activate the first tab panel', function() { + expect(panel.hasAttribute('hidden')).to.equal(false); + }); }); - it('should activate tab when activeIndex property is changed', async function() { - const el = await createFixture(TEMPLATE); - el.activeIndex = 2; - await nextFrame(); - el.activeIndex = 0; - await nextFrame(); - const tab = el.querySelector('pf-tab:first-of-type'); - expect(tab!.hasAttribute('active')).to.equal(true); - const panel = (await a11ySnapshot()).children.find(x => x.role === 'tabpanel'); - expect(panel?.name, 'active panel').to.equal('Users'); - }); + describe('with `vertical` attribute', function() { + beforeEach(async function() { + element = await createFixture(TEMPLATE); + element.setAttribute('vertical', ''); + await allUpdates(element); + }); - it('should change focus when keyboard navigation is used', async function() { - const el = await createFixture(TEMPLATE); - await el.updateComplete; - const firstTab = el.querySelector('pf-tab:first-of-type') as HTMLElement; - const secondTab = el.querySelector('pf-tab:nth-of-type(2)')?.id; - firstTab?.focus(); - await nextFrame(); - const initial = document.activeElement?.id; - await sendKeys({ down: 'ArrowRight' }); - await nextFrame(); - const after = document.activeElement?.id; - expect(initial).to.not.equal(after); - expect(secondTab).to.equal(after); + it('should have vertical styles', function() { + const tabs = element.shadowRoot!.querySelector('[part="tabs"]')!; + const tabsVerticalStyles = getComputedStyle(tabs).flexDirection; + expect(tabsVerticalStyles).to.be.equal('column'); + }); }); - it('should open panel at same index of selected tab', async function() { - const el = await createFixture(TEMPLATE); - await nextFrame(); - el.activeIndex = 1; - await nextFrame(); - const inactivePanel = el.querySelector('pf-tab-panel:first-of-type'); - /* given active-key of 1 on a zero based index, nth-of-type(n) takes a 1 based index = 2. */ - const activePanel = el.querySelector('pf-tab-panel:nth-of-type(2)'); - expect(inactivePanel!.hasAttribute('hidden')).to.equal(true); - expect(activePanel!.hasAttribute('hidden')).to.equal(false); + describe('when a tab is set `active`', function() { + let previousTab: PfTab; + let previousPanel: PfTabPanel; + + beforeEach(async function() { + element = await createFixture(TEMPLATE); + tab = element.querySelector('pf-tab:nth-of-type(2)')!; + tab.active = true; + await allUpdates(element); + panel = element.querySelector('pf-tab-panel:nth-of-type(2)')!; + previousTab = element.querySelector('pf-tab:first-of-type')!; + previousPanel = element.querySelector('pf-tab-panel:first-of-type')!; + }); + + it('should activate the tab', function() { + expect(tab.hasAttribute('active')).to.be.true; + }); + + it('should activate its panel', function() { + expect(panel.hasAttribute('hidden')).to.be.false; + }); + + it('should deactivate previously active tab', function() { + expect(previousTab.hasAttribute('active')).to.be.false; + }); + + it('should hide previously active panel', function() { + expect(previousPanel.hasAttribute('hidden')).to.be.true; + }); }); - describe('vertical tabs', function() { - it('should have vertical styles', async function() { - const el = await createFixture(TEMPLATE); - el.setAttribute('vertical', ''); - await nextFrame(); - const tabs = el.shadowRoot!.querySelector('[part="tabs"]')!; - const tabsVerticalStyles = getComputedStyle(tabs).flexDirection; - expect(tabsVerticalStyles).to.be.equal('column'); + describe('when `activeIndex` is set', function() { + let snapshot: A11yTreeSnapshot; + let index: number; + before(async function() { + element = await createFixture(TEMPLATE); + index = 2; + element.activeIndex = index; + await allUpdates(element); + tab = element.querySelector('pf-tab:nth-of-type(3)')!; + snapshot = await a11ySnapshot(); + }); + + it('should activate the tab', function() { + expect(tab.hasAttribute('active')).to.be.true; + }); + + it('should activate its panel', function() { + expect(panel.hasAttribute('hidden')).to.be.false; + expect(snapshot.children.find(x => x.role === 'tabpanel')?.name).to.equal('Database'); }); }); - describe('disabled tabs', function() { + describe('when a tab is `disabled`', function() { + let snapshot: A11yTreeSnapshot; + beforeEach(async function() { - await setViewport({ width: 320, height: 640 }); + element = await createFixture(TEMPLATE); + tab = element.querySelector('pf-tab')!; + tab.disabled = true; + await allUpdates(element); + snapshot = await a11ySnapshot(); }); - it('should aria-disable the tab if disabled', async function() { - const el = await createFixture(TEMPLATE); - const disabledTab = el.querySelector('pf-tab:nth-of-type(2)')! as BaseTab; - disabledTab.disabled = true; - await nextFrame(); - const tab = (await a11ySnapshot()).children.find(x => x.role === 'tab' && x.name === 'Containers'); - expect(tab?.disabled).to.be.true; + it('should disable the button', function() { + const [disabledTab] = snapshot.children; + const tabButton = disabledTab.children.find(x => x.role === 'button')!; + expect(tabButton.disabled).to.equal(true); }); - it('should have disabled css styles if disabled', async function() { - const el = await createFixture(TEMPLATE); - const disabledTab = el.querySelector('pf-tab:first-of-type')!; - disabledTab.setAttribute('disabled', 'disabled'); - await nextFrame(); - const button = disabledTab!.shadowRoot!.firstElementChild!; - const disabledStyles = getComputedStyle(button).backgroundColor; - expect(disabledStyles).to.equal('rgb(245, 245, 245)'); + describe('when clicked', function() { + beforeEach(async function() { + tab.click(); + await nextFrame(); + }); + + it('should not activate', function() { + expect(tab.hasAttribute('active')).to.be.false; + }); }); - it('should have disabled css styles if aria-disabled attribute is true', async function() { - const el = await createFixture(TEMPLATE); - const disabledTab = el.querySelector('pf-tab:first-of-type')!; - disabledTab.setAttribute('aria-disabled', 'true'); - await nextFrame(); - const button = disabledTab!.shadowRoot!.firstElementChild!; - const disabledStyles = getComputedStyle(button).backgroundColor; - expect(disabledStyles).to.equal('rgb(245, 245, 245)'); + describe('when activeIndex is set to disabled tab', function() { + beforeEach(async function() { + element.activeIndex = 0; + await allUpdates(element); + }); + + it('should not activate', function() { + expect(tab.hasAttribute('active')).to.be.false; + }); }); }); - describe('on small screen', function() { + describe('when no active tab is given and the first tab is disabled', function() { + let secondTab: PfTab; beforeEach(async function() { - await setViewport({ width: 320, height: 640 }); + element = await createFixture(DISABLED); + await allUpdates(element); + secondTab = element.querySelector('pf-tab:nth-of-type(2)')!; + }); + + it('should activate the next focusable tab', function() { + expect(secondTab.hasAttribute('active')).to.equal(true); }); + }); - it('should overflow if too wide', async function() { - const el = await createFixture(TEMPLATE); - const tabs = el.shadowRoot!.querySelector('[part="tabs"]')!; - const tabsOverflow = getComputedStyle(tabs).overflowX === 'auto'; - expect(tabsOverflow).to.equal(true); + describe('when viewed in a small viewport', function() { + beforeEach(async function() { + await setViewport({ width: 240, height: 640 }); + element = await createFixture(TEMPLATE); + await allUpdates(element); }); - it('should have visible scroll buttons if overflowed', async function() { - const el = await createFixture(TEMPLATE); - // Property 'scrollTimeoutDelay' is protected and only accessible within class 'PfeTabs' and its subclasses. - // using 150 as a static representation. - await aTimeout(150); - const previousTab = el.shadowRoot!.querySelector('#previousTab')!; - const nextTab = el.shadowRoot!.querySelector('#nextTab')!; + it('should have visible scroll buttons if overflowed', function() { + /** + * Note: overflow buttons are not included in the accessibility tree otherwise we'd test + * for buttons there. tabindex="-1" is used on the buttons to prevent focus and was a + * decision made to keep logical keyboard navigation order flow between tabs and panels + * as the next overflow button exists in the DOM between the tabs container and the open panel + * and would disrupt the expected flow. For keyboard users they are able to scroll using the + * left and right arrows keys and do not need direct access to the overflow buttons but still + * exist as visual cues for which direction is overflowed + **/ + const previousTab = element.shadowRoot!.querySelector('#previousTab')!; + const nextTab = element.shadowRoot!.querySelector('#nextTab')!; + expect(previousTab).to.not.be.null; + expect(nextTab).to.not.be.null; const prevDisplayStyle = getComputedStyle(previousTab).display; const nextDisplayStyle = getComputedStyle(nextTab).display; expect(prevDisplayStyle ).to.not.equal('none'); @@ -201,41 +241,91 @@ describe('', function() { }); }); - describe('manual activation', function() { + describe(`when navigated by keyboard`, function() { + let nextTab: PfTab; + let afterKeyPress: Element | null; + + describe('when pressing the right arrow key from first tab', function() { + beforeEach(async function() { + element = await createFixture(TEMPLATE); + await allUpdates(element); + tab = element.querySelector('pf-tab:first-of-type')!; + tab.focus(); + await allUpdates(element); + await sendKeys({ down: 'ArrowRight' }); + await allUpdates(element); + afterKeyPress = document.activeElement; + nextTab = element.querySelector('pf-tab:nth-of-type(2)')!; + }); + + it('should activate the next tab', function() { + expect(nextTab.hasAttribute('active')).to.be.true; + expect(afterKeyPress).to.equal(nextTab); + }); + }); + + describe('when pressing the left arrow key from first tab', function() { + beforeEach(async function() { + element = await createFixture(TEMPLATE); + await allUpdates(element); + tab = element.querySelector('pf-tab:first-of-type')!; + tab.focus(); + await allUpdates(element); + await sendKeys({ down: 'ArrowLeft' }); + await allUpdates(element); + afterKeyPress = document.activeElement; + nextTab = element.querySelector('pf-tab:last-of-type')!; + }); + + it('should activate the last tab', function() { + expect(nextTab.hasAttribute('active')).to.be.true; + expect(afterKeyPress).to.equal(nextTab); + }); + }); + + describe('when pressing the right arrow key from last tab', function() { + beforeEach(async function() { + element = await createFixture(TEMPLATE); + await allUpdates(element); + tab = element.querySelector('pf-tab:last-of-type')!; + tab.focus(); + await allUpdates(element); + await sendKeys({ down: 'ArrowRight' }); + await allUpdates(element); + afterKeyPress = document.activeElement; + nextTab = element.querySelector('pf-tab:first-of-type')!; + }); + + it('should activate the last tab', function() { + expect(nextTab.hasAttribute('active')).to.be.true; + expect(afterKeyPress).to.equal(nextTab); + }); + }); + }); + + describe('when `manual` attribute is set', function() { let element: PfTabs; let firstTab: PfTab; let secondTab: PfTab; let initialFocus: Element | null; let afterFocus: Element | null; + beforeEach(async function() { - element = await createFixture(html` - - Users - Users - Containers - Containers - Database - Database - Disabled - Disabled - Aria Disabled - Aria Disabled - - `); + element = await createFixture(TEMPLATE); + element.manual = true; + await allUpdates(element); [firstTab, secondTab] = element.querySelectorAll('pf-tab'); - await element.updateComplete; - await nextFrame(); initialFocus = document.activeElement; }); - describe('pressing right arrow', function() { + describe('pressing right arrow key', function() { beforeEach(async function() { - firstTab?.focus(); + firstTab?.focus(); // ensure focus on first tab await sendKeys({ down: 'ArrowRight' }); - await element.updateComplete; - await nextFrame(); + await allUpdates(element); afterFocus = document.activeElement; }); + it('should not activate second tab', function() { expect(firstTab.active).to.be.true; expect(secondTab.active).to.be.false; @@ -243,13 +333,15 @@ describe('', function() { .and.to.not.equal(afterFocus); expect(initialFocus).to.not.equal(secondTab); }); - describe('then pressing enter', function() { + + describe('pressing enter key', function() { beforeEach(async function() { await sendKeys({ down: 'Enter' }); - await nextFrame(); + await allUpdates(element); afterFocus = document.activeElement; }); - it('should activate second tab', async function() { + + it('should activate second tab', function() { expect(firstTab.active).to.be.false; expect(secondTab.active).to.be.true; expect(afterFocus).to.equal(secondTab);