From e114e84a9246ac9ce1efd500c29f82947aef4144 Mon Sep 17 00:00:00 2001 From: bearcat-msft <90428651+bearcat-msft@users.noreply.github.com> Date: Wed, 5 Apr 2023 11:30:04 -0700 Subject: [PATCH] Adding web component Tabs, Tab and TabPanel (#27167) * adding tab list spec * adding files for tab list, tab, and tab-panel * adding files for tab list, tab, and tab-panel * adding tab * working tab list web components. no styling. no re-referencing of attributes * adding styling for tabList * commenting out unused styling * adding tab-list styles * basic api working. styling not correct in all cases. * Adding svgs. they are not rendering for some reason * svgs working * updating styling * removing fill on svg * adding logic to track tab selection and css variables. broken * commit before refactor * addint html data attr * updating how tab data is handled * Animations not working * animations not working * broken animation * location of tab is not correct * refactoring dataActiveTabChanged * removing class field for current active tab * adding method to add and remove classes * tab animates to offset location * animating almost correctly * horizontal animations working * adding vertical positioning logic * tabs animations working vertical and horizontal * Adding tab-list options. adding comments * adjusting styling * removing unused vars * removing comments * Adding change file * updating readme. fixing naming conflict in exports of tab-panel index * fixing default value for tablist size * removing check for activeid in setTabData * removing patch for removing activeIndicator * moving pointer events none to disabled fluent tab * removing boolean converters, adding type consts, renaming TabList to Tabs. * documenting difference between tabs and tab list * updating tabs index.js * fixing circular dependency import * removing references to TabList, renaming to Tabs. Removing Tabs orientation type const and importing it from FAST * Refactoring tab into tabs * updating token names object * updating css for tab token names * updating tab token names in tabs.ts * removing unused vars * adding pseudo element to account for spacing difference of bold and unbolded text on active states * adding placeholder element to render active indicator * updating :focus-visible style * removing unused vars * adding tab-content span and 2px margin for tab-content span * updating color foreground for unselected and selected tabs * removing unused var * removing host-context * removing tab content part * putting back tab content part * removing unecessary dom element * removing unecessary z index * adding tab-content class. removing tab-content part * adding display helper function to tab-panel * shorthanding tab padding in tab styles * removing duplicate selector * fixing display helper implementation * removing divs in storybook.ts * removing divs from stories.ts * adding second display helper * adding display helper in tab.styles * removing stray div in stories.ts * adding display helper in tabs.styles * removing active indicator css * removing important * removing unused vars * renaming getSelectedTab to getTab, since tab is passed into method * updating TabData * updating check for previous tab for first render * replacing document add style with fast controller add style * grabbing scale and offset token names from const * updating stories, removing redundant code and renaming tabids * using const objects to provide default values * Removing animation clear method. apparently not needed * updating comments * removing duplicate display * setting display to inline-flex * removing unnecessary disabled font-weight in tab * removing unnecessary outline * replacing margin-right with margin-inline-end * Reducing number of duplicarte border radius Removing horizontal selector in css since it's default Removing the TabTokenNames and hard coding tokens into css Combining selectors in css and refactoring css overall Setting fill for subtle appearance selectors * adding margin-inline-end * Adding max width on tab content * Revert "Adding max width on tab content" This reverts commit b8dffdee3e976527c98ecef84aa2dffe8a12a193. * fixing focus visible border * combining padding * Removing margin auto on right margin --------- Co-authored-by: Chris Holt --- ...-34a9c737-14ba-48cd-aaca-4a706295cefb.json | 7 + packages/web-components/src/index.ts | 3 + .../web-components/src/tab-panel/define.ts | 4 + .../web-components/src/tab-panel/index.ts | 4 + .../src/tab-panel/tab-panel.definition.ts | 10 + .../src/tab-panel/tab-panel.styles.ts | 12 + .../src/tab-panel/tab-panel.template.ts | 3 + .../web-components/src/tab-panel/tab-panel.ts | 3 + packages/web-components/src/tab/define.ts | 4 + packages/web-components/src/tab/index.ts | 4 + .../web-components/src/tab/tab.definition.ts | 10 + packages/web-components/src/tab/tab.styles.ts | 111 ++++++++ .../web-components/src/tab/tab.template.ts | 14 + packages/web-components/src/tab/tab.ts | 25 ++ packages/web-components/src/tabs/define.ts | 4 + packages/web-components/src/tabs/index.ts | 5 + .../src/tabs/tabs.definition.ts | 10 + .../web-components/src/tabs/tabs.options.ts | 19 ++ packages/web-components/src/tabs/tabs.spec.md | 200 ++++++++++++++ .../web-components/src/tabs/tabs.stories.ts | 235 ++++++++++++++++ .../web-components/src/tabs/tabs.styles.ts | 255 ++++++++++++++++++ .../web-components/src/tabs/tabs.template.ts | 3 + packages/web-components/src/tabs/tabs.ts | 181 +++++++++++++ 23 files changed, 1126 insertions(+) create mode 100644 change/@fluentui-web-components-34a9c737-14ba-48cd-aaca-4a706295cefb.json create mode 100644 packages/web-components/src/tab-panel/define.ts create mode 100644 packages/web-components/src/tab-panel/index.ts create mode 100644 packages/web-components/src/tab-panel/tab-panel.definition.ts create mode 100644 packages/web-components/src/tab-panel/tab-panel.styles.ts create mode 100644 packages/web-components/src/tab-panel/tab-panel.template.ts create mode 100644 packages/web-components/src/tab-panel/tab-panel.ts create mode 100644 packages/web-components/src/tab/define.ts create mode 100644 packages/web-components/src/tab/index.ts create mode 100644 packages/web-components/src/tab/tab.definition.ts create mode 100644 packages/web-components/src/tab/tab.styles.ts create mode 100644 packages/web-components/src/tab/tab.template.ts create mode 100644 packages/web-components/src/tab/tab.ts create mode 100644 packages/web-components/src/tabs/define.ts create mode 100644 packages/web-components/src/tabs/index.ts create mode 100644 packages/web-components/src/tabs/tabs.definition.ts create mode 100644 packages/web-components/src/tabs/tabs.options.ts create mode 100644 packages/web-components/src/tabs/tabs.spec.md create mode 100644 packages/web-components/src/tabs/tabs.stories.ts create mode 100644 packages/web-components/src/tabs/tabs.styles.ts create mode 100644 packages/web-components/src/tabs/tabs.template.ts create mode 100644 packages/web-components/src/tabs/tabs.ts diff --git a/change/@fluentui-web-components-34a9c737-14ba-48cd-aaca-4a706295cefb.json b/change/@fluentui-web-components-34a9c737-14ba-48cd-aaca-4a706295cefb.json new file mode 100644 index 00000000000000..02b015b1fb028d --- /dev/null +++ b/change/@fluentui-web-components-34a9c737-14ba-48cd-aaca-4a706295cefb.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Adding TabList, Tab, and TabPanel web components", + "packageName": "@fluentui/web-components", + "email": "mibaraka@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/web-components/src/index.ts b/packages/web-components/src/index.ts index 35095ddf314d57..7aa3307163e81c 100644 --- a/packages/web-components/src/index.ts +++ b/packages/web-components/src/index.ts @@ -11,6 +11,9 @@ export * from './progress-bar/index.js'; export * from './slider/index.js'; export * from './spinner/index.js'; export * from './switch/index.js'; +export * from './tabs/index.js'; +export * from './tab/index.js'; +export * from './tab-panel/index.js'; export * from './text/index.js'; export * from './theme/index.js'; diff --git a/packages/web-components/src/tab-panel/define.ts b/packages/web-components/src/tab-panel/define.ts new file mode 100644 index 00000000000000..c60ce787b412d6 --- /dev/null +++ b/packages/web-components/src/tab-panel/define.ts @@ -0,0 +1,4 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { definition } from './tab-panel.definition.js'; + +definition.define(FluentDesignSystem.registry); diff --git a/packages/web-components/src/tab-panel/index.ts b/packages/web-components/src/tab-panel/index.ts new file mode 100644 index 00000000000000..a207477eac76dd --- /dev/null +++ b/packages/web-components/src/tab-panel/index.ts @@ -0,0 +1,4 @@ +export * from './tab-panel.js'; +export { template as TabPanelTemplate } from './tab-panel.template.js'; +export { styles as TabPanelStyles } from './tab-panel.styles.js'; +export { definition as TabPanelDefinition } from './tab-panel.definition.js'; diff --git a/packages/web-components/src/tab-panel/tab-panel.definition.ts b/packages/web-components/src/tab-panel/tab-panel.definition.ts new file mode 100644 index 00000000000000..b94f49a3289680 --- /dev/null +++ b/packages/web-components/src/tab-panel/tab-panel.definition.ts @@ -0,0 +1,10 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { TabPanel } from './tab-panel.js'; +import { template } from './tab-panel.template.js'; +import { styles } from './tab-panel.styles.js'; + +export const definition = TabPanel.compose({ + name: `${FluentDesignSystem.prefix}-tab-panel`, + template, + styles, +}); diff --git a/packages/web-components/src/tab-panel/tab-panel.styles.ts b/packages/web-components/src/tab-panel/tab-panel.styles.ts new file mode 100644 index 00000000000000..8d16a18d20678f --- /dev/null +++ b/packages/web-components/src/tab-panel/tab-panel.styles.ts @@ -0,0 +1,12 @@ +import { css } from '@microsoft/fast-element'; +import { display } from '@microsoft/fast-foundation'; +import { spacingHorizontalM, spacingHorizontalMNudge } from '../theme/design-tokens.js'; + +export const styles = css` + ${display('block')} + + :host { + box-sizing: border-box; + padding: ${spacingHorizontalM} ${spacingHorizontalMNudge}; + } +`; diff --git a/packages/web-components/src/tab-panel/tab-panel.template.ts b/packages/web-components/src/tab-panel/tab-panel.template.ts new file mode 100644 index 00000000000000..9226ce001ac554 --- /dev/null +++ b/packages/web-components/src/tab-panel/tab-panel.template.ts @@ -0,0 +1,3 @@ +import { tabPanelTemplate } from '@microsoft/fast-foundation'; + +export const template = tabPanelTemplate(); diff --git a/packages/web-components/src/tab-panel/tab-panel.ts b/packages/web-components/src/tab-panel/tab-panel.ts new file mode 100644 index 00000000000000..673ad33891033a --- /dev/null +++ b/packages/web-components/src/tab-panel/tab-panel.ts @@ -0,0 +1,3 @@ +import { FASTTabPanel } from '@microsoft/fast-foundation'; + +export class TabPanel extends FASTTabPanel {} diff --git a/packages/web-components/src/tab/define.ts b/packages/web-components/src/tab/define.ts new file mode 100644 index 00000000000000..dd39e9b3204957 --- /dev/null +++ b/packages/web-components/src/tab/define.ts @@ -0,0 +1,4 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { definition } from './tab.definition.js'; + +definition.define(FluentDesignSystem.registry); diff --git a/packages/web-components/src/tab/index.ts b/packages/web-components/src/tab/index.ts new file mode 100644 index 00000000000000..08f46e1c8d896c --- /dev/null +++ b/packages/web-components/src/tab/index.ts @@ -0,0 +1,4 @@ +export * from './tab.js'; +export { template as TabTemplate } from './tab.template.js'; +export { styles as TabStyles } from './tab.styles.js'; +export { definition as TabDefinition } from './tab.definition.js'; diff --git a/packages/web-components/src/tab/tab.definition.ts b/packages/web-components/src/tab/tab.definition.ts new file mode 100644 index 00000000000000..16fbae5108492c --- /dev/null +++ b/packages/web-components/src/tab/tab.definition.ts @@ -0,0 +1,10 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { Tab } from './tab.js'; +import { template } from './tab.template.js'; +import { styles } from './tab.styles.js'; + +export const definition = Tab.compose({ + name: `${FluentDesignSystem.prefix}-tab`, + template, + styles, +}); diff --git a/packages/web-components/src/tab/tab.styles.ts b/packages/web-components/src/tab/tab.styles.ts new file mode 100644 index 00000000000000..e5fcaeae516b21 --- /dev/null +++ b/packages/web-components/src/tab/tab.styles.ts @@ -0,0 +1,111 @@ +import { css } from '@microsoft/fast-element'; +import { display } from '@microsoft/fast-foundation'; +import { + borderRadiusCircular, + borderRadiusMedium, + borderRadiusSmall, + colorCompoundBrandStroke, + colorNeutralForeground1, + colorNeutralForeground2, + colorNeutralForegroundDisabled, + colorNeutralStroke1Hover, + colorStrokeFocus1, + colorStrokeFocus2, + fontFamilyBase, + fontSizeBase300, + fontWeightSemibold, + lineHeightBase300, + spacingHorizontalM, + spacingHorizontalMNudge, +} from '../theme/design-tokens.js'; + +export const styles = css` + ${display('inline-flex')} + + :host { + position: relative; + flex-direction: column; + cursor: pointer; + box-sizing: border-box; + justify-content: center; + line-height: ${lineHeightBase300}; + font-family: ${fontFamilyBase}; + font-size: ${fontSizeBase300}; + color: ${colorNeutralForeground2}; + fill: currentcolor; + grid-row: 1; + padding: ${spacingHorizontalM} ${spacingHorizontalMNudge}; + border-radius: ${borderRadiusMedium}; + } + :host .tab-content { + display: inline-flex; + flex-direction: column; + padding: 0 2px; + } + + :host([aria-selected='true']) { + color: ${colorNeutralForeground1}; + font-weight: ${fontWeightSemibold}; + } + + /* adds hidden textContent to prevent shifting ui on bold / unbolding of text */ + :host .tab-content::after { + content: var(--textContent); + visibility: hidden; + height: 0; + line-height: ${lineHeightBase300}; + font-weight: ${fontWeightSemibold}; + } + + :host([aria-selected='true'])::after { + background-color: ${colorCompoundBrandStroke}; + border-radius: ${borderRadiusCircular}; + content: ''; + inset: 0; + position: absolute; + z-index: 2; + } + + :host([aria-selected='false']:hover)::after { + background-color: ${colorNeutralStroke1Hover}; + border-radius: ${borderRadiusCircular}; + content: ''; + inset: 0; + position: absolute; + z-index: 1; + } + + :host([aria-selected='true'][disabled])::after { + background-color: ${colorNeutralForegroundDisabled}; + } + + ::slotted([slot='start']), + ::slotted([slot='end']) { + display: flex; + } + ::slotted([slot='start']) { + margin-inline-end: 11px; + } + ::slotted([slot='end']) { + margin-inline-start: 11px; + } + :host([disabled]) { + cursor: not-allowed; + fill: ${colorNeutralForegroundDisabled}; + color: ${colorNeutralForegroundDisabled}; + } + + :host([disabled]:hover)::after { + background-color: unset; + } + + :host(:focus) { + outline: none; + } + + :host(:focus-visible) { + border-radius: ${borderRadiusSmall}; + box-shadow: 0 0 0 3px ${colorStrokeFocus2}; + outline: 1px solid ${colorStrokeFocus1}; + } +`; diff --git a/packages/web-components/src/tab/tab.template.ts b/packages/web-components/src/tab/tab.template.ts new file mode 100644 index 00000000000000..b0a5991a069503 --- /dev/null +++ b/packages/web-components/src/tab/tab.template.ts @@ -0,0 +1,14 @@ +import { endSlotTemplate, FASTTab, startSlotTemplate, TabOptions } from '@microsoft/fast-foundation'; +import { ElementViewTemplate, html } from '@microsoft/fast-element'; + +export function tabTemplate(options: TabOptions = {}): ElementViewTemplate { + return html` + + `; +} + +export const template = tabTemplate({}); diff --git a/packages/web-components/src/tab/tab.ts b/packages/web-components/src/tab/tab.ts new file mode 100644 index 00000000000000..7e32451ea8f788 --- /dev/null +++ b/packages/web-components/src/tab/tab.ts @@ -0,0 +1,25 @@ +import { css, ElementStyles } from '@microsoft/fast-element'; +import { FASTTab } from '@microsoft/fast-foundation'; + +/** + * Tab extends the FASTTab and is a child of the TabList + */ +export class Tab extends FASTTab { + private styles: ElementStyles | undefined; + + connectedCallback() { + super.connectedCallback(); + + if (this.styles !== undefined) { + this.$fastController.removeStyles(this.styles); + } + + this.styles = css/**css*/ ` + :host { + --textContent: '${this.textContent as any}'; + } + `; + + this.$fastController.addStyles(this.styles); + } +} diff --git a/packages/web-components/src/tabs/define.ts b/packages/web-components/src/tabs/define.ts new file mode 100644 index 00000000000000..30cbdf744c61f0 --- /dev/null +++ b/packages/web-components/src/tabs/define.ts @@ -0,0 +1,4 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { definition } from './tabs.definition.js'; + +definition.define(FluentDesignSystem.registry); diff --git a/packages/web-components/src/tabs/index.ts b/packages/web-components/src/tabs/index.ts new file mode 100644 index 00000000000000..14c0fbd3d95507 --- /dev/null +++ b/packages/web-components/src/tabs/index.ts @@ -0,0 +1,5 @@ +export * from './tabs.js'; +export * from './tabs.options.js'; +export { template as TabsTemplate } from './tabs.template.js'; +export { styles as TabsStyles } from './tabs.styles.js'; +export { definition as TabsDefinition } from './tabs.definition.js'; diff --git a/packages/web-components/src/tabs/tabs.definition.ts b/packages/web-components/src/tabs/tabs.definition.ts new file mode 100644 index 00000000000000..f3b2494308437d --- /dev/null +++ b/packages/web-components/src/tabs/tabs.definition.ts @@ -0,0 +1,10 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { Tabs } from './tabs.js'; +import { template } from './tabs.template.js'; +import { styles } from './tabs.styles.js'; + +export const definition = Tabs.compose({ + name: `${FluentDesignSystem.prefix}-tabs`, + template, + styles, +}); diff --git a/packages/web-components/src/tabs/tabs.options.ts b/packages/web-components/src/tabs/tabs.options.ts new file mode 100644 index 00000000000000..6509d9d57539b3 --- /dev/null +++ b/packages/web-components/src/tabs/tabs.options.ts @@ -0,0 +1,19 @@ +import { ValuesOf } from '@microsoft/fast-foundation'; +import { TabsOrientation } from '@microsoft/fast-foundation'; + +export const TabsAppearance = { + subtle: 'subtle', + transparent: 'transparent', +} as const; + +export type TabsAppearance = ValuesOf; + +export const TabsSize = { + small: 'small', + medium: 'medium', + large: 'large', +} as const; + +export type TabsSize = ValuesOf; + +export { TabsOrientation }; diff --git a/packages/web-components/src/tabs/tabs.spec.md b/packages/web-components/src/tabs/tabs.spec.md new file mode 100644 index 00000000000000..d7b5f32756ffa7 --- /dev/null +++ b/packages/web-components/src/tabs/tabs.spec.md @@ -0,0 +1,200 @@ +# Tabs + +## Description + +Tabs allow for navigation between two or more content views and relies on text headers to articulate the different sections of content. + +A note on the naming of this component. The closest equivalent from Fluent UI React is the TabList control. The Web Component Tabs control is named differently because the control comprises of Tabs, Tab List and a Tab Panel. Whereas the react equivalent does not include tab panels. Therefore, a fully equivalent name, in this case, would be inaccurate. + +## Design Spec + +[Link to Design Spec in Figma](https://www.figma.com/file/dK5AnDvvnSTWV9lduQWeDk/TabList?node-id=3942%3A9316&t=we0hQaRaKSJc6IeM-0) + +## Engineering Spec + +### Inputs + +| attribute | type | default | description | +| ----------- | ------------------------------ | ------------- | ----------------------------------------------------------------------- | +| activeid | string | - | sets the selected tab | +| appearance | "subtle" \| "transparent | "transparent" | - | +| disabled | boolean | - | blocks control and all tab children from all keyboard and mouse events. | +| size | "small" \| "medium" \| "large" | "medium" | | +| orientation | "vertical" \| "horizontal" | "horizontal" | sets the orientation of the tab list to vertical display | + +### Outputs + +- [selectedValue: unkown] - the selected value of the currently selected tab + +### Events + +- change: html event handler - event fires on keyboard or mouse click + +### Slots + +- start - content before the tab list +- end - content after the tab list +- tab - slot for the tab itself +- tabpanel - slot for tab panel + +### CSS Variables + +| state | variant | destination | css variable | +| -------- | --------------------------------- | ------------------------- | --------------------------------- | +| rest | transparent, rest, selected | background color | --transparentBackground | +| rest | transparent, rest, selected | content color | --neutralForeground1 | +| rest | transparent, rest, selected | selection indicator color | --compoundBrandStroke1 | +| rest | transparent, rest, selected | icon color | --compoundBrandForeground1 | +| - | -- | -- | -- | +| rest | transparent, rest, unselected | background color | --transparentBackground | +| rest | transparent, rest, unselected | content color | --neutralForeground2 | +| rest | transparent, rest, unselected | selection indicator color | --transparentStroke | +| rest | transparent, rest, unselected | icon color | --neutralForeground2 | +| -- | -- | -- | -- | +| hover | transparent, hover, selected | background color | --transparentBackgroundHover | +| hover | transparent, hover, selected | content color | --neutralForeground1Hover | +| hover | transparent, hover, selected | selection indicator color | --compoundBrandStroke1Hover | +| hover | transparent, hover, selected | icon color | --compoundBrandForeground1Hover | +| -- | -- | -- | -- | +| hover | transparent, hover, unselected | background color | --transparentBackgroundHover | +| hover | transparent, hover, unselected | content color | --neutralForeground2Hover | +| hover | transparent, hover, unselected | selection indicator color | --compoundBrandStroke1Hover | +| hover | transparent, hover, unselected | icon color | --compoundBrandForeground2Hover | +| -- | -- | -- | -- | +| pressed | transparent, hover, selected | background color | --transparentBackgroundPressed | +| pressed | transparent, hover, selected | content color | --neutralForeground1Pressed | +| pressed | transparent, hover, selected | selection indicator color | --compoundBrandStroke1Pressed | +| pressed | transparent, hover, selected | icon color | --compoundBrandForeground1Pressed | +| -- | -- | -- | -- | +| pressed | transparent, hover, unselected | background color | --transparentBackgroundPressed | +| pressed | transparent, hover, unselected | content color | --neutralForeground2Pressed | +| pressed | transparent, hover, unselected | selection indicator color | --neutralStroke1Pressed | +| pressed | transparent, hover, unselected | icon color | --compoundBrandForeground1Pressed | +| -- | -- | -- | -- | +| disabled | transparent, disabled, selected | background color | --transparentBackground | +| disabled | transparent, disabled, selected | content color | --neutralForegroundDisabled | +| disabled | transparent, disabled, selected | selection indicator color | --neutralForegroundDisabled | +| disabled | transparent, disabled, selected | icon color | --nuetralForegroundDisabled | +| -- | -- | -- | -- | +| disabled | transparent, disabled, unselected | background color | --transparentBackground | +| disabled | transparent, disabled, unselected | content color | --neutralForegroundDisabled | +| disabled | transparent, disabled, unselected | selection indicator color | --transparentStroke | +| disabled | transparent, disabled, unselected | icon color | --neutralForegroundDisabled | + +--- + +## Accessibility + +- [x] Tabs WCAG's patterns: https://www.w3.org/WAI/ARIA/apg/patterns/tabs/ + - recommended that tabs activate on focus + - content of table is preloaded + - when tab list is aria-orientation vertical: `down arrow` performs as right arrow and `up arrow` performs as left arrow + - Horizontal tab list does not listen for `down arrow` or `up arrow` + - when tabpanel does not contain any focusable elements or the first element is not focusable the tab panel should set `tabindex="0"` +- [x] Are there any accessibility elements unique to this component? yes, many see link above. +- [x] List ARIA attributes: `role, aria-labelledby, aria-label, aria-controls, aria-selected, aria-haspopup, aria-orientation` +- [x] Does the component support 400% zoom? + +## Preparation + +- [x] [FAST Tabs Component](https://www.fast.design/docs/components/tabs/) this component will inherit from and document +- [x] [Check the Fluent UI React V9 Component Spec](https://github.com/microsoft/fluentui/blob/master/packages/react-components/react-tabs/docs/Spec.md) + - React V9 TabList prefers event handler to be passed into component as a React Prop `onTabSelect`. This event handling will be removed in favor of the TabList web component taking control of the event handling. + - React V9 `as` prop (to render the list as HTML element preferred by developer) will be omitted + - React V9 +- [x] [Fluent UI React V9 Storybook](https://react.fluentui.dev/?path=/docs/components-tablist--default) +- [x] [Open GitHub issues related to component](https://github.com/microsoft/fluentui/issues?q=is%3Aissue+is%3Aopen+TabList) +- [ ] (Optional) [Draft implementation](https://github.com/microsoft/fluentui/wiki/Component-Implementation-Guide#draft-implementation) + - [link to draft implementation, if applicable] +- [ ] [Component Spec authored](https://github.com/microsoft/fluentui/wiki/Component-Implementation-Guide#component-spec) and [reviewed](https://github.com/microsoft/fluentui/wiki/Component-Implementation-Guide#spec-review) + +## Differences from Fluent UI to FAST + +The Fluent/FAST web component differs from the Fluent React Control as follows: + +| difference | Tabs - Fluent Web Component | TabList - Fluent React Component | +| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| active indicator control / id control selection | managed by control | managed by user with application state | +| keyboard and focus selection | selects active tab on arrow key focus change | reselects tab on space bar or enter after arrow refocus | +| icon slotting | favors composition (dev chooses how to slot which icon) | favors automation (dev supplies icon name and control handles the rendering of icon) | +| icon slotting filled / unfilled icons | favors composition over automated handling. requires dev to add interactivity to render filled or unfilled icon | favors automated handling of icons and provides filled and unfilled iconography on selection | +| tab-panels | requires tab panel control to set content on tab selection | does not require or include a tab panel control / template | +| reserve-selected-tab-space | has reserve selected tab space defaulting to true and gives user the option to set to false | removes attribute | + +[Link to FAST Web Component API](https://www.fast.design/docs/components/tabs/#class-tab) + +| fluent api name | fast api Equivalent | +| --------------- | ------------------- | +| vertical | orientation | +| selected-value | activeid | +| value | id | + +## Implementation - Sample Code + +### Default + +By default Tabs are arranged horizontally. The developer sets `selected-value` Fluent-Tab-List attribute. The Component handles the logic of what is shown and hidden when user clicks on the tabs. For switcing to work correctly the tab list requires that the indexing of the tabs and tab-panels be organized to correspond to their matching items - the order of the tabs must match the order of the tab panels: + +```html + + One / Left + Two / Middle + Three / Right + + Panel One + Panel Two + Panel Three + +``` + +### Controlled + +If the developer wants to control the selected tab, tab values can be provided. + +```html + + One / Left + Two / Middle + Three / Right + + Panel One + Panel Two + Panel Three + +``` + +### Vertical + +```html + + One / Left + Two / Middle + Three / Right + +``` + +## Implementation + +- [ ] Initial conformance and unit tests (validate basic functionality) +- [x] [Initial documentation](https://github.com/microsoft/fluentui/wiki/Component-Implementation-Guide#documentation) + - [x] [Storybook stories](https://github.com/microsoft/fluentui/wiki/Component-Implementation-Guide#storybook-stories) + - [x] README.md covering basic usage +- [ ] [Component released as unstable](https://github.com/microsoft/fluentui/wiki/Component-Implementation-Guide#unstable-release) from `@fluentui/web-components/unstable` +- [x] Uses design tokens for styling +- [ ] Renders correctly in High Contrast mode + +## Validation + +- [ ] [Add tests](https://github.com/microsoft/fluentui/wiki/Component-Implementation-Guide#tests) + - [ ] Unit and conformance tests + - [ ] Bundle size fixtures + - [ ] Performance test scenario + - [ ] Accessibility behavior tests + - [ ] Create an issue and run [manual accessibility tests](https://github.com/microsoft/fluentui/wiki/Manual-Accessibility-Review-Checklist): [link to issue] +- [ ] [Validate with partners](https://github.com/microsoft/fluentui/wiki/Component-Implementation-Guide#validation) +- [ ] [Finalize documentation](https://github.com/microsoft/fluentui/wiki/Component-Implementation-Guide#finalize-documentation) + - [ ] Review and add any missing storybook stories + - [ ] Finalize migration guide + - [ ] In package.json: Remove the alpha/beta tag from the version number in package.json + - [ ] In package.json: Change beachball's `disallowedChangeTypes` to `"major", "prerelease"` +- [x] [Fluent Design Guidelines](https://www.figma.com/file/dK5AnDvvnSTWV9lduQWeDk/TabList?node-id=3942%3A9316&t=0maCSaKYbXs7BoLo-1) diff --git a/packages/web-components/src/tabs/tabs.stories.ts b/packages/web-components/src/tabs/tabs.stories.ts new file mode 100644 index 00000000000000..76479f1e0dcca1 --- /dev/null +++ b/packages/web-components/src/tabs/tabs.stories.ts @@ -0,0 +1,235 @@ +import { html } from '@microsoft/fast-element'; +import { TabsOrientation } from '@microsoft/fast-foundation'; +import type { Args, Meta } from '@storybook/html'; +import { renderComponent } from '../helpers.stories.js'; +import type { Tabs as FluentTabs } from './tabs.js'; +import './define.js'; +import '../tab/define.js'; +import '../tab-panel/define.js'; +import { TabsAppearance as TabsAppearanceValues, TabsSize } from './tabs.options.js'; + +type TabsStoryArgs = Args & FluentTabs; +type TabsStoryMeta = Meta; + +const tabIds = ['first-tab', 'second-tab', 'third-tab', 'fourth-tab']; + +const tabsDefault = html` + x.orientation} + appearance=${x => x.appearance} + ?disabled=${x => x.disabled} + size=${x => x.size} + activeid=${x => x.activeid} + > + First Tab + Second Tab + Third Tab + Fourth Tab + + Tab One Content + Tab Two Content + Tab Three Content + Tab Four Content + +`; +export const TabsDefault = renderComponent(tabsDefault).bind({}); + +const tabsHorizontal = html` + + First Tab + Second Tab + Third Tab + Fourth Tab + + + + + + +`; +export const TabsHorizontal = renderComponent(tabsHorizontal).bind({}); + +const tabsVertical = html` + + First Tab + Second Tab + Third Tab + Fourth Tab + + + + + + +`; +export const TabsVertical = renderComponent(tabsVertical).bind({}); + +const tabsAppearance = html` + + First Tab + Second Tab + Third Tab + Fourth Tab + + + + + + + + First Tab + Second Tab + Third Tab + Fourth Tab + + + + + + +`; +export const TabsAppearance = renderComponent(tabsAppearance).bind({}); + +const tabsDisabledTabs = html` + + First Tab + Second Tab + Third Tab + Fourth Tab + + + + + + + + + First Tab + Second Tab + Third Tab + Fourth Tab + + + + + + +`; +export const TabsDisabled = renderComponent(tabsDisabledTabs).bind({}); + +const tabsSizeSmall = html` + + First Tab + Second Tab + Third Tab + Fourth Tab + + + + + + + + First Tab + Second Tab + Third Tab + Fourth Tab + + + + + + +`; +export const TabsSizeSmall = renderComponent(tabsSizeSmall).bind({}); + +const tabsSizeMedium = html` + + First Tab + Second Tab + Third Tab + Fourth Tab + + + + + + + + First Tab + Second Tab + Third Tab + Fourth Tab + + + + + + +`; +export const TabsSizeMedium = renderComponent(tabsSizeMedium).bind({}); + +const tabsSizeLarge = html` + + First Tab + Second Tab + Third Tab + Fourth Tab + + + + + + + + First Tab + Second Tab + Third Tab + Fourth Tab + + + + + + +`; +export const TabsSizeLarge = renderComponent(tabsSizeLarge).bind({}); + +export default { + title: 'Components/Tabs', + args: { + appearance: 'transparent', + disabled: false, + orientation: 'horizontal', + size: 'medium', + }, + argTypes: { + appearance: { + options: Object.values(TabsAppearanceValues), + defaultValue: TabsAppearanceValues.transparent, + control: { + type: 'select', + }, + }, + activeid: { + options: tabIds, + defaultValue: tabIds[0], + control: { type: 'select' }, + }, + disabled: { + options: [true, false], + defaultValue: false, + control: { type: 'select' }, + }, + size: { + options: Object.values(TabsSize), + defaultValue: TabsSize.medium, + control: { type: 'select' }, + }, + orientation: { + options: Object.values(TabsOrientation), + defaultValue: TabsOrientation.horizontal, + control: { type: 'select' }, + }, + }, +} as TabsStoryMeta; diff --git a/packages/web-components/src/tabs/tabs.styles.ts b/packages/web-components/src/tabs/tabs.styles.ts new file mode 100644 index 00000000000000..949c18a33160c9 --- /dev/null +++ b/packages/web-components/src/tabs/tabs.styles.ts @@ -0,0 +1,255 @@ +import { css } from '@microsoft/fast-element'; +import { display } from '@microsoft/fast-foundation'; +import { + borderRadiusCircular, + borderRadiusMedium, + colorCompoundBrandForeground1Hover, + colorNeutralForeground1, + colorNeutralForeground1Hover, + colorNeutralForeground2, + colorNeutralForegroundDisabled, + colorSubtleBackgroundHover, + colorSubtleBackgroundPressed, + curveDecelerateMax, + durationSlow, + fontFamilyBase, + fontSizeBase300, + fontSizeBase400, + lineHeightBase300, + lineHeightBase400, + spacingHorizontalMNudge, + spacingHorizontalSNudge, + spacingVerticalL, + spacingVerticalMNudge, + spacingVerticalS, + spacingVerticalSNudge, + spacingVerticalXXS, + strokeWidthThicker, +} from '../theme/design-tokens.js'; + +export const styles = css` + ${display('grid')} + + :host { + box-sizing: border-box; + font-family: ${fontFamilyBase}; + font-size: ${fontSizeBase300}; + line-height: ${lineHeightBase300}; + color: ${colorNeutralForeground2}; + grid-template-columns: auto 1fr auto; + grid-template-rows: auto 1fr; + } + + :host([disabled]) { + cursor: not-allowed; + color: ${colorNeutralForegroundDisabled}; + } + + :host([disabled]) ::slotted(fluent-tab) { + pointer-events: none; + cursor: not-allowed; + color: ${colorNeutralForegroundDisabled}; + } + :host([disabled]) ::slotted(fluent-tab:after) { + background-color: ${colorNeutralForegroundDisabled}; + } + :host([disabled]) ::slotted(fluent-tab[aria-selected='true'])::after { + background-color: ${colorNeutralForegroundDisabled}; + } + :host([disabled]) ::slotted(fluent-tab:hover):before { + content: unset; + } + + :host ::slotted(fluent-tab) { + border-radius: ${borderRadiusMedium}; + } + + :host ::slotted(fluent-tab[aria-selected='true'])::before { + background-color: ${colorNeutralForegroundDisabled}; + } + + .tablist { + display: grid; + grid-template-rows: auto auto; + grid-template-columns: auto; + position: relative; + width: max-content; + align-self: end; + box-sizing: border-box; + } + ::slotted([slot='start']), + ::slotted([slot='end']) { + display: flex; + align-self: center; + } + ::slotted([slot='start']) { + margin-inline-end: 11px; + } + ::slotted([slot='end']) { + margin-inline-start: 11px; + } + + .tabpanel { + grid-row: 2; + grid-column-start: 1; + grid-column-end: 4; + position: relative; + } + :host([orientation='vertical']) { + grid-template-rows: auto 1fr auto; + grid-template-columns: auto 1fr; + } + :host([orientation='vertical']) .tablist { + grid-row-start: 2; + grid-row-end: 2; + display: grid; + grid-template-rows: auto; + grid-template-columns: auto 1fr; + position: relative; + width: max-content; + justify-self: end; + align-self: flex-start; + width: 100%; + } + :host([orientation='vertical']) .tabpanel { + grid-column: 2; + grid-row-start: 1; + grid-row-end: 4; + } + :host([orientation='vertical']) ::slotted([slot='end']) { + grid-row: 3; + } + + :host([appearance='subtle']) ::slotted(fluent-tab:hover) { + background-color: ${colorSubtleBackgroundHover}; + color: ${colorNeutralForeground1Hover}; + fill: ${colorCompoundBrandForeground1Hover}; + } + + :host([appearance='subtle']) ::slotted(fluent-tab:active) { + background-color: ${colorSubtleBackgroundPressed}; + fill: ${colorSubtleBackgroundPressed}; + color: ${colorNeutralForeground1}; + } + + :host([size='small']) ::slotted(fluent-tab) { + font-size: ${fontSizeBase300}; + line-height: ${lineHeightBase300}; + padding: ${spacingVerticalSNudge} ${spacingHorizontalSNudge}; + } + + :host([size='large']) ::slotted(fluent-tab) { + font-size: ${fontSizeBase400}; + line-height: ${lineHeightBase400}; + padding: ${spacingVerticalL} ${spacingHorizontalMNudge}; + } + + /* indicator animation */ + :host ::slotted(fluent-tab[data-animate='true'])::after { + transition-property: transform; + transition-duration: ${durationSlow}; + transition-timing-function: ${curveDecelerateMax}; + } + :host ::slotted(fluent-tab)::after { + height: ${strokeWidthThicker}; + margin-top: auto; + transform-origin: left; + transform: translateX(var(--tabIndicatorOffset)) scaleX(var(--tabIndicatorScale)); + } + :host([orientation='vertical']) ::slotted(fluent-tab)::after { + width: ${strokeWidthThicker}; + height: unset; + margin-top: unset; + transform-origin: top; + transform: translateY(var(--tabIndicatorOffset)) scaleY(var(--tabIndicatorScale)); + } + + /* ::before adds a secondary indicator placeholder that appears right after click on the active tab */ + :host ::slotted(fluent-tab)::before { + height: ${strokeWidthThicker}; + border-radius: ${borderRadiusCircular}; + content: ''; + inset: 0; + position: absolute; + margin-top: auto; + } + :host([orientation='vertical']) ::slotted(fluent-tab)::before { + height: unset; + width: ${strokeWidthThicker}; + margin-inline-end: auto; + transform-origin: top; + } + + :host ::slotted(fluent-tab[aria-selected='false']:hover)::after { + height: ${strokeWidthThicker}; + margin-top: auto; + transform-origin: left; + } + :host([orientation='vertical']) ::slotted(fluent-tab[aria-selected='false']:hover)::after { + height: unset; + margin-inline-end: auto; + transform-origin: top; + width: ${strokeWidthThicker}; + } + + :host([orientation='vertical']) ::slotted(fluent-tab) { + align-items: flex-start; + grid-column: 2; + padding-top: ${spacingVerticalSNudge}; + padding-bottom: ${spacingVerticalSNudge}; + } + :host([orientation='vertical'][size='small']) ::slotted(fluent-tab) { + padding-top: ${spacingVerticalXXS}; + padding-bottom: ${spacingVerticalXXS}; + } + :host([orientation='vertical'][size='large']) ::slotted(fluent-tab) { + padding-top: ${spacingVerticalS}; + padding-bottom: ${spacingVerticalS}; + } + + /* horizontal spacing for indicator */ + :host([size='small']) ::slotted(fluent-tab)::after, + :host([size='small']) ::slotted(fluent-tab)::before, + :host([size='small']) ::slotted(fluent-tab:hover)::after { + right: ${spacingHorizontalSNudge}; + left: ${spacingHorizontalSNudge}; + } + :host ::slotted(fluent-tab)::after, + :host ::slotted(fluent-tab)::before, + :host ::slotted(fluent-tab:hover)::after { + right: ${spacingHorizontalMNudge}; + left: ${spacingHorizontalMNudge}; + } + :host([size='large']) ::slotted(fluent-tab)::after, + :host([size='large']) ::slotted(fluent-tab)::before, + :host([size='large']) ::slotted(fluent-tab:hover)::after { + right: ${spacingHorizontalMNudge}; + left: ${spacingHorizontalMNudge}; + } + + /* vertical spacing for indicator */ + :host([orientation='vertical'][size='small']) ::slotted(fluent-tab)::after, + :host([orientation='vertical'][size='small']) ::slotted(fluent-tab)::before, + :host([orientation='vertical'][size='small']) ::slotted(fluent-tab:hover)::after { + right: 0; + left: 0; + top: ${spacingVerticalSNudge}; + bottom: ${spacingVerticalSNudge}; + } + :host([orientation='vertical']) ::slotted(fluent-tab)::after, + :host([orientation='vertical']) ::slotted(fluent-tab)::before, + :host([orientation='vertical']) ::slotted(fluent-tab:hover)::after { + right: 0; + left: 0; + top: ${spacingVerticalS}; + bottom: ${spacingVerticalS}; + } + :host([orientation='vertical'][size='large']) ::slotted(fluent-tab)::after, + :host([orientation='vertical'][size='large']) ::slotted(fluent-tab)::before, + :host([orientation='vertical'][size='large']) ::slotted(fluent-tab:hover)::after { + right: 0; + left: 0; + top: ${spacingVerticalMNudge}; + bottom: ${spacingVerticalMNudge}; + } +`; diff --git a/packages/web-components/src/tabs/tabs.template.ts b/packages/web-components/src/tabs/tabs.template.ts new file mode 100644 index 00000000000000..bfe986487625be --- /dev/null +++ b/packages/web-components/src/tabs/tabs.template.ts @@ -0,0 +1,3 @@ +import { tabsTemplate } from '@microsoft/fast-foundation'; + +export const template = tabsTemplate({}); diff --git a/packages/web-components/src/tabs/tabs.ts b/packages/web-components/src/tabs/tabs.ts new file mode 100644 index 00000000000000..e370582fc6860c --- /dev/null +++ b/packages/web-components/src/tabs/tabs.ts @@ -0,0 +1,181 @@ +import { attr, css, ElementStyles } from '@microsoft/fast-element'; +import { FASTTabs, TabsOrientation } from '@microsoft/fast-foundation'; +import { Tab } from '../index.js'; +import { TabsAppearance, TabsSize } from './tabs.options.js'; + +type TabData = Omit; + +/** + * TabList extends FASTTabs and is used for constructing a fluent-tab-list custom html element. + * + * @class TabList component + * @public + */ +export class Tabs extends FASTTabs { + /** + * activeTabData + * The positional coordinates and size dimensions of the active tab. Used for calculating the offset and scale of the tab active indicator. + */ + private activeTabData: TabData = { x: 0, y: 0, height: 0, width: 0 }; + /** + * previousActiveTabData + * The positional coordinates and size dimensions of the active tab. Used for calculating the offset and scale of the tab active indicator. + */ + private previousActiveTabData: TabData = { x: 0, y: 0, height: 0, width: 0 }; + /** + * activeTabOffset + * Used to position the active indicator for animations of the active indicator on active tab changes. + */ + private activeTabOffset = 0; + /** + * activeTabScale + * Used to scale the tab active indicator up or down as animations of the active indicator occur. + */ + private activeTabScale = 1; + + /** + * styles + * used in the class for storing the css variables required for animations + */ + private styles: ElementStyles | undefined; + + /** + * appearance + * There are two modes of appearance: transparent and subtle. + */ + @attr + public appearance?: TabsAppearance = TabsAppearance.transparent; + + /** + * disabled + * Used for disabling all click and keyboard events for the tabs, child tab elements and tab panel elements. UI styling of content and tabs will appear as "grayed out." + */ + @attr({ mode: 'boolean' }) + public disabled?: boolean; + + /** + * size + * defaults to medium. + * Used to set the size of all the tab controls, which effects text size and margins. Three sizes: small, medium and large. + */ + @attr + public size?: TabsSize; + + /** + * calculateAnimationProperties + * + * Recalculates the active tab offset and scale. + * These values will be applied to css variables that control the tab active indicator position animations + */ + private calculateAnimationProperties(tab: Tab) { + this.activeTabOffset = this.getTabPosition(tab); + this.activeTabScale = this.getTabScale(tab); + } + + /** + * getSelectedTabPosition - gets the x or y coordinates of the tab + */ + private getTabPosition(tab: Tab): number { + if (this.orientation === TabsOrientation.horizontal) { + return this.previousActiveTabData.x - (tab.getBoundingClientRect().x - this.getBoundingClientRect().x); + } else return this.previousActiveTabData.y - (tab.getBoundingClientRect().y - this.getBoundingClientRect().y); + } + + /** + * getSelectedTabScale - gets the scale of the tab + */ + private getTabScale(tab: Tab): number { + if (this.orientation === TabsOrientation.horizontal) { + return this.previousActiveTabData.width / tab.getBoundingClientRect().width; + } else return this.previousActiveTabData.height / tab.getBoundingClientRect().height; + } + + /** + * applyUpdatedCSSValues + * + * calculates and applies updated values to CSS variables + * @param tab + */ + private applyUpdatedCSSValues(tab: Tab) { + this.calculateAnimationProperties(tab); + this.setTabScaleCSSVar(); + this.setTabOffsetCSSVar(); + } + + /** + * animationLoop + * runs through all the operations required for setting the tab active indicator to its starting location, ending location, and applying the animated css class to the tab. + * @param tab + */ + private animationLoop(tab: Tab) { + // remove the animated class so nothing animates yet + tab.setAttribute('data-animate', 'false'); + // animation start - this applyUpdeatedCSSValues sets the active indicator to the location of the previously selected tab + this.applyUpdatedCSSValues(tab); + // changing the previously active tab allows the applyUpdatedCSSValues method to calculate the correct end to the animation. + this.previousActiveTabData = this.activeTabData; + // calculate and apply updated css values for animation. + this.applyUpdatedCSSValues(tab); + // add the css class and active indicator will animate from the previous tab location to its tab location + tab.setAttribute('data-animate', 'true'); + } + + /** + * setTabData + * sets the data from the active tab onto the class. used for making all the animation calculations for the active tab indicator. + */ + private setTabData(): void { + if (this.tabs && this.tabs.length > 0) { + const tabs = this.tabs as Tab[]; + const activeTab = this.activetab || tabs[0]; + const activeRect = activeTab?.getBoundingClientRect(); + const parentRect = this.getBoundingClientRect(); + + this.activeTabData = { + x: activeRect.x - parentRect.x, + y: activeRect.y - parentRect.y, + height: activeRect.height, + width: activeRect.width, + } as TabData; + + if ( + this.previousActiveTabData?.x !== this.activeTabData?.x && + this.previousActiveTabData?.y !== this.activeTabData?.y + ) { + this.previousActiveTabData = this.activeTabData; + } + } + } + + private setTabOffsetCSSVar() { + this.styles = css/**css*/ ` + :host { + --tabIndicatorOffset: ${this.activeTabOffset.toString()}px; + } + `; + this.$fastController.addStyles(this.styles); + } + + private setTabScaleCSSVar() { + this.styles = css/**css*/ ` + :host { + --tabIndicatorScale: ${this.activeTabScale.toString()}; + } + `; + this.$fastController.addStyles(this.styles); + } + + public activeidChanged(oldValue: string, newValue: string) { + super.activeidChanged(oldValue, newValue); + this.setTabData(); + + if (this.activetab) { + this.animationLoop(this.activetab as Tab); + } + } + + public tabsChanged(): void { + super.tabsChanged(); + this.setTabData(); + } +}