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(); + } +}