diff --git a/change/@fluentui-web-components-f0a2f34a-306f-475b-b230-e24dd267cab0.json b/change/@fluentui-web-components-f0a2f34a-306f-475b-b230-e24dd267cab0.json new file mode 100644 index 0000000000000..6d52baa7a36e5 --- /dev/null +++ b/change/@fluentui-web-components-f0a2f34a-306f-475b-b230-e24dd267cab0.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat(drawer): add drawer web component", + "packageName": "@fluentui/web-components", + "email": "email not defined", + "dependentChangeType": "patch" +} diff --git a/packages/web-components/package.json b/packages/web-components/package.json index 7285b4b063e69..51dacd8a06bea 100644 --- a/packages/web-components/package.json +++ b/packages/web-components/package.json @@ -78,6 +78,14 @@ "types": "./dist/dts/divider/define.d.ts", "default": "./dist/esm/divider/define.js" }, + "./drawer.js": { + "types": "./dist/dts/drawer/define.d.ts", + "default": "./dist/esm/drawer/define.js" + }, + "./drawer-body.js": { + "types": "./dist/dts/drawer-body/define.d.ts", + "default": "./dist/esm/drawer-body/define.js" + }, "./image.js": { "types": "./dist/dts/image/define.d.ts", "default": "./dist/esm/image/define.js" diff --git a/packages/web-components/src/drawer-body/README.md b/packages/web-components/src/drawer-body/README.md new file mode 100644 index 0000000000000..3910ecf320797 --- /dev/null +++ b/packages/web-components/src/drawer-body/README.md @@ -0,0 +1,49 @@ +# DrawerBody + +## Overview + +The `DrawerBody` component is a part of the Fluent UI library and is intended to be used with the `fluent-drawer` component. It provides consumers with a structured template for the drawer's content, including areas for a header, content, and footer. + +## Attributes + +This component does not have specific attributes beyond standard HTML attributes. + +## Methods + +The `DrawerBody` component does not define custom methods beyond those inherited from `FASTElement`. + +## Events + +The `DrawerBody` component does not emit custom events beyond those standard to HTML elements. + +### **Slots** + +| Name | Description | +| -------- | ------------------------------------- | +| `title` | The slot for title | +| `close` | The slot for the close button | +| | The default slot for the main content | +| `footer` | The slot for the footer | + +## CSS Parts + +| Name | Description | +| --------- | ---------------------------------------------- | +| `header` | Styles the header section of the drawer. | +| `content` | Styles the main content section of the drawer. | +| `footer` | Styles the footer section of the drawer. | + +## Usage Examples + +### Basic Usage + +```html + + +
Drawer Title
+ +
Content goes here...
+
Footer content
+
+
+``` diff --git a/packages/web-components/src/drawer-body/define.ts b/packages/web-components/src/drawer-body/define.ts new file mode 100644 index 0000000000000..5972eb0e60cf1 --- /dev/null +++ b/packages/web-components/src/drawer-body/define.ts @@ -0,0 +1,4 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { definition } from './drawer-body.definition.js'; + +definition.define(FluentDesignSystem.registry); diff --git a/packages/web-components/src/drawer-body/drawer-body.definition.ts b/packages/web-components/src/drawer-body/drawer-body.definition.ts new file mode 100644 index 0000000000000..562c0647ad36c --- /dev/null +++ b/packages/web-components/src/drawer-body/drawer-body.definition.ts @@ -0,0 +1,17 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { DrawerBody } from './drawer-body.js'; +import { styles } from './drawer-body.styles.js'; +import { template } from './drawer-body.template.js'; + +/** + * + * @public + * @remarks + * HTML Element: + */ + +export const definition = DrawerBody.compose({ + name: `${FluentDesignSystem.prefix}-drawer-body`, + template, + styles, +}); diff --git a/packages/web-components/src/drawer-body/drawer-body.styles.ts b/packages/web-components/src/drawer-body/drawer-body.styles.ts new file mode 100644 index 0000000000000..173aacab542f3 --- /dev/null +++ b/packages/web-components/src/drawer-body/drawer-body.styles.ts @@ -0,0 +1,31 @@ +import { css } from '@microsoft/fast-element'; +import { display } from '../utils/index.js'; +import { spacingHorizontalM, spacingHorizontalXL } from '../theme/design-tokens.js'; +import { typographySubtitle1Styles } from '../styles/partials/typography.partials.js'; + +/** Drawer styles + * @public + */ +export const styles = css` + ${display('grid')} + :host { + box-sizing: border-box; + grid-template-rows: min-content auto min-content; + position: relative; + height: 100%; + padding: ${spacingHorizontalXL}; + max-height: 100svh; + } + .header { + display: flex; + justify-content: space-between; + align-items: center; + ${typographySubtitle1Styles} + } + + .footer { + display: flex; + justify-content: flex-start; + gap: ${spacingHorizontalM}; + } +`; diff --git a/packages/web-components/src/drawer-body/drawer-body.template.ts b/packages/web-components/src/drawer-body/drawer-body.template.ts new file mode 100644 index 0000000000000..eb515759e7a76 --- /dev/null +++ b/packages/web-components/src/drawer-body/drawer-body.template.ts @@ -0,0 +1,23 @@ +import { ElementViewTemplate, html } from '@microsoft/fast-element'; +import type { DrawerBody } from './drawer-body.js'; + +/** + * The template for the Drawer component. + * @public + */ +export function drawerBodyTemplate(): ElementViewTemplate { + return html` +
+ + +
+
+ +
+ + `; +} + +export const template: ElementViewTemplate = drawerBodyTemplate(); diff --git a/packages/web-components/src/drawer-body/drawer-body.ts b/packages/web-components/src/drawer-body/drawer-body.ts new file mode 100644 index 0000000000000..2bd404d9ac088 --- /dev/null +++ b/packages/web-components/src/drawer-body/drawer-body.ts @@ -0,0 +1,3 @@ +import { FASTElement } from '@microsoft/fast-element'; + +export class DrawerBody extends FASTElement {} diff --git a/packages/web-components/src/drawer-body/index.ts b/packages/web-components/src/drawer-body/index.ts new file mode 100644 index 0000000000000..bb88e71edf28c --- /dev/null +++ b/packages/web-components/src/drawer-body/index.ts @@ -0,0 +1,4 @@ +export * from './drawer-body.js'; +export { definition as DrawerBodyDefinition } from './drawer-body.definition.js'; +export { styles as DrawerBodyStyles } from './drawer-body.styles.js'; +export { template as DrawerBodyTemplate } from './drawer-body.template.js'; diff --git a/packages/web-components/src/drawer/README.md b/packages/web-components/src/drawer/README.md new file mode 100644 index 0000000000000..3b65f3b9fac92 --- /dev/null +++ b/packages/web-components/src/drawer/README.md @@ -0,0 +1,111 @@ +# Drawer + +The `Drawer` component represents a drawer that can be opened and closed, typically used for navigation or additional content. + +## Design Spec + +Link to Drawer Design Spec in Figma: [Link]() + +## Engineering Spec + +The Fluent WC3 Drawer extends `FASTElement` + +### Class `Drawer` + +### Template + +```ts +export function drawerTemplate(): ElementViewTemplate { + return html` + + + + `; +} +``` + +### **Variables** + +| Name | Type | Description | +| ---------------- | ------------------------------- | ---------------------- | +| `DrawerPosition` | `start` `end` | Positions for Drawer | +| `DrawerSize` | `small` `medium` `large` `full` | Sizes for Drawer | +| `DrawerType` | `modal` `non-modal` `inline` | Modal types for Drawer | + +### **Attributes** + +| Name | Privacy | Type | Description | +| ----------------- | ------- | -------------- | ----------------------------------------------------------------------------------------- | +| `type` | public | DrawerType | Determines whether the drawer should be displayed as a modal, non-modal or inline drawer. | +| `position` | public | DrawerPosition | Sets the position of the drawer (start/end). | +| `size` | public | DrawerSize | Sets the size of the drawer (small/medium/large). | +| `ariaDescribedby` | public | string | The ID of the element that describes the drawer. | +| `ariaLabelledby` | public | string | The ID of the element that labels the drawer. | + +### **Events** + +| Name | Type | Description | +| -------------- | ------------- | -------------------------------------------- | +| `toggle` | `CustomEvent` | Fires after the dialog's open state changes | +| `beforetoggle` | `CustomEvent` | Fires before the dialog's open state changes | + +### **Methods** + +| Name | Privacy | Description | +| ------------------ | ------- | --------------------------------------------------------------- | +| `show` | public | Shows the drawer. | +| `hide` | public | Hides the drawer. | +| `emitToggle` | public | Method to emit an event after the dialog's open state changes. | +| `emitBeforeToggle` | public | Method to emit an event before the dialog's open state changes. | +| `clickHandler` | public | Handles click events on the drawer. | +| `keydownHandler` | public | Handles keydown events on the drawer. | + +### **Slots** + +| Name | Description | +| ---- | ------------------------------------- | +| | The default slot for the main content | + +### **CSS Variables** + +| Name | Description | +| ---------------- | ----------------------------------- | +| `--drawer-width` | Used to set the width of the drawer | + +## **Accessiblity** + +### **WAI-ARIA Roles, States, and Properties** + +| Name | Privacy | Type | Description | +| ------------------ | ------- | ------------------- | ---------------------------------------------------------------------------- | +| `role` | public | string | Sets the role of the dialog element to dialog when modal dialog is rendered. | +| `aria-modal` | public | string or undefined | Indicates if the drawer is modal. | +| `aria-describedby` | public | string | The ID of the element that describes the drawer. | +| `aria-labelledby` | public | string | The ID of the element that labels the drawer. | +| `aria-label` | public | string | Provides an accessible name for the drawer when aria-labelledby is not used. | + +### **Fluent Web Component v3 v.s Fluent React 9** + +
+ +**Component and Slot Mapping** + +| Fluent UI React 9 | Fluent Web Components 3 | +| ----------------- | ----------------------- | +| `` | `type="modal"` | +| `` | `type="inline"` | +| ` ` | `` | diff --git a/packages/web-components/src/drawer/define.ts b/packages/web-components/src/drawer/define.ts new file mode 100644 index 0000000000000..d94d257122ee8 --- /dev/null +++ b/packages/web-components/src/drawer/define.ts @@ -0,0 +1,4 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { definition } from './drawer.definition.js'; + +definition.define(FluentDesignSystem.registry); diff --git a/packages/web-components/src/drawer/drawer.definition.ts b/packages/web-components/src/drawer/drawer.definition.ts new file mode 100644 index 0000000000000..8c855559a706e --- /dev/null +++ b/packages/web-components/src/drawer/drawer.definition.ts @@ -0,0 +1,17 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { Drawer } from './drawer.js'; +import { styles } from './drawer.styles.js'; +import { template } from './drawer.template.js'; + +/** + * + * @public + * @remarks + * HTML Element: + */ + +export const definition = Drawer.compose({ + name: `${FluentDesignSystem.prefix}-drawer`, + template, + styles, +}); diff --git a/packages/web-components/src/drawer/drawer.options.ts b/packages/web-components/src/drawer/drawer.options.ts new file mode 100644 index 0000000000000..74e2a9c5f3632 --- /dev/null +++ b/packages/web-components/src/drawer/drawer.options.ts @@ -0,0 +1,46 @@ +import type { ValuesOf } from '../utils/index.js'; + +/** + * A drawer can be positioned on the left or right side of the viewport. + */ +export const DrawerPosition = { + start: 'start', + end: 'end', +} as const; + +/** + * The position of the drawer. + * @public + */ +export type DrawerPosition = ValuesOf; + +/** + * A drawer can be different sizes + */ +export const DrawerSize = { + small: 'small', + medium: 'medium', + large: 'large', + full: 'full', +} as const; + +/** + * The size of the drawer. + * @public + */ +export type DrawerSize = ValuesOf; + +/** + * A drawer can be different sizes + */ +export const DrawerType = { + nonModal: 'non-modal', + modal: 'modal', + inline: 'inline', +} as const; + +/** + * The size of the drawer. + * @public + */ +export type DrawerType = ValuesOf; diff --git a/packages/web-components/src/drawer/drawer.spec.ts b/packages/web-components/src/drawer/drawer.spec.ts new file mode 100644 index 0000000000000..2d167763e7594 --- /dev/null +++ b/packages/web-components/src/drawer/drawer.spec.ts @@ -0,0 +1,207 @@ +import { expect, test } from '@playwright/test'; + +import { fixtureURL } from '../helpers.tests.js'; +import type { Drawer } from './drawer.js'; + +test.describe('Drawer', () => { + test.beforeEach(async ({ page }) => { + await page.goto(fixtureURL('components-drawer--drawer')); + + await page.waitForFunction(() => customElements.whenDefined('fluent-drawer')); + }); + + test('should reflect type attribute', async ({ page }) => { + const element = page.locator('fluent-drawer'); + + await page.setContent(/* html */ ` + Drawer + `); + + await expect(element).toHaveAttribute('type', 'modal'); + await expect(element).toHaveJSProperty('type', 'modal'); + + await element.evaluate((node: Drawer) => { + node.type = 'non-modal'; + }); + + await expect(element).toHaveAttribute('type', 'non-modal'); + }); + + test('should reflect size attribute', async ({ page }) => { + const element = page.locator('fluent-drawer'); + + await page.setContent(/* html */ ` + Drawer + `); + + await expect(element).toHaveAttribute('size', 'small'); + await expect(element).toHaveJSProperty('size', 'small'); + + await element.evaluate((node: Drawer) => { + node.size = 'medium'; + }); + + await expect(element).toHaveAttribute('size', 'medium'); + await expect(element).toHaveJSProperty('size', 'medium'); + + await element.evaluate((node: Drawer) => { + node.size = 'large'; + }); + + await expect(element).toHaveAttribute('size', 'large'); + await expect(element).toHaveJSProperty('size', 'large'); + + await element.evaluate((node: Drawer) => { + node.size = 'full'; + }); + + await expect(element).toHaveAttribute('size', 'full'); + await expect(element).toHaveJSProperty('size', 'full'); + }); + + test('should reflect position attribute', async ({ page }) => { + const element = page.locator('fluent-drawer'); + + await page.setContent(/* html */ ` + Drawer + `); + + await expect(element).toHaveAttribute('position', 'start'); + await expect(element).toHaveJSProperty('position', 'start'); + + await element.evaluate((node: Drawer) => { + node.position = 'end'; + }); + + await expect(element).toHaveAttribute('position', 'end'); + await expect(element).toHaveJSProperty('position', 'end'); + }); + + test('should reflect aria-label attribute', async ({ page }) => { + const element = page.locator('fluent-drawer'); + + await page.setContent(/* html */ ` + Drawer + `); + + await expect(element).toHaveAttribute('aria-label', 'abc'); + await expect(element).toHaveJSProperty('ariaLabel', 'abc'); + + await element.evaluate((node: Drawer) => { + node.ariaLabel = 'def'; + }); + + await expect(element).toHaveAttribute('aria-label', 'def'); + await expect(element).toHaveJSProperty('ariaLabel', 'def'); + }); + + test('should reflect aria-labelledby attribute', async ({ page }) => { + const element = page.locator('fluent-drawer'); + + await page.setContent(/* html */ ` + Drawer + `); + + await expect(element).toHaveAttribute('aria-labelledby', 'abc'); + await expect(element).toHaveJSProperty('ariaLabelledby', 'abc'); + + await element.evaluate((node: Drawer) => { + node.ariaLabelledby = 'def'; + }); + + await expect(element).toHaveAttribute('aria-labelledby', 'def'); + await expect(element).toHaveJSProperty('ariaLabelledby', 'def'); + }); + + test('should reflect aria-describedby attribute', async ({ page }) => { + const element = page.locator('fluent-drawer'); + + await page.setContent(/* html */ ` + Drawer + `); + + await expect(element).toHaveAttribute('aria-describedby', 'abc'); + await expect(element).toHaveJSProperty('ariaDescribedby', 'abc'); + + await element.evaluate((node: Drawer) => { + node.ariaDescribedby = 'def'; + }); + + await expect(element).toHaveAttribute('aria-describedby', 'def'); + await expect(element).toHaveJSProperty('ariaDescribedby', 'def'); + }); + + test('should emit an event when open property changes', async ({ page }) => { + const element = page.locator('fluent-drawer'); + + await page.setContent(/* html */ ` + Drawer + `); + + const [wasOpened] = await Promise.all([ + element.evaluate( + node => + new Promise(resolve => { + node.addEventListener('toggle', () => resolve(true)); + }), + ), + await element.evaluate((node: Drawer) => { + node.show(); + }), + ]); + + expect(wasOpened).toBe(true); + }); + + test('should emit an event before open property changes', async ({ page }) => { + const element = page.locator('fluent-drawer'); + + await page.setContent(/* html */ ` + Drawer + `); + + const [wasOpened] = await Promise.all([ + element.evaluate( + node => + new Promise(resolve => { + node.addEventListener('beforetoggle', () => resolve(true)); + }), + ), + await element.evaluate((node: Drawer) => { + node.show(); + }), + ]); + + expect(wasOpened).toBe(true); + }); + + test('should fire a `cancel` event when keydown is invoked on the document', async ({ page }) => { + const element = page.locator('fluent-drawer'); + + await page.setContent(/* html */ ` + + `); + + await element.evaluate((node: Drawer) => { + node.show(); + }); + + const [wasDismissed] = await Promise.all([ + element.evaluate( + node => + new Promise(resolve => { + node.addEventListener('cancel', () => resolve(true)); + }), + ), + element.evaluate(node => { + node.dispatchEvent( + new Event('cancel', { + key: 'Escape', + } as EventInit), + ); + }), + ]); + + expect(wasDismissed).toBe(true); + }); +}); diff --git a/packages/web-components/src/drawer/drawer.stories.ts b/packages/web-components/src/drawer/drawer.stories.ts new file mode 100644 index 0000000000000..cb9eade68fe5e --- /dev/null +++ b/packages/web-components/src/drawer/drawer.stories.ts @@ -0,0 +1,652 @@ +import { html } from '@microsoft/fast-element'; +import type { Args, Meta } from '@storybook/html'; +import { renderComponent } from '../helpers.stories.js'; +import { RadioGroup } from '../radio-group/radio-group.js'; +import type { Drawer as FluentDrawer } from './drawer.js'; +import { DrawerPosition, DrawerSize, DrawerType } from './drawer.options.js'; +import './define.js'; +import '../drawer-body/define.js'; + +type DrawerStoryArgs = Args & FluentDrawer; +type DrawerStoryMeta = Meta; + +const dismissed20Regular = html``; + +const toggleDrawer = (drawerID: string, containerID?: string) => { + const drawer = document.getElementById(drawerID) as FluentDrawer; + if (containerID) { + const container = document.querySelector(`#${containerID}`); + const drawers = container!.querySelectorAll(`fluent-drawer:not(#${drawerID})`); + drawers.forEach(drawerElement => { + const drawer = drawerElement as FluentDrawer; + if (drawer.dialog.open) { + drawer.hide(); + } + }); + } + if (drawer.dialog.open) { + drawer.hide(); + } else { + drawer.show(); + } +}; + +const toggleSelectedDrawer = (radioGroupId: string) => { + const radioGroup = document.getElementById(radioGroupId) as RadioGroup; + const idSubString = radioGroup.value; + const drawerToOpen = document.querySelector(`#drawer-${idSubString}`) as FluentDrawer; + const drawersToClose = document.querySelectorAll(`fluent-drawer:not(#drawer-${idSubString})`); + if (drawerToOpen.dialog.open) { + drawerToOpen.hide(); + } else { + drawerToOpen.show(); + } + drawersToClose.forEach(d => { + const drawer = d as FluentDrawer; + if (drawer.dialog.open) { + drawer.hide(); + } + }); +}; + +const hideDrawer = (drawerID: string) => { + const drawer = document.getElementById(drawerID) as FluentDrawer; + if (drawer.dialog.open) { + drawer.hide(); + } +}; + +setTimeout(() => { + const input = document.getElementById('custom-size-input') as FluentDrawer; + const drawer = document.getElementById('drawer-width-custom') as FluentDrawer; + input.addEventListener('input', (e: any) => { + drawer.style.setProperty('--drawer-width', `${e.target.value}px`); + }); +}, 2000); + +const storyTemplate = html` +
+ +
+ + + Drawer Header +
+ + The drawer gives users a quick entry point to configuration and information. It should be used when + retaining context is beneficial to users. An overlay is optional depending on whether or not interacting + with the backgroun d content is beneficial to the user's context/scenario. An overlay makes the drawer + blocking and signifies that the users full attention is required when making configurations. + +
+ + Please select an option + Option 1 + Option 2 + Option 3 + +
+
+
+ Close + Do Something +
+
+
+
+

Drawer

+ +

+ The Drawer gives users a quick entry point to configuration and information. It should be used when + retaining context is beneficial to users. +

+
+ + fluent-drawer + + + Position + Start + End + + Toggle Drawer +
+ + + Drawer Header + +
+ + The drawer gives users a quick entry point to configuration and information. It should be used when + retaining context is beneficial to users. An overlay is optional depending on whether or not interacting + with the backgroun d content is beneficial to the user's context/scenario. An overlay makes the drawer + blocking and signifies that the users full attention is required when making configurations. + +
+ + Please select an option + Option 1 + Option 2 + Option 3 + +
+
+
+ Close + Do Something +
+
+
+
+
+`; + +export default { + title: 'Components/Drawer', + args: { + type: DrawerType.modal, + size: DrawerSize.medium, + }, + argTypes: { + position: { + options: Object.values(DrawerPosition), + table: { + type: { + summary: 'Sets the position of drawer', + }, + defaultValue: { + summary: DrawerPosition.start, + }, + }, + }, + type: { + options: Object.values(DrawerType), + control: { + type: 'select', + }, + table: { + type: { + summary: 'Sets the modal type of the drawer', + }, + defaultValue: { + summary: DrawerType.modal, + }, + }, + }, + size: { + options: Object.values(DrawerSize), + control: { + type: 'select', + }, + table: { + type: { + summary: 'Sets the width of drawer', + }, + defaultValue: { + summary: DrawerSize.medium, + }, + }, + }, + }, +} as DrawerStoryMeta; + +export const Drawer = renderComponent(storyTemplate).bind({}); + +export const Modal = renderComponent(html` +
+
+ + + Drawer Header + + ${dismissed20Regular} + +
+ +

+ When a modal dialog is open, the rest of the page is dimmed out and cannot be interacted with. The tab + sequence is kept within the dialog and moving the focus outside the dialog will imply closing it. This + is the default type of the component. +

+
+
+
+ + type="modal" + +
+
+ Close + Do Something +
+
+
+
+
+

Modal

+ +

+ When a modal dialog is open, the rest of the page is dimmed out and cannot be interacted with. The tab + sequence is kept within the dialog and moving the focus outside the dialog will imply closing it. This is the + default type of the component. +

+
+ + type="modal" + + Toggle Drawer +
+
+`); + +export const NonModal = renderComponent(html` +
+
+ + + Non modal + + ${dismissed20Regular} + + +

+ When a non-modal dialog is open, the rest of the page is not dimmed out and users can interact with the + rest of the page. This also implies that the tab focus can move outside the dialog when it reaches the + last focusable element. +

+
+
+ + type="non-modal" + +
+
+
+
+

Non Modal

+ +

+ When a non-modal dialog is open, the rest of the page is not dimmed out and users can interact with the rest + of the page. This also implies that the tab focus can move outside the dialog when it reaches the last + focusable element. +

+
+ + type="non-modal" + + Toggle Drawer +
+
+`); + +export const Inline = renderComponent(html` +
+
+ + + Drawer Inline + + ${dismissed20Regular} + + +

+ An inline Drawer is often used for navigation that is not dismissible. As it is on the same level as the + main surface, users can still interact with other UI elements. This could be useful for swapping between + different items in the main surface. +

+
+
+ + type="inline" + +
+
+
+
+

Inline

+ +

+ An inline Drawer is often used for navigation that is not dismissible. As it is on the same level as the main + surface, users can still interact with other UI elements. This could be useful for swapping between different + items in the main surface. +

+
+ + type="inline" + + Toggle Drawer +
+
+`); + +export const Position = renderComponent(html` +
+
+ + + Drawer position start + + + ${dismissed20Regular} + + +

+ When a Drawer is invoked, it slides in from either the left or right side of the screen. This can be + specified by the position attribute. +

+
+
+ + + default + +
+ Primary + Secondary +
+
+
+
+
+

Position

+ + +

+ When a Drawer is invoked, it slides in from either the left or right side of the screen. This can be specified + by the position attribute. +

+
+
+ + position="start" + + Select a Drawer Position + + Start + End + + Toggle Drawer +
+
+ + + Drawer position end + + ${dismissed20Regular} + + +

+ The drawer component offers flexible positioning options to suit your layout needs. By using the position + attribute, you can easily place the drawer on either side of the screen. The attribute accepts values of + type DrawerPosition, which includes two options: 'start' and 'end'. The default position of the Drawer is + 'end'. +

+
+
+ + position="end" + +
+ Primary + Secondary +
+
+
+
+
+`); + +export const Size = renderComponent(html` +
+
+

Size

+ + +

The size attribute controls the width of the drawer. The default is medium.

+
+ + size="small" + + Select a Drawer Size + + Small + Medium + Large + Full + + Toggle Drawer +
+ + + Drawer small + + ${dismissed20Regular} + + +

The size attribute controls the width of the drawer. The default is medium.

+
+
+ + size="small" + +
+
+ + + Drawer medium + + ${dismissed20Regular} + + +

The size attribute controls the width of the drawer. The default is medium.

+
+
+ + default + +
+
+ + + Drawer large + + ${dismissed20Regular} + +

The size attribute controls the width of the drawer. The default is medium.

+ +
+
+ + size="large" + +
+
+ + + Drawer full + + ${dismissed20Regular} + + +

The size attribute controls the width of the drawer. The default is medium.

+
+
+
+ + size="full" + +
+
+
+`); + +export const CustomSize = renderComponent(html` +
+
+

Custom Size

+ + +

The Drawer can be sized to any custom width, by overriding the drawer-width CSS variable:

+
+ + var(--drawer-width) + + Set a Drawer Size + + Toggle Drawer +
+ + + Custom size + + ${dismissed20Regular} + + +

The Drawer can be sized to any custom width, by overriding the drawer-width CSS variable:

+
+
+
+ + var(--drawer-width) + +
+
+
+`); diff --git a/packages/web-components/src/drawer/drawer.styles.ts b/packages/web-components/src/drawer/drawer.styles.ts new file mode 100644 index 0000000000000..3ee4275c68c38 --- /dev/null +++ b/packages/web-components/src/drawer/drawer.styles.ts @@ -0,0 +1,153 @@ +import { css } from '@microsoft/fast-element'; +import { display } from '../utils/index.js'; +import { + colorBackgroundOverlay, + colorNeutralBackground1, + colorNeutralForeground1, + colorTransparentStroke, + curveAccelerateMid, + curveDecelerateMid, + curveLinear, + durationGentle, + fontFamilyBase, + fontSizeBase300, + fontWeightRegular, + lineHeightBase300, + shadow64, + strokeWidthThin, +} from '../theme/design-tokens.js'; + +/** Drawer styles + * @public + */ +export const styles = css` + ${display('block')} + + :host { + --dialog-backdrop: ${colorBackgroundOverlay}; + } + + :host([type='non-modal']) dialog[open]::backdrop { + display: none; + } + + :host([type='non-modal']) dialog { + position: fixed; + top: 0; + bottom: 0; + } + + :host([type='inline']) { + height: 100%; + width: fit-content; + } + + :host([type='inline']) dialog[open] { + box-shadow: none; + position: relative; + } + + :host([size='small']) dialog { + width: 320px; + max-width: 320px; + } + + :host([size='large']) dialog { + width: 940px; + max-width: 940px; + } + + :host([size='full']) dialog { + width: 100%; + max-width: 100%; + } + + :host([position='end']) dialog { + margin-inline-start: auto; + margin-inline-end: 0; + } + + dialog { + box-sizing: border-box; + z-index: var(--drawer-elevation, 1000); + font-size: ${fontSizeBase300}; + line-height: ${lineHeightBase300}; + font-family: ${fontFamilyBase}; + font-weight: ${fontWeightRegular}; + color: ${colorNeutralForeground1}; + max-width: var(--drawer-width, 592px); + max-height: 100vh; + height: 100%; + margin-inline-start: 0; + margin-inline-end: auto; + border-inline-end-color: ${colorTransparentStroke}; + border-inline-start-color: var(--drawer-separator, ${colorTransparentStroke}); + outline: none; + top: 0; + bottom: 0; + width: var(--drawer-width, 592px); + border-radius: 0; + padding: 0; + max-width: var(--drawer-width, 592px); + box-shadow: ${shadow64}; + border: ${strokeWidthThin} solid ${colorTransparentStroke}; + background: ${colorNeutralBackground1}; + } + + dialog::backdrop { + background: var(--dialog-backdrop); + } + + @layer animations { + /* Disable animations for reduced motion */ + @media (prefers-reduced-motion: no-preference) { + dialog { + transition: display allow-discrete, opacity, overlay allow-discrete, transform; + transition-duration: ${durationGentle}; + transition-timing-function: ${curveDecelerateMid}; + } + + /* Exit styles for dialog */ + :host dialog:not([open]) { + transform: translateX(-100%); + transition-timing-function: ${curveAccelerateMid}; + } + :host([position='end']) dialog:not([open]) { + transform: translateX(100%); + transition-timing-function: ${curveAccelerateMid}; + } + + dialog[open] { + transform: translateX(0); + } + + dialog::backdrop { + transition: display allow-discrete, opacity, overlay allow-discrete, scale; + transition-duration: ${durationGentle}; + transition-timing-function: ${curveDecelerateMid}; + background: var(--dialog-backdrop, ${colorBackgroundOverlay}); + opacity: 0; + } + + dialog[open]::backdrop { + opacity: 1; + } + + dialog::backdrop { + transition-timing-function: ${curveLinear}; + } + } + + @starting-style { + dialog[open] { + transform: translateX(-100%); + } + :host([position='end']) dialog[open] { + transform: translateX(100%); + } + dialog[open]::backdrop { + opacity: 0; + } + } + } +`; diff --git a/packages/web-components/src/drawer/drawer.template.ts b/packages/web-components/src/drawer/drawer.template.ts new file mode 100644 index 0000000000000..b223c8de96911 --- /dev/null +++ b/packages/web-components/src/drawer/drawer.template.ts @@ -0,0 +1,30 @@ +import { ElementViewTemplate, html, ref } from '@microsoft/fast-element'; +import type { Drawer } from './drawer.js'; + +/** + * The template for the Drawer component. + * @public + */ +export function drawerTemplate(): ElementViewTemplate { + return html` + + + + `; +} + +export const template: ElementViewTemplate = drawerTemplate(); diff --git a/packages/web-components/src/drawer/drawer.ts b/packages/web-components/src/drawer/drawer.ts new file mode 100644 index 0000000000000..e6b0332ab9ce7 --- /dev/null +++ b/packages/web-components/src/drawer/drawer.ts @@ -0,0 +1,140 @@ +import { attr, FASTElement, observable, Updates } from '@microsoft/fast-element'; +import { DrawerPosition, DrawerSize, DrawerType } from './drawer.options.js'; + +/** + * A Drawer component that allows content to be displayed in a side panel. It can be rendered as modal or non-modal. + * @extends FASTElement + * + * @attr {DrawerType} type - Determines whether the drawer should be displayed as modal, non-modal, or alert. + * @attr {DrawerPosition} position - Sets the position of the drawer (start/end). + * @attr {DrawerSize} size - Sets the size of the drawer (small/medium/large). + * @attr {string} ariaDescribedby - The ID of the element that describes the drawer. + * @attr {string} ariaLabelledby - The ID of the element that labels the drawer. + * + * @csspart dialog - The dialog element of the drawer. + * + * @slot - Default slot for the content of the drawer. + * + * @fires toggle - Event emitted after the dialog's open state changes. + * @fires beforetoggle - Event emitted before the dialog's open state changes. + * + * @method show - Method to show the drawer. + * @method hide - Method to hide the drawer. + * @method clickHandler - Handles click events on the drawer. + * @method emitToggle - Emits an event after the dialog's open state changes. + * @method emitBeforeToggle - Emits an event before the dialog's open state changes. + * + * @summary A component that provides a drawer for displaying content in a side panel. + * + * @tag fluent-drawer + */ +export class Drawer extends FASTElement { + /** + * @public + * Determines whether the drawer should be displayed as modal or non-modal + * When rendered as a modal, an overlay is applied over the rest of the view. + */ + @attr + public type: DrawerType = DrawerType.modal; + + /** + * @public + * The ID of the element that labels the drawer. + */ + @attr({ attribute: 'aria-labelledby' }) + public ariaLabelledby?: string; + + /** + * @public + * The ID of the element that describes the drawer. + */ + @attr({ attribute: 'aria-describedby' }) + public ariaDescribedby?: string; + + /**"" + * @public + * @defaultValue start + * Sets the position of the drawer (start/end). + */ + @attr + public position: DrawerPosition = DrawerPosition.start; + + /** + * @public + * @defaultValue medium + * Sets the size of the drawer (small/medium/large). + */ + @attr({ attribute: 'size' }) + public size: DrawerSize = DrawerSize.medium; + + /** + * @public + * The dialog element. + */ + @observable + public dialog!: HTMLDialogElement; + + /** + * @public + * Method to emit an event after the dialog's open state changes + * HTML spec proposal: https://github.com/whatwg/html/issues/9733 + */ + public emitToggle = (): void => { + this.$emit('toggle', { + oldState: this.dialog.open ? 'closed' : 'open', + newState: this.dialog.open ? 'open' : 'closed', + }); + }; + + /** + * @public + * Method to emit an event before the dialog's open state changes + * HTML spec proposal: https://github.com/whatwg/html/issues/9733 + */ + public emitBeforeToggle = (): void => { + this.$emit('beforetoggle', { + oldState: this.dialog.open ? 'open' : 'closed', + newState: this.dialog.open ? 'closed' : 'open', + }); + }; + + /** + * @public + * Method to show the drawer + */ + public show(): void { + Updates.enqueue(() => { + this.emitBeforeToggle(); + if (this.type === DrawerType.inline || this.type === DrawerType.nonModal) { + this.dialog.show(); + } else { + this.dialog.showModal(); + } + this.emitToggle(); + }); + } + + /** + * @public + * Method to hide the drawer + */ + public hide(): void { + this.emitBeforeToggle(); + this.dialog.close(); + this.emitToggle(); + } + + /** + * @public + * @param event - The click event + * @returns boolean - Always returns true + * Handles click events on the drawer. + */ + public clickHandler(event: Event): boolean { + event.preventDefault(); + if (this.dialog.open && event.target === this.dialog) { + this.hide(); + } + return true; + } +} diff --git a/packages/web-components/src/drawer/index.ts b/packages/web-components/src/drawer/index.ts new file mode 100644 index 0000000000000..e6a070ceec4e2 --- /dev/null +++ b/packages/web-components/src/drawer/index.ts @@ -0,0 +1,5 @@ +export * from './drawer.js'; +export * from './drawer.options.js'; +export { definition as DrawerDefinition } from './drawer.definition.js'; +export { styles as DrawerStyles } from './drawer.styles.js'; +export { template as DrawerTemplate } from './drawer.template.js'; diff --git a/packages/web-components/src/index-rollup.ts b/packages/web-components/src/index-rollup.ts index a56cc040003ff..da3be8edfef63 100644 --- a/packages/web-components/src/index-rollup.ts +++ b/packages/web-components/src/index-rollup.ts @@ -10,6 +10,7 @@ import './counter-badge/define.js'; import './dialog/define.js'; import './dialog-body/define.js'; import './divider/define.js'; +import './drawer/define.js'; import './field/define.js'; import './image/define.js'; import './label/define.js'; diff --git a/packages/web-components/src/index.ts b/packages/web-components/src/index.ts index 39ed4eb301155..aaf875f21107d 100644 --- a/packages/web-components/src/index.ts +++ b/packages/web-components/src/index.ts @@ -89,6 +89,15 @@ export { DividerStyles, DividerTemplate, } from './divider/index.js'; +export { + Drawer, + DrawerDefinition, + DrawerPosition, + DrawerSize, + DrawerType, + DrawerTemplate, + DrawerStyles, +} from './drawer/index.js'; export { Field, FieldLabelPosition,