diff --git a/README.md b/README.md index 8fa787a380..8bea50dfd4 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ NOTE: To update the component status: | Accordion | | [Issue](https://github.com/ni/nimble/issues/533) | :o: | :o: | :o: | | Anchor | [XD](https://xd.adobe.com/view/33ffad4a-eb2c-4241-b8c5-ebfff1faf6f6-66ac/screen/bfadf499-caf5-4ca0-9814-e777fbae0d46/) | [Issue](https://github.com/ni/nimble/issues/324) | [:white_check_mark: - SB](https://ni.github.io/nimble/storybook/?path=/docs/anchor--text-anchor) | :white_check_mark: | :white_check_mark: | | Anchor Button | | [Issue](https://github.com/ni/nimble/issues/324) | [:white_check_mark: - SB](https://ni.github.io/nimble/storybook/?path=/docs/anchor-button--outline-anchor-button) | :white_check_mark: | :white_check_mark: | +| Anchor Tabs | [XD](https://xd.adobe.com/view/33ffad4a-eb2c-4241-b8c5-ebfff1faf6f6-66ac/screen/b2aa2c0c-03b7-4571-8e0d-de88baf0814b) | [Issue](https://github.com/ni/nimble/issues/479) | [:white_check_mark: - SB](https://nimble.ni.dev/storybook/?path=/docs/anchor-tabs--tabs) | :o: | :o: | | Banners | [XD](https://xd.adobe.com/view/33ffad4a-eb2c-4241-b8c5-ebfff1faf6f6-66ac/screen/29c405f7-08ea-48b6-973f-546970b9dbab) | [Issue](https://github.com/ni/nimble/issues/305) | :o: | :o: | :o: | | Breadcrumb | [XD](https://xd.adobe.com/view/33ffad4a-eb2c-4241-b8c5-ebfff1faf6f6-66ac/screen/7b53bb3e-439b-4f13-9d5f-55adc7da8a2e) | | [:white_check_mark: - SB](https://ni.github.io/nimble/storybook/?path=/docs/breadcrumb--standard-breadcrumb) | :white_check_mark: | :white_check_mark: | | Card | | [Issue](https://github.com/ni/nimble/issues/296) | :o: | :o: | :o: | diff --git a/change/@ni-nimble-components-60beeec6-9cec-44db-a7d5-8588b0862986.json b/change/@ni-nimble-components-60beeec6-9cec-44db-a7d5-8588b0862986.json new file mode 100644 index 0000000000..acec490ac3 --- /dev/null +++ b/change/@ni-nimble-components-60beeec6-9cec-44db-a7d5-8588b0862986.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Anchor tabs component", + "packageName": "@ni/nimble-components", + "email": "7282195+m-akinc@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/nimble-components/src/all-components.ts b/packages/nimble-components/src/all-components.ts index b598a56a41..a683e84b5b 100644 --- a/packages/nimble-components/src/all-components.ts +++ b/packages/nimble-components/src/all-components.ts @@ -6,6 +6,8 @@ import './anchor'; import './anchor-button'; +import './anchor-tab'; +import './anchor-tabs'; import './anchored-region'; import './breadcrumb'; import './breadcrumb-item'; diff --git a/packages/nimble-components/src/anchor-tab/index.ts b/packages/nimble-components/src/anchor-tab/index.ts new file mode 100644 index 0000000000..3f1b133230 --- /dev/null +++ b/packages/nimble-components/src/anchor-tab/index.ts @@ -0,0 +1,39 @@ +import { attr } from '@microsoft/fast-element'; +import { + DesignSystem, + FoundationElementDefinition, + StartEndOptions +} from '@microsoft/fast-foundation'; +import { AnchorBase } from '../anchor-base'; +import { styles } from './styles'; +import { template } from './template'; + +declare global { + interface HTMLElementTagNameMap { + 'nimble-anchor-tab': AnchorTab; + } +} + +export type TabOptions = FoundationElementDefinition & StartEndOptions; + +/** + * A nimble-styled link tab + */ +export class AnchorTab extends AnchorBase { + /** + * When true, the control will be immutable by user interaction. See {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/disabled | disabled HTML attribute} for more information. + * @public + * @remarks + * HTML Attribute: disabled + */ + @attr({ mode: 'boolean' }) + public disabled = false; +} + +const nimbleAnchorTab = AnchorTab.compose({ + baseName: 'anchor-tab', + template, + styles +}); + +DesignSystem.getOrCreate().withPrefix('nimble').register(nimbleAnchorTab()); diff --git a/packages/nimble-components/src/anchor-tab/styles.ts b/packages/nimble-components/src/anchor-tab/styles.ts new file mode 100644 index 0000000000..28d562a0ad --- /dev/null +++ b/packages/nimble-components/src/anchor-tab/styles.ts @@ -0,0 +1,13 @@ +import { css } from '@microsoft/fast-element'; +import { styles as tabStyles } from '../patterns/tab/styles'; + +export const styles = css` + ${tabStyles} + + a { + text-decoration: none; + color: inherit; + cursor: inherit; + outline: none; + } +`; diff --git a/packages/nimble-components/src/anchor-tab/template.ts b/packages/nimble-components/src/anchor-tab/template.ts new file mode 100644 index 0000000000..01f0f4f3af --- /dev/null +++ b/packages/nimble-components/src/anchor-tab/template.ts @@ -0,0 +1,24 @@ +import { html, ViewTemplate } from '@microsoft/fast-element'; +import type { FoundationElementTemplate } from '@microsoft/fast-foundation'; +import type { AnchorTab, TabOptions } from '.'; + +export const template: FoundationElementTemplate< +ViewTemplate, +TabOptions +> = () => html` + +`; diff --git a/packages/nimble-components/src/anchor-tab/tests/anchor-tab.spec.ts b/packages/nimble-components/src/anchor-tab/tests/anchor-tab.spec.ts new file mode 100644 index 0000000000..ee0bb1a615 --- /dev/null +++ b/packages/nimble-components/src/anchor-tab/tests/anchor-tab.spec.ts @@ -0,0 +1,63 @@ +import { DOM, html } from '@microsoft/fast-element'; +import { AnchorTab } from '..'; +import { Fixture, fixture } from '../../utilities/tests/fixture'; +import { getSpecTypeByNamedList } from '../../utilities/tests/parameterized'; + +async function setup(): Promise> { + return fixture(html``); +} + +describe('AnchorTab', () => { + let element: AnchorTab; + let connect: () => Promise; + let disconnect: () => Promise; + + beforeEach(async () => { + ({ element, connect, disconnect } = await setup()); + }); + + afterEach(async () => { + await disconnect(); + }); + + it('can construct an element instance', () => { + expect(document.createElement('nimble-anchor-tab')).toBeInstanceOf( + AnchorTab + ); + }); + + const attributeNames: { name: string }[] = [ + { name: 'download' }, + { name: 'href' }, + { name: 'hreflang' }, + { name: 'ping' }, + { name: 'referrerpolicy' }, + { name: 'rel' }, + { name: 'target' }, + { name: 'type' } + ]; + describe('should reflect value to the internal anchor element', () => { + const focused: string[] = []; + const disabled: string[] = []; + for (const attribute of attributeNames) { + const specType = getSpecTypeByNamedList( + attribute, + focused, + disabled + ); + // eslint-disable-next-line @typescript-eslint/no-loop-func + specType(`for attribute ${attribute.name}`, async () => { + await connect(); + + element.setAttribute(attribute.name, 'foo'); + await DOM.nextUpdate(); + + expect( + element + .shadowRoot!.querySelector('a')! + .getAttribute(attribute.name) + ).toBe('foo'); + }); + } + }); +}); diff --git a/packages/nimble-components/src/anchor-tabs/index.ts b/packages/nimble-components/src/anchor-tabs/index.ts new file mode 100644 index 0000000000..4deb80a5f5 --- /dev/null +++ b/packages/nimble-components/src/anchor-tabs/index.ts @@ -0,0 +1,285 @@ +// Based on: +// https://github.com/microsoft/fast/blob/6bce27d0b2d654650b8751bf055f5e3b5a4f9250/packages/web-components/fast-foundation/src/tabs/tabs.ts +// The code is heavily modified such that a diff against the original is useless. +// Primary differences are: +// - Reimplemented active tab indicator and removed attribute to disable it +// - Separated tab focus and tab selection, i.e. can focus a different tab than the selected one +// - Removed everything related to tab panels +// - Removed support for vertical tab orientation +// - Removed change event +// - Conforms to our linter rules +import { attr, observable } from '@microsoft/fast-element'; +import { + keyArrowLeft, + keyArrowRight, + keyEnd, + keyEnter, + keyHome, + keySpace, + uniqueId +} from '@microsoft/fast-web-utilities'; +import { + DesignSystem, + StartEnd, + applyMixins, + StartEndOptions, + FoundationElementDefinition, + FoundationElement +} from '@microsoft/fast-foundation'; +import { styles } from './styles'; +import { template } from './template'; +import type { AnchorTab } from '../anchor-tab'; + +declare global { + interface HTMLElementTagNameMap { + 'nimble-anchor-tabs': AnchorTabs; + } +} + +export type TabsOptions = FoundationElementDefinition & StartEndOptions; + +/** + * A nimble-styled set of anchor tabs + */ +export class AnchorTabs extends FoundationElement { + /** + * The id of the active tab + * + * @public + * @remarks + * HTML Attribute: activeid + */ + @attr + public activeid?: string; + + /** + * @internal + */ + @observable + public tabs!: HTMLElement[]; + + /** + * A reference to the active tab + * @public + */ + public activetab?: HTMLElement; + + /** + * A reference to the tablist div + * @internal + */ + public tablist!: HTMLElement; + + private tabIds: string[] = []; + + /** + * @internal + */ + public activeidChanged(_oldValue: string, _newValue: string): void { + if (this.$fastController.isConnected) { + this.setTabs(); + } + } + + /** + * @internal + */ + public tabsChanged(): void { + if (this.$fastController.isConnected) { + this.tabIds = this.getTabIds(); + this.setTabs(); + } + } + + /** + * @internal + */ + public override connectedCallback(): void { + super.connectedCallback(); + + this.tabIds = this.getTabIds(); + } + + private readonly isDisabledElement = (el: Element): el is HTMLElement => { + return el.getAttribute('aria-disabled') === 'true'; + }; + + private readonly isFocusableElement = (el: Element): el is HTMLElement => { + return !this.isDisabledElement(el); + }; + + private readonly setTabs = (): void => { + const gridHorizontalProperty = 'gridColumn'; + const gridVerticalProperty = 'gridRow'; + + this.activetab = undefined; + let firstFocusableTab: HTMLElement | undefined; + this.tabs.forEach((tab: HTMLElement, index: number) => { + const tabId: string = this.tabIds[index]!; + const isActiveTab = this.activeid === tabId; + if (!firstFocusableTab && this.isFocusableElement(tab)) { + firstFocusableTab = tab; + } + const isTabStop = this.activeid === tabId && this.isFocusableElement(tab); + tab.setAttribute('id', tabId); + tab.setAttribute('aria-selected', isActiveTab ? 'true' : 'false'); + tab.removeEventListener('click', this.handleTabClick); + tab.addEventListener('click', this.handleTabClick); + tab.removeEventListener('keydown', this.handleTabKeyDown); + tab.addEventListener('keydown', this.handleTabKeyDown); + tab.setAttribute('tabindex', isTabStop ? '0' : '-1'); + if (isActiveTab) { + this.activetab = tab; + } + + tab.style[gridVerticalProperty] = ''; + tab.style[gridHorizontalProperty] = `${index + 1}`; + }); + + if ( + firstFocusableTab + && (!this.activetab || !this.isFocusableElement(this.activetab)) + ) { + firstFocusableTab.setAttribute('tabindex', '0'); + } + }; + + private getTabIds(): string[] { + return this.tabs.map((tab: HTMLElement) => { + return tab.getAttribute('id') ?? `tab-${uniqueId()}`; + }); + } + + private readonly handleTabClick = (event: MouseEvent): void => { + const selectedTab = event.currentTarget as HTMLElement; + if ( + selectedTab.nodeType === 1 + && this.isFocusableElement(selectedTab) + ) { + this.tabs.forEach((tab: HTMLElement) => { + tab.setAttribute('tabindex', tab === selectedTab ? '0' : '-1'); + }); + } + }; + + private readonly handleTabKeyDown = (event: KeyboardEvent): void => { + let anchor; + switch (event.key) { + case keyArrowLeft: + event.preventDefault(); + this.adjustBackward(); + break; + case keyArrowRight: + event.preventDefault(); + this.adjustForward(); + break; + case keyHome: + event.preventDefault(); + this.focusFirstOrLast(false); + break; + case keyEnd: + event.preventDefault(); + this.focusFirstOrLast(true); + break; + case keySpace: + case keyEnter: + event.preventDefault(); + this.getTabAnchor(event.target as AnchorTab).click(); + break; + case 'ContextMenu': + event.preventDefault(); + anchor = this.getTabAnchor(event.target as AnchorTab); + anchor.focus(); + anchor.dispatchEvent( + new KeyboardEvent('keydown', { + key: event.key, + bubbles: false + }) + ); + break; + default: + // do nothing + } + }; + + private focusFirstOrLast(focusLast: boolean): void { + const focusableTabs = this.tabs.filter(t => !this.isDisabledElement(t)); + const focusableIndex = focusLast ? focusableTabs.length - 1 : 0; + const index = this.tabs.indexOf(focusableTabs[focusableIndex]!); + if (index > -1) { + this.focusTabByIndex(this.tabs, index); + } + } + + private readonly adjustForward = (): void => { + const group: HTMLElement[] = this.tabs; + let index = 0; + const focusedTab = group.find(x => x === document.activeElement); + + index = focusedTab ? group.indexOf(focusedTab) + 1 : 1; + if (index === group.length) { + index = 0; + } + + while (index < group.length && group.length > 1) { + if (this.isFocusableElement(group[index]!)) { + this.focusTabByIndex(group, index); + break; + } else if (focusedTab && index === group.indexOf(focusedTab)) { + break; + } else if (index + 1 >= group.length) { + index = 0; + } else { + index += 1; + } + } + }; + + private readonly adjustBackward = (): void => { + const group: HTMLElement[] = this.tabs; + let index = 0; + const focusedTab = group.find(x => x === document.activeElement); + + index = focusedTab ? group.indexOf(focusedTab) - 1 : 0; + index = index < 0 ? group.length - 1 : index; + + while (index >= 0 && group.length > 1) { + if (this.isFocusableElement(group[index]!)) { + this.focusTabByIndex(group, index); + break; + } else if (index - 1 < 0) { + index = group.length - 1; + } else { + index -= 1; + } + } + }; + + private readonly focusTabByIndex = ( + group: HTMLElement[], + index: number + ): void => { + const focusedTab: HTMLElement = group[index]!; + focusedTab.focus(); + + this.tabs.forEach((tab: HTMLElement) => { + tab.setAttribute('tabindex', tab === focusedTab ? '0' : '-1'); + }); + }; + + private getTabAnchor(tab: AnchorTab): HTMLAnchorElement { + return tab.shadowRoot!.querySelector('a')!; + } +} +applyMixins(AnchorTabs, StartEnd); + +const nimbleAnchorTabs = AnchorTabs.compose({ + baseName: 'anchor-tabs', + template, + styles, + shadowOptions: { + delegatesFocus: false + } +}); + +DesignSystem.getOrCreate().withPrefix('nimble').register(nimbleAnchorTabs()); diff --git a/packages/nimble-components/src/anchor-tabs/styles.ts b/packages/nimble-components/src/anchor-tabs/styles.ts new file mode 100644 index 0000000000..9d326423a8 --- /dev/null +++ b/packages/nimble-components/src/anchor-tabs/styles.ts @@ -0,0 +1,20 @@ +import { css } from '@microsoft/fast-element'; +import { display } from '@microsoft/fast-foundation'; + +export const styles = css` + ${display('grid')} + + :host { + box-sizing: border-box; + grid-template-columns: auto auto 1fr; + grid-template-rows: auto 1fr; + } + + .tablist { + display: grid; + grid-template-rows: auto auto; + grid-template-columns: auto; + width: max-content; + align-self: end; + } +`; diff --git a/packages/nimble-components/src/anchor-tabs/template.ts b/packages/nimble-components/src/anchor-tabs/template.ts new file mode 100644 index 0000000000..6edd59c2ac --- /dev/null +++ b/packages/nimble-components/src/anchor-tabs/template.ts @@ -0,0 +1,18 @@ +import { html, ref, slotted, ViewTemplate } from '@microsoft/fast-element'; +import { + endSlotTemplate, + FoundationElementTemplate, + startSlotTemplate +} from '@microsoft/fast-foundation'; +import type { AnchorTabs, TabsOptions } from '.'; + +export const template: FoundationElementTemplate< +ViewTemplate, +TabsOptions +> = (context, definition) => html` + ${startSlotTemplate(context, definition)} +
+ +
+ ${endSlotTemplate(context, definition)} +`; diff --git a/packages/nimble-components/src/anchor-tabs/tests/anchor-tabs-matrix.stories.ts b/packages/nimble-components/src/anchor-tabs/tests/anchor-tabs-matrix.stories.ts new file mode 100644 index 0000000000..07829967a1 --- /dev/null +++ b/packages/nimble-components/src/anchor-tabs/tests/anchor-tabs-matrix.stories.ts @@ -0,0 +1,75 @@ +import type { Meta, Story } from '@storybook/html'; +import { withXD } from 'storybook-addon-xd-designs'; +import { html, ViewTemplate, when } from '@microsoft/fast-element'; +import { + createMatrixThemeStory, + createStory +} from '../../utilities/tests/storybook'; +import { + createMatrix, + sharedMatrixParameters +} from '../../utilities/tests/matrix'; +import { DisabledState, disabledStates } from '../../utilities/tests/states'; +import { hiddenWrapper } from '../../utilities/tests/hidden'; +import '../../all-components'; +import { textCustomizationWrapper } from '../../utilities/tests/text-customization'; + +const metadata: Meta = { + title: 'Tests/Anchor Tabs', + decorators: [withXD], + parameters: { + ...sharedMatrixParameters(), + design: { + artboardUrl: + 'https://xd.adobe.com/view/33ffad4a-eb2c-4241-b8c5-ebfff1faf6f6-66ac/screen/b2aa2c0c-03b7-4571-8e0d-de88baf0814b/specs' + } + } +}; + +export default metadata; + +const tabsToolbarState = [false, true] as const; +type TabsToolbarState = typeof tabsToolbarState[number]; + +// prettier-ignore +const component = ( + toolbar: TabsToolbarState, + [disabledName, disabled]: DisabledState +): ViewTemplate => html` + + ${when(() => toolbar, html` + + Toolbar Button + + `)} + Tab One + + Tab Two ${() => disabledName} + + + +`; + +export const anchorTabsThemeMatrix: Story = createMatrixThemeStory( + createMatrix(component, [tabsToolbarState, disabledStates]) +); + +export const hiddenTabs: Story = createStory( + hiddenWrapper( + html`` + ) +); + +export const textCustomized: Story = createMatrixThemeStory( + textCustomizationWrapper( + html` + + Inner text + Tabs toolbar + Tab + + ` + ) +); diff --git a/packages/nimble-components/src/anchor-tabs/tests/anchor-tabs.spec.ts b/packages/nimble-components/src/anchor-tabs/tests/anchor-tabs.spec.ts new file mode 100644 index 0000000000..9f6e238ee4 --- /dev/null +++ b/packages/nimble-components/src/anchor-tabs/tests/anchor-tabs.spec.ts @@ -0,0 +1,302 @@ +import { DOM, html } from '@microsoft/fast-element'; +import { + keyArrowLeft, + keyArrowRight, + keyEnd, + keyEnter, + keyHome, + keySpace, + keyTab +} from '@microsoft/fast-web-utilities'; +import { AnchorTabs } from '..'; +import '../../anchor-tab'; +import type { AnchorTab } from '../../anchor-tab'; +import { fixture, Fixture } from '../../utilities/tests/fixture'; +import { getSpecTypeByNamedList } from '../../utilities/tests/parameterized'; + +async function setup(): Promise> { + return fixture( + html` + + + + ` + ); +} + +describe('AnchorTabs', () => { + let element: AnchorTabs; + let connect: () => Promise; + let disconnect: () => Promise; + + function tab(index: number): AnchorTab { + return element.children[index] as AnchorTab; + } + + function anchor(index: number): HTMLAnchorElement { + return tab(index).shadowRoot!.querySelector('a')!; + } + + beforeEach(async () => { + ({ element, connect, disconnect } = await setup()); + }); + + afterEach(async () => { + await disconnect(); + }); + + it('can construct an element instance', () => { + expect(document.createElement('nimble-anchor-tabs')).toBeInstanceOf( + AnchorTabs + ); + }); + + it('should set the "tablist" class on the internal div', async () => { + await connect(); + expect(element.tablist.classList.contains('tablist')).toBeTrue(); + }); + + it('should set the `part` attribute to "tablist" on the internal div', async () => { + await connect(); + expect(element.tablist.part.contains('tablist')).toBeTrue(); + }); + + it('should set the `role` attribute to "tablist" on the internal div', async () => { + await connect(); + expect(element.tablist.getAttribute('role')).toBe('tablist'); + }); + + it('should have a slots named "start", "anchortab", and "end", in that order', async () => { + await connect(); + const slots = element.shadowRoot?.querySelectorAll('slot'); + expect(slots![0]?.getAttribute('name')).toBe('start'); + expect(slots![1]?.getAttribute('name')).toBe('anchortab'); + expect(slots![2]?.getAttribute('name')).toBe('end'); + }); + + it('should assign tab id when unspecified', async () => { + await connect(); + expect(tab(0).id).toBeDefined(); + }); + + it('should set activeid property from attribute value', async () => { + await connect(); + expect(element.activeid).toBe('tab-2'); + }); + + it('should populate tabs array with anchor tabs', async () => { + await connect(); + expect(element.tabs.length).toBe(3); + expect(element.tabs[0]?.nodeName.toLowerCase()).toBe( + 'nimble-anchor-tab' + ); + expect(element.tabs[1]?.nodeName.toLowerCase()).toBe( + 'nimble-anchor-tab' + ); + expect(element.tabs[2]?.nodeName.toLowerCase()).toBe( + 'nimble-anchor-tab' + ); + }); + + it('should set activetab property based on activeid', async () => { + await connect(); + expect(element.activetab).toBeDefined(); + expect(element.activetab).toBe(tab(1)); + }); + + it('should set aria-selected on active tab', async () => { + await connect(); + expect(element.activetab?.ariaSelected).toBe('true'); + }); + + it('should update activetab when activeid is changed', async () => { + await connect(); + element.activeid = 'tab-3'; + expect(element.activetab).toBe(tab(2)); + }); + + it('should clear activetab when active tab is removed', async () => { + await connect(); + tab(1).remove(); + await DOM.nextUpdate(); + expect(element.activetab).toBeUndefined(); + }); + + it('should keep activetab when active tab is disabled', async () => { + await connect(); + tab(1).disabled = true; + await DOM.nextUpdate(); + expect(element.activetab).toBe(tab(1)); + }); + + const navigationTests: { + name: string, + disabledIndex?: number, + initialFocusIndex: number, + keyName: string, + expectedFinalFocusIndex: number + }[] = [ + { + name: 'should focus next tab when right arrow key pressed', + initialFocusIndex: 0, + keyName: keyArrowRight, + expectedFinalFocusIndex: 1 + }, + { + name: 'should focus previous tab when left arrow key pressed', + initialFocusIndex: 1, + keyName: keyArrowLeft, + expectedFinalFocusIndex: 0 + }, + { + name: 'should wrap to first tab when arrowing right on last tab', + initialFocusIndex: 2, + keyName: keyArrowRight, + expectedFinalFocusIndex: 0 + }, + { + name: 'should wrap to last tab when arrowing left on first tab', + initialFocusIndex: 0, + keyName: keyArrowLeft, + expectedFinalFocusIndex: 2 + }, + { + name: 'should focus first tab when Home key pressed', + initialFocusIndex: 1, + keyName: keyHome, + expectedFinalFocusIndex: 0 + }, + { + name: 'should focus last tab when End key pressed', + initialFocusIndex: 1, + keyName: keyEnd, + expectedFinalFocusIndex: 2 + }, + { + name: 'should skip disabled tab when arrowing right', + disabledIndex: 1, + initialFocusIndex: 0, + keyName: keyArrowRight, + expectedFinalFocusIndex: 2 + }, + { + name: 'should skip disabled tab when arrowing left', + disabledIndex: 1, + initialFocusIndex: 2, + keyName: keyArrowLeft, + expectedFinalFocusIndex: 0 + }, + { + name: 'should skip disabled when arrowing right on last tab', + disabledIndex: 0, + initialFocusIndex: 2, + keyName: keyArrowRight, + expectedFinalFocusIndex: 1 + }, + { + name: 'should skip disabled when arrowing left on first tab', + disabledIndex: 2, + initialFocusIndex: 0, + keyName: keyArrowLeft, + expectedFinalFocusIndex: 1 + }, + { + name: 'should focus first enabled tab when Home key pressed', + disabledIndex: 0, + initialFocusIndex: 2, + keyName: keyHome, + expectedFinalFocusIndex: 1 + }, + { + name: 'should focus last enabled tab when End key pressed', + disabledIndex: 2, + initialFocusIndex: 0, + keyName: keyEnd, + expectedFinalFocusIndex: 1 + } + ]; + describe('navigation', () => { + const focused: string[] = []; + const disabled: string[] = []; + for (const test of navigationTests) { + const specType = getSpecTypeByNamedList(test, focused, disabled); + // eslint-disable-next-line @typescript-eslint/no-loop-func + specType(test.name, async () => { + await connect(); + if (test.disabledIndex !== undefined) { + tab(test.disabledIndex).disabled = true; + await DOM.nextUpdate(); + } + tab(test.initialFocusIndex).focus(); + tab(test.initialFocusIndex).dispatchEvent( + new KeyboardEvent('keydown', { key: test.keyName }) + ); + await DOM.nextUpdate(); + expect(document.activeElement).toBe( + tab(test.expectedFinalFocusIndex) + ); + }); + } + }); + + it('should skip past other tabs when pressing tab key after click', async () => { + await connect(); + tab(1).focus(); + tab(1).dispatchEvent(new Event('click')); + await DOM.nextUpdate(); + document.activeElement!.dispatchEvent( + new KeyboardEvent('keydown', { key: keyTab }) + ); + await DOM.nextUpdate(); + expect(document.activeElement).toBe(tab(1)); + }); + + it('should skip past other tabs when pressing tab key after arrow key', async () => { + await connect(); + tab(1).focus(); + await DOM.nextUpdate(); + document.activeElement!.dispatchEvent( + new KeyboardEvent('keydown', { key: keyArrowLeft }) + ); + await DOM.nextUpdate(); + document.activeElement!.dispatchEvent( + new KeyboardEvent('keydown', { key: keyTab }) + ); + await DOM.nextUpdate(); + expect(document.activeElement).toBe(tab(0)); + }); + + it('should update tabindex values on tab click', async () => { + await connect(); + expect(tab(0).tabIndex).toBe(-1); + expect(tab(1).tabIndex).toBe(0); + expect(tab(2).tabIndex).toBe(-1); + tab(0).dispatchEvent(new Event('click')); + await DOM.nextUpdate(); + expect(tab(0).tabIndex).toBe(0); + expect(tab(1).tabIndex).toBe(-1); + expect(tab(2).tabIndex).toBe(-1); + }); + + it('should turn tab Space key press into click on inner anchor element', async () => { + await connect(); + let timesClicked = 0; + anchor(0).addEventListener('click', () => { + timesClicked += 1; + }); + tab(0).dispatchEvent(new KeyboardEvent('keydown', { key: keySpace })); + await DOM.nextUpdate(); + expect(timesClicked).toBe(1); + }); + + it('should turn tab Enter key press into click on inner anchor element', async () => { + await connect(); + let timesClicked = 0; + anchor(0).addEventListener('click', () => { + timesClicked += 1; + }); + tab(0).dispatchEvent(new KeyboardEvent('keydown', { key: keyEnter })); + await DOM.nextUpdate(); + expect(timesClicked).toBe(1); + }); +}); diff --git a/packages/nimble-components/src/anchor-tabs/tests/anchor-tabs.stories.ts b/packages/nimble-components/src/anchor-tabs/tests/anchor-tabs.stories.ts new file mode 100644 index 0000000000..f46e807050 --- /dev/null +++ b/packages/nimble-components/src/anchor-tabs/tests/anchor-tabs.stories.ts @@ -0,0 +1,66 @@ +import { html, when } from '@microsoft/fast-element'; +import type { Meta, StoryObj } from '@storybook/html'; +import { withXD } from 'storybook-addon-xd-designs'; +import { createUserSelectedThemeStory } from '../../utilities/tests/storybook'; +import '../../all-components'; + +interface TabsArgs { + activeId: string; + toolbar: boolean; + tabHref: string; + tabDisabled: boolean; +} + +const overviewText = `Anchor tabs are a sequence of links that are styled to look like tab elements, where one link can +be distinguished as the currently active item. Use this component instead of the standard tabs component when each tab +represents a different URL to navigate to. Use the standard tabs component when the tabs should switch between different +tab panels hosted on the same page.`; + +const metadata: Meta = { + title: 'Anchor Tabs', + decorators: [withXD], + parameters: { + docs: { + description: { + component: overviewText + } + }, + design: { + artboardUrl: + 'https://xd.adobe.com/view/33ffad4a-eb2c-4241-b8c5-ebfff1faf6f6-66ac/screen/b2aa2c0c-03b7-4571-8e0d-de88baf0814b/specs' + } + }, + // prettier-ignore + render: createUserSelectedThemeStory(html` + + ${when(x => x.toolbar, html`Toolbar Button`)} + Google + NI + Nimble + + `), + argTypes: { + activeId: { + options: ['None', '1', '2', '3'], + control: { type: 'radio' }, + description: + "The `id` of the `nimble-anchor-tab` that should be indicated as currently active/selected. It is the application's responsibility to set `activeId` to the tab matching the currently loaded URL." + }, + tabHref: { + name: 'tab 1 href' + }, + tabDisabled: { + name: 'tab 1 disabled' + } + }, + args: { + activeId: 'None', + toolbar: false, + tabHref: 'https://www.google.com', + tabDisabled: false + } +}; + +export default metadata; + +export const anchorTabs: StoryObj = {}; diff --git a/packages/nimble-components/src/anchor/tests/anchor.spec.ts b/packages/nimble-components/src/anchor/tests/anchor.spec.ts index 4c41918b19..e8137f3082 100644 --- a/packages/nimble-components/src/anchor/tests/anchor.spec.ts +++ b/packages/nimble-components/src/anchor/tests/anchor.spec.ts @@ -44,6 +44,7 @@ describe('Anchor', () => { const attributeNames: { name: string }[] = [ { name: 'download' }, + { name: 'href' }, { name: 'hreflang' }, { name: 'ping' }, { name: 'referrerpolicy' }, diff --git a/packages/nimble-components/src/tab/styles.ts b/packages/nimble-components/src/patterns/tab/styles.ts similarity index 83% rename from packages/nimble-components/src/tab/styles.ts rename to packages/nimble-components/src/patterns/tab/styles.ts index 380024fb00..e7f14e30a8 100644 --- a/packages/nimble-components/src/tab/styles.ts +++ b/packages/nimble-components/src/patterns/tab/styles.ts @@ -11,8 +11,8 @@ import { fillHoverSelectedColor, standardPadding, smallDelay -} from '../theme-provider/design-tokens'; -import { focusVisible } from '../utilities/style/focus'; +} from '../../theme-provider/design-tokens'; +import { focusVisible } from '../../utilities/style/focus'; export const styles = css` ${display('inline-flex')} @@ -36,12 +36,12 @@ export const styles = css` background-color: ${fillHoverColor}; } - :host(:focus) { - outline: none; + :host(:hover[aria-selected='true']) { + background-color: ${fillHoverSelectedColor}; } - :host(:focus:hover) { - background-color: ${fillHoverSelectedColor}; + :host(:focus) { + outline: none; } :host(${focusVisible}) { @@ -58,7 +58,7 @@ export const styles = css` background: none; } - slot { + slot:not([name]) { display: block; padding: calc(${standardPadding} / 2) ${standardPadding} calc(${standardPadding} / 2 - ${borderWidth}); @@ -105,7 +105,17 @@ export const styles = css` } } + :host(${focusVisible})::after { + width: 100%; + border-bottom-width: var(--ni-private-focus-indicator-width); + } + :host([aria-selected='true'])::after { width: 100%; + border-bottom-width: var(--ni-private-active-indicator-width); + } + + :host([aria-selected='true'][disabled])::after { + border-bottom-color: rgba(${borderHoverColor}, 0.3); } `; diff --git a/packages/nimble-components/src/tab/index.ts b/packages/nimble-components/src/tab/index.ts index 4967125575..6846110fde 100644 --- a/packages/nimble-components/src/tab/index.ts +++ b/packages/nimble-components/src/tab/index.ts @@ -3,7 +3,7 @@ import { Tab as FoundationTab, tabTemplate as template } from '@microsoft/fast-foundation'; -import { styles } from './styles'; +import { styles } from '../patterns/tab/styles'; declare global { interface HTMLElementTagNameMap { diff --git a/packages/nimble-components/src/tabs/tests/tabs.stories.ts b/packages/nimble-components/src/tabs/tests/tabs.stories.ts index 09a90246dc..6c8edb3a99 100644 --- a/packages/nimble-components/src/tabs/tests/tabs.stories.ts +++ b/packages/nimble-components/src/tabs/tests/tabs.stories.ts @@ -1,24 +1,21 @@ -import { html, repeat, when } from '@microsoft/fast-element'; +import { html, when } from '@microsoft/fast-element'; import type { Meta, StoryObj } from '@storybook/html'; import { withXD } from 'storybook-addon-xd-designs'; import { createUserSelectedThemeStory } from '../../utilities/tests/storybook'; import '../../all-components'; interface TabsArgs { - tabs: TabArgs[]; - toolbar: string; + activeId: string; + toolbar: boolean; + tabDisabled: boolean; } -interface TabArgs { - label: string; - content: string; - disabled: boolean; -} +const overviewText = `Per [W3C](https://w3c.github.io/aria-practices/#tabpanel) - Tabs are a set of layered +sections of content, known as tab panels, that display one panel of content at a time. Each tab panel has an +associated tab element, that when activated, displays the panel. The list of tab elements is arranged along +one edge of the currently displayed panel, most commonly the top edge. -const overviewText = `Per [W3C](https://w3c.github.io/aria-practices/#tabpanel) - Tabs are a set of layered -sections of content, known as tab panels, that display one panel of content at a time. Each tab panel has an -associated tab element, that when activated, displays the panel. The list of tab elements is arranged along -one edge of the currently displayed panel, most commonly the top edge.`; +If you want a sequence of tabs that navigate to different URLs, use the Anchor Tabs component instead.`; const metadata: Meta = { title: 'Tabs', @@ -39,44 +36,32 @@ const metadata: Meta = { }, // prettier-ignore render: createUserSelectedThemeStory(html` - - ${when(x => x.toolbar, html``)} - ${repeat(x => x.tabs, html` - ${x => x.label} - `)} - ${repeat(x => x.tabs, html` - ${x => x.content} - `)} + + ${when(x => x.toolbar, html`Toolbar Button`)} + Tab One + Tab Two + Tab Three + Content of the first tab + Content of the second tab + Content of the third tab `), + argTypes: { + activeId: { + options: ['1', '2', '3'], + control: { type: 'radio' } + }, + tabDisabled: { + name: 'tab 1 disabled' + } + }, args: { - tabs: [ - { - label: 'Tab One', - content: 'Content of the first tab', - disabled: false - }, - { - label: 'Tab Two', - content: 'Content of the second tab', - disabled: false - }, - { - label: 'Tab Three', - content: 'Content of the third tab', - disabled: false - } - ] + activeId: '1', + toolbar: false, + tabDisabled: false } }; export default metadata; export const tabs: StoryObj = {}; - -export const toolbar: StoryObj = { - args: { - toolbar: - 'Toolbar Button' - } -};