From 1c7690c1580c0da7fab346f4c813fdae459a7e83 Mon Sep 17 00:00:00 2001 From: BrdyBrn Date: Wed, 25 Oct 2023 11:43:59 -0700 Subject: [PATCH] Web Component Dialog (#28569) * dialog init * dialog: updates stories, styles, and docs * dialog: updates styles * dialog: removes dead code, cleans up * dialog: updates styles * updates docs * deletes dead code * dialog: addresses PR feedback * reverts dead file * dialog: updates styles per feedback * drawer: updates template, styles * dialog: removes attr changed callback * dialog: updates slot names for react parity, updates docs * yarn change * dialog: addresses feedback * dialog: updates README * dialog: fixes responsive styles * dialog: removes dead styles * dialog: updates readme docs * dialog: reworks dialog to use navtive html dialog element * dialog: fixes jsdoc * dialog: updates slot names for closer parity with fv9, updates stories and docs * dialog: updates README * dialog: updates template class names * dialog: updates backdrop to use fluent token * dialog: adds two column layout story * dialog: adds additional stories * dialog: adds exports to index and package json * dialog: fixes style token imports * dialog: cleans up open state * dialog: updates template slot names * dialog: updates property name * dialog: removes change-focus * dialog: syncs open attr * dialog: sync open attribute * dialog: updates storybook docs * dialog: updates readme docs --- ...-c3eff1b3-5486-4127-9d4b-c9d6d05fdb24.json | 7 + packages/web-components/package.json | 5 + packages/web-components/src/dialog/README.md | 91 +++ packages/web-components/src/dialog/define.ts | 4 + .../src/dialog/dialog.definition.ts | 17 + .../src/dialog/dialog.options.ts | 13 + .../src/dialog/dialog.stories.ts | 636 ++++++++++++++++++ .../src/dialog/dialog.styles.ts | 121 ++++ .../src/dialog/dialog.template.ts | 67 ++ packages/web-components/src/dialog/dialog.ts | 400 +++++++++++ packages/web-components/src/dialog/index.ts | 4 + packages/web-components/src/index.ts | 1 + 12 files changed, 1366 insertions(+) create mode 100644 change/@fluentui-web-components-c3eff1b3-5486-4127-9d4b-c9d6d05fdb24.json create mode 100644 packages/web-components/src/dialog/README.md create mode 100644 packages/web-components/src/dialog/define.ts create mode 100644 packages/web-components/src/dialog/dialog.definition.ts create mode 100644 packages/web-components/src/dialog/dialog.options.ts create mode 100644 packages/web-components/src/dialog/dialog.stories.ts create mode 100644 packages/web-components/src/dialog/dialog.styles.ts create mode 100644 packages/web-components/src/dialog/dialog.template.ts create mode 100644 packages/web-components/src/dialog/dialog.ts create mode 100644 packages/web-components/src/dialog/index.ts diff --git a/change/@fluentui-web-components-c3eff1b3-5486-4127-9d4b-c9d6d05fdb24.json b/change/@fluentui-web-components-c3eff1b3-5486-4127-9d4b-c9d6d05fdb24.json new file mode 100644 index 00000000000000..f15f2159a265d6 --- /dev/null +++ b/change/@fluentui-web-components-c3eff1b3-5486-4127-9d4b-c9d6d05fdb24.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat(dialog): add dialog web component", + "packageName": "@fluentui/web-components", + "email": "brian.christopher.brady@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/web-components/package.json b/packages/web-components/package.json index f7d382b510e9c6..2e09e55b8d6808 100644 --- a/packages/web-components/package.json +++ b/packages/web-components/package.json @@ -59,6 +59,10 @@ "types": "./dist/dts/counter-badge/define.d.ts", "default": "./dist/esm/counter-badge/define.js" }, + "./dialog.js": { + "types": "./dist/dts/dialog/define.d.ts", + "default": "./dist/esm/dialog/define.js" + }, "./divider.js": { "types": "./dist/dts/divider/define.d.ts", "default": "./dist/esm/divider/define.js" @@ -150,6 +154,7 @@ "./dist/esm/checkbox/define.js", "./dist/esm/compound-button/define.js", "./dist/esm/counter-badge/define.js", + "./dist/esm/dialog/define.js", "./dist/esm/divider/define.js", "./dist/esm/image/define.js", "./dist/esm/label/define.js", diff --git a/packages/web-components/src/dialog/README.md b/packages/web-components/src/dialog/README.md new file mode 100644 index 00000000000000..df863504dd81f7 --- /dev/null +++ b/packages/web-components/src/dialog/README.md @@ -0,0 +1,91 @@ +# Dialog + +> Dialog is a window overlaid on either the primary window or another dialog window. Windows under a modal dialog are inert. That is, users cannot interact with content outside an active dialog window. Inert content outside an active dialog is typically visually obscured or dimmed so it is difficult to discern, and in some implementations, attempts to interact with the inert content cause the dialog to close. + +## **Design Spec** + +[Link to Dialog in Figma](https://www.figma.com/file/jtF47yOXDxkI00ZkydE999/Dialog?type=design&node-id=2605%3A15263&mode=dev) + +## **Engineering Spec** + +Fluent WC3 Dialog has feature parity with the Fluent UI React 9 Dialog implementation but not direct parity. + +## Superclass: [FASTElement](https://www.fast.design/docs/fast-element/defining-elements) + +## Class: `Dialog` + +
+ +### **Component Name** + +`` + +### **Basic Implemenation** + +```html + + + Dialog + + + Default Content + + + Do Something + Close + +``` + +### **Attributes** + +| Name | Privacy | Type | Default | Description | +| ------------------ | ------- | ----------------- | ----------------------- | --------------------------------------------------------- | +| `modal-type` | public | `DialogModalType` | `DialogModalType.modal` | Indicates that the type of modal to render. | +| `open` | public | `boolean` | `false` | Controls the open state of the dialog | +| `no-title-action` | public | `boolean` | `false` | Used to set whether the default title action is rendered. | +| `aria-labelledby` | public | `string` | `undefined` | optional based on implementation\*\* | +| `aria-describedby` | public | `string` | `undefined` | optional based on implementation\*\* | +| `aria-label ` | public | `string` | `undefined` | optional based on implementation\*\* | + +\*\* See the [W3C Specification](https://w3c.github.io/aria-practices/#dialog_roles_states_props) for requirements and details. + +
+ +### **Methods** + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------ | ------- | ------------------------------ | ---------- | ------ | -------------- | +| `hide` | public | The method to hide the dialog. | | void | FASTDialog | +| `show` | public | The method to show the dialog. | | void | FASTDialog | + +
+ +### **Slots** + +| Name | Description | +| -------------- | ---------------------------------------------------------- | +| `title` | slot for title content | +| `title-action` | slot for close button | +| | default slot for content rendered between title and footer | +| `action` | slot for actions content | + +### **Events** + +| Name | Description | Details | +| -------------- | --------------------------------------------------------------- | -------------------------------------------------- | +| `onOpenChange` | Event fired when the component transitions from its open state. | `{ open: this.dialog.open, dismissed: dismissed }` | + +## **Preparation** + +### **Fluent Web Component v3 v.s Fluent React 9** + +**Component, Element, and Slot Mapping** + +| Fluent UI React 9 | Fluent Web Components 3 | Description of difference | +| ------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `` | `` | tag name | +| `` | methods: `hide() show()` | In the React version of our components, a "DialogTrigger" component is utilized as part of a composite component of Dialog. The DialogTrigger component provides functionality for toggling the visibility of the Dialog component.
In the Web Component version does not include a dialog trigger. Instead, it expects the user to directly access methods on the Dialog class or utilize CSS to control the visibility of the dialog component. | +| `` | `dialog::backdrop` | In the React version of our components, the DialogSurface component is used as part of the composite Dialog component to represent the dimmed background of the dialog.
The Web Component version utilizes the HTML dialog ::backdrop pseudoelement. | +| `` | `slot: title` | In the React version of our components, the component is used to implement the title of the dialog.
In the Web Component version, the title is provided through the title slot. | +| `` | `slot: title-action` | In the React version of our components, the component the DialogTitles action prop.
In the Web Component version, the title action is provided through the Dialogs title-action slot | +| `` | `slot: action` | In the React version of our components, the component is used to implement the actions within the dialog.
In the Web Component version, actions are passsed through the `action` slot | diff --git a/packages/web-components/src/dialog/define.ts b/packages/web-components/src/dialog/define.ts new file mode 100644 index 00000000000000..55ac9cde847354 --- /dev/null +++ b/packages/web-components/src/dialog/define.ts @@ -0,0 +1,4 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { definition } from './dialog.definition.js'; + +definition.define(FluentDesignSystem.registry); diff --git a/packages/web-components/src/dialog/dialog.definition.ts b/packages/web-components/src/dialog/dialog.definition.ts new file mode 100644 index 00000000000000..103ccc7ab3e0d6 --- /dev/null +++ b/packages/web-components/src/dialog/dialog.definition.ts @@ -0,0 +1,17 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { Dialog } from './dialog.js'; +import { template } from './dialog.template.js'; +import { styles } from './dialog.styles.js'; + +/** + * The Fluent Dialog Element + * + * @public + * @remarks + * HTML Element: \ + */ +export const definition = Dialog.compose({ + name: `${FluentDesignSystem.prefix}-dialog`, + template, + styles, +}); diff --git a/packages/web-components/src/dialog/dialog.options.ts b/packages/web-components/src/dialog/dialog.options.ts new file mode 100644 index 00000000000000..d2e3dc39941b18 --- /dev/null +++ b/packages/web-components/src/dialog/dialog.options.ts @@ -0,0 +1,13 @@ +import type { ValuesOf } from '@microsoft/fast-foundation/utilities.js'; + +/** + * Dialog modal type + * @public + */ +export const DialogModalType = { + modal: 'modal', + nonModal: 'non-modal', + alert: 'alert', +} as const; + +export type DialogModalType = ValuesOf; diff --git a/packages/web-components/src/dialog/dialog.stories.ts b/packages/web-components/src/dialog/dialog.stories.ts new file mode 100644 index 00000000000000..4435b669b5fd80 --- /dev/null +++ b/packages/web-components/src/dialog/dialog.stories.ts @@ -0,0 +1,636 @@ +import { html } from '@microsoft/fast-element'; +import type { Args, Meta } from '@storybook/html'; +import { renderComponent } from '../helpers.stories.js'; +import type { Dialog as FluentDialog } from './dialog.js'; +import './define.js'; +import '../button/define.js'; +import '../text/define.js'; +import { DialogModalType } from './dialog.options.js'; + +type DialogStoryArgs = Args & FluentDialog; +type DialogStoryMeta = Meta; + +const dismissed20Regular = html` + +`; + +const dismissCircle20Regular = html``; + +const closeDialog = (e: Event, id: string, dismissed: boolean = false) => { + const dialog = document.getElementById(id) as FluentDialog; + dialog.hide(dismissed); +}; + +const openDialog = (e: Event, id: string) => { + const dialog = document.getElementById(id) as FluentDialog; + dialog.show(); +}; + +const openDialogControlled = (e: Event, id: string) => { + const dialog = document.getElementById(id) as FluentDialog; + dialog.setAttribute('open', ''); +}; + +const closeDialogControlled = (e: Event, id: string) => { + const dialog = document.getElementById(id) as FluentDialog; + dialog.removeAttribute('open'); +}; + +const dialogTemplate = html` + +
+ openDialog(e, 'dialog-default')}>Open Dialog + + Dialog + +

+ The dialog component is a window overlaid on either the primary window or another dialog window. Windows under + a modal dialog are inert. That is, users cannot interact with content outside an active dialog window. +

+
+
+ fluent-dialog + Close Dialog + Do Something +
+
+`; + +export default { + title: 'Components/Dialog', + args: { + modalType: DialogModalType.modal, + }, + argTypes: { + modalType: { + description: + 'modal: When this type of 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.

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.

alert: A special type of modal dialog that interrupts the users workflow to communicate an important message or ask for a decision. Unlike a typical modal dialog, the user must take an action through the options given to dismiss the dialog, and it cannot be dismissed through the dimmed background.', + table: { + defaultValue: { summary: DialogModalType.modal }, + }, + control: { + type: 'select', + options: Object.values(DialogModalType), + }, + defaultValue: DialogModalType.modal, + }, + noTitleAction: { + description: + 'Used to opt out of rendering the default title action that is rendered when the dialog typeis set to non-modal', + table: { + defaultValue: { summary: false }, + }, + control: { + type: 'boolean', + }, + defaultValue: false, + }, + open: { + description: 'Controls the open state of the dialog', + table: { + defaultValue: { summary: false }, + }, + }, + onOpenChange: { + description: + 'Event fired when the component transitions from its open state.

event: A CustomEvent emitted when the open state changes.

detail: An object containing relevant information, such as the open value and the type of interaction that triggered the event.', + }, + }, +} as DialogStoryMeta; + +export const Default = renderComponent(dialogTemplate).bind({}); + +export const NonModal = renderComponent(html` +
+ + + A non-modal dialog by default presents no backdrop, allowing elements outside of the dialog to be interacted with. + A non-modal dialog will present by default a close button. + + +
+

Note: if an element outside of the dialog is focused then it will not be possible to close the dialog with the Escape key.

+
+ modal-type="non-modal" +
+ + openDialog(e, 'dialog-nonmodal')}>Open Dialog + +
Non-modal
+ +

+ A non-modal dialog by default presents no backdrop, allowing elements outside of the dialog to be interacted with. + + A non-moda dialog will present by default a closeButton. +

+ +

Note: if an element outside of the dialog is focused then it will not be possible to close the dialog with the Escape key.

+ modal-type="non-modal" + Close Dialog + Do Something +
+
+`); + +export const Modal = renderComponent(html` +
+ +

+ A modal is a type of dialog that temporarily halts the main workflow to convey a significant message or require + user interaction. By default, interactions such as clicking outside the dialog or pressing the Escape key will + close the modal-dialog, resuming the user's interaction with the main content. +

+
+
+ modal-type="modal" +
+ openDialog(e, 'dialog-modal')}>Open Dialog + +
Modal
+
+ A modal is a type of dialog that temporarily halts the main workflow to convey a significant message or require + user interaction. By default, interactions such as clicking outside the dialog or pressing the Escape key will + close the modal-dialog, resuming the user's interaction with the main content. +
+
+ modal-type="modal" + Close Dialog + Do Something +
+
+`); + +export const Alert = renderComponent(html` +
+ +

+ An alert is a type of modal-dialog that interrupts the user's workflow to communicate an important message and + acquire a response. By default clicking on backdrop and pressing Escape will not dismiss an alert dialog. +

+
+
+ modal-type="alert" + +
+ openDialog(e, 'dialog-alert')}>Open Dialog + +
Alert
+
+ An alert is a type of modal-dialog that interrupts the user's workflow to communicate an important message and + acquire a response. By default clicking on backdrop and pressing Escape will not dismiss an alert dialog. +
+
+ modal-type="alert" + Close Dialog + Do Something +
+
+`); + +export const Actions = renderComponent(html` +
+

+ A dialog should have no more than + two + actions. +

+ + However, if required, you can populate the action slot with any number of buttons as needed. +
+ slot="action" +
+ openDialog(e, 'dialog-fluidactions')}>Open Dialog + +
Actions
+ + ${dismissed20Regular} + +
+ + A dialog should have no more than + two + footer actions. + +
+ + However, if required, you can populate the action slot with any number of buttons as needed. +
+ slot="action" +
+
+ + Something + Something Else + + Close Dialog + Something Else Entirely +
+
+`); + +export const TitleCustomAction = renderComponent(html` +
+ + By default a non-modal dialog renders a dismiss button with a close icon. + +
+ + + This slot can be customized to add a different kind of action, that it'll be available in any kind of dialog, + ignoring the modalType property, here's an example replacing the simple close icon with a fluent button using a + different icon. + + +
+ slot="title-action" +
+ openDialog(e, 'dialog-titlecustomaction')}>Open Dialog + +
Title Custom Action
+ + ${dismissCircle20Regular} + +
+ + By default a non-modal dialog renders a dismiss button with a close icon. + +
+ + + This slot can be customized to add a different kind of action, that it'll be available in any kind of + dialog, ignoring the modalType property, here's an example replacing the simple close icon with a fluent + button using a different icon. + + +
+ slot="title-action" +
+
+ + Close Dialog + Do Something +
+
+`); + +export const NoTitleAction = renderComponent(html` +
+

The no-title-action attribute can be provided to opt out of rendering any title action.

+
+ no-title-action +
+ openDialog(e, 'dialog-notitleaction')}>Open Dialog + +
No Title Action
+
+ +

The no-title-action attribute can be provided to opt out of rendering any title action.

+
+ no-title-action +
+
+ + Close Dialog + Do Something +
+
+`); + +export const ControlledAndUncontrolled = renderComponent(html` +
+ + + Employ the open attribute to dictate the dialog's visibility state. This method offers a declarative approach, + where the dialog's visibility is determined by the presence or absence of the external open attribute, ensuring + the state is managed outside the component. + + +
+ open +
+ openDialogControlled(e, 'dialog-controlled')}> + Open Controlled Dialog + +
+
+ + + Utilize the show and hide methods to manage the dialog's visibility. This approach allows the dialog to handle + its state internally, giving you a direct and programmatic way to toggle its display without relying on external + attributes. + + +
+ Dialog.show() + Dialog.hide() +
+ openDialog(e, 'dialog-uncontrolled')}>Open Uncontrolled Dialog +
+ +
Controlled Dialog
+ + ${dismissed20Regular} + +
+ + + Employ the open attribute to dictate the dialog's visibility state. This method offers a declarative + approach, where the dialog's visibility is determined by the presence or absence of the external open + attribute, ensuring the state is managed outside the component. + + + open +
+
+ + + Close Controlled Dialog + + Do Something +
+ +
Uncontrolled Dialog
+ + ${dismissed20Regular} + +
+ + + Utilize the show and hide methods to manage the dialog's visibility. This approach + allows the dialog to handle its state internally, giving you a direct and programmatic way to toggle its + display without relying on external attributes. + + +
+ Dialog.show() + Dialog.hide() +
+
+ + + Close Uncontrolled Dialog + + Do Something +
+
+`); + +export const TwoColumnLayout = renderComponent(html` +
+

+ The dialog is designed with flexibility in mind, accommodating multiple column layouts within its structure. +

+
+ openDialog(e, 'dialog-twocolumn')}>Open Dialog + +
Welcome!
+
+

+ The dialog is designed with flexibility in mind, accommodating multiple column layouts within its structure. +

+
+
+
+ + image layout story + +
+
+

Don't have an account? Sign up now!

+
+ + Email + +
+ + Username + +
+ + Password + +
+
+
+ + Cancel + Sign Up +
+
+`); + +export const RTL = renderComponent(html` +
+

+ The dialog component seamlessly supports both Right-to-Left (RTL) and Left-to-Right (LTR) text directions, + ensuring flexibility for various language orientations. +

+
+ openDialog(e, 'dialog-rtl')}>Open Dialog + + +
أهلاً!
+ + ${dismissed20Regular} + +

هذا المكون يدعم كلاً من LTR و RTL.

+ + إلغاء + قم بشيء +
+
+`); + +export const ScrollingLongContent = renderComponent(html` +
+

+ By default content provided in the default slot should grow until it fits viewport size, overflowed content will + be scrollable +

+
+ openDialog(e, 'dialog-longcontent')}>Open Dialog + +
Scrolling Long Content
+ + ${dismissed20Regular} + +

+ By default content provided in the default slot should grow until it fits viewport size, overflowed content + will be scrollable +

+
+
+ +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce nec lectus non lorem iaculis luctus. Proin ac + dolor eget enim commodo pretium. Duis ut nibh ac metus interdum finibus. Integer maximus ante a tincidunt + pretium. Aliquam erat volutpat. Sed nec ante vel lectus dignissim commodo id ut elit. Curabitur ullamcorper + sapien id mauris interdum, ac placerat mi malesuada. Duis aliquam, dolor eget facilisis mollis, ante leo + tincidunt quam, vel convallis ipsum turpis et turpis. Mauris fermentum neque nec tortor semper tempus. Integer + malesuada, nunc ac cursus facilisis, lectus mauris interdum erat, in vulputate risus velit in neque. Etiam + volutpat ante nec fringilla tempus. Quisque et lobortis dolor. Fusce sit amet odio sed ipsum fringilla auctor. + Suspendisse faucibus tellus in luctus hendrerit. Vestibulum euismod velit non laoreet feugiat. Nam sit amet + velit urna. Cras consectetur tempor sem, in suscipit sem ultrices id. Vivamus id felis fringilla, scelerisque + nulla non, aliquam leo. In pharetra mauris ut enim ullamcorper, id suscipit quam ullamcorper. Quisque + tincidunt, felis nec congue elementum, mauris est finibus ex, ut volutpat ante est nec est. Aliquam tempor, + turpis ac scelerisque dignissim, metus velit rutrum sem, eget efficitur mauris erat in metus. Vestibulum in + urna massa. Donec eleifend leo at dui convallis aliquet. Integer eleifend, velit ut consequat tempus, enim + elit ultricies diam, at congue enim enim id nunc. Nullam fringilla bibendum nulla, at lacinia sem bibendum + eget. Nunc posuere ipsum sed enim facilisis efficitur. Pellentesque id semper mi, a feugiat sem. Nunc + interdum, leo ut tincidunt consectetur, nunc mauris accumsan nulla, vel ultricies velit erat nec sapien. + Praesent eleifend ex at odio scelerisque cursus. Morbi eget tellus sed sapien scelerisque cursus at a ante. + Sed venenatis vehicula erat eu feugiat. Ut eu elit vitae urna tincidunt pulvinar nec at nunc. Vestibulum eget + tristique sapien. Sed egestas sapien vel ante viverra pharetra. Cras sit amet felis at nulla tincidunt euismod + vitae et justo. Duis nec rutrum lectus, nec lobortis quam. Pellentesque habitant morbi tristique senectus et + netus et malesuada fames ac turpis egestas. Sed ac ex condimentum, consectetur felis non, maximus odio. Sed + mattis arcu id justo fringilla, a tristique purus vestibulum. Nulla nec fringilla quam. Sed ac elit ac sem + posuere cursus nec vitae mauris. Suspendisse nec pulvinar risus. Sed a tincidunt elit, in gravida tortor. + Quisque sollicitudin lectus vel interdum tempor. Fusce dictum fermentum sem sed suscipit. Vivamus sollicitudin + ex turpis, sit amet consequat leo auctor at. Donec fermentum aliquet lectus, sit amet efficitur nibh + pellentesque et. Curabitur dapibus quam vitae lectus pellentesque, vitae varius massa facilisis. Quisque + consectetur eros a arcu cursus fringilla. Fusce efficitur auctor nibh, nec sollicitudin eros semper eget. Cras + a elit ut tortor semper volutpat eu vel nunc. Duis dapibus quam risus, ac tristique nisl aliquam eu. Curabitur + vel ipsum non nunc euismod fringilla vel a lorem. Curabitur viverra magna ac justo fringilla, eu vestibulum + purus finibus. Donec elementum volutpat libero, in tempus massa convallis vitae. Curabitur vitae mauris id + urna dictum pharetra. Nullam vehicula arcu arcu, vitae elementum enim tincidunt at. Duis eleifend, lorem a + efficitur facilisis, nulla dolor finibus orci, et ullamcorper orci ex ac purus. Aenean sem lectus, malesuada + id magna id, facilisis condimentum nibh. Cras tempor neque mi, sit amet suscipit libero consectetur non. + Nullam id eleifend mauris. Mauris iaculis lectus eu scelerisque efficitur. In id suscipit libero. Donec + condimentum, purus ac laoreet facilisis, risus lorem facilisis neque, id volutpat felis mi eget metus. Nulla + facilisi. Donec consequat tincidunt nunc sed elementum. Integer consectetur tristique orci, ut congue justo + pellentesque eu. Fusce faucibus iaculis mauris, eu lobortis orci egestas eget. Nullam nec arcu bibendum, + cursus diam ac, facilisis enim. Nulla facilisi. Curabitur lacinia odio mauris, a gravida nisi volutpat in. + Aliquam at maximus felis. Vestibulum convallis dignissim urna id gravida. +

+
+ Close Dialog + Do Something +
+
+`); diff --git a/packages/web-components/src/dialog/dialog.styles.ts b/packages/web-components/src/dialog/dialog.styles.ts new file mode 100644 index 00000000000000..5185d9468f0222 --- /dev/null +++ b/packages/web-components/src/dialog/dialog.styles.ts @@ -0,0 +1,121 @@ +import { css } from '@microsoft/fast-element'; +import { display } from '@microsoft/fast-foundation'; +import { + borderRadiusXLarge, + colorBackgroundOverlay, + colorNeutralBackground1, + colorNeutralForeground1, + colorTransparentStroke, + fontFamilyBase, + fontSizeBase300, + fontSizeBase500, + fontWeightRegular, + fontWeightSemibold, + lineHeightBase300, + lineHeightBase500, + shadow64, + spacingHorizontalS, + spacingHorizontalXXL, + spacingVerticalS, + spacingVerticalXXL, + strokeWidthThin, +} from '../theme/design-tokens.js'; + +/** Dialog styles + * @public + */ +export const styles = css` + ${display('flex')} + + :host { + --dialog-backdrop: ${colorBackgroundOverlay}; + } + + dialog { + background: ${colorNeutralBackground1}; + border: ${strokeWidthThin} solid ${colorTransparentStroke}; + z-index: 2; + margin: auto auto; + max-width: 100%; + width: 100vw; + border-radius: ${borderRadiusXLarge}; + box-shadow: ${shadow64}; + max-height: 100vh; + height: fit-content; + overflow: unset; + position: fixed; + inset: 0; + padding: 0; + } + + dialog::backdrop { + background: var(--dialog-backdrop, rgba(0, 0, 0, 0.4)); + } + + .root { + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow: unset; + max-height: calc(100vh - 48px); + padding: ${spacingVerticalXXL} ${spacingHorizontalXXL}; + } + + .title { + font-size: ${fontSizeBase500}; + line-height: ${lineHeightBase500}; + font-weight: ${fontWeightSemibold}; + font-family: ${fontFamilyBase}; + color: ${colorNeutralForeground1}; + margin-bottom: ${spacingVerticalS}; + display: flex; + justify-content: space-between; + align-items: flex-start; + column-gap: 8px; + } + + .content { + vertical-align: top; + min-height: 32px; + color: ${colorNeutralForeground1}; + font-size: ${fontSizeBase300}; + line-height: ${lineHeightBase300}; + font-weight: ${fontWeightRegular}; + font-family: ${fontFamilyBase}; + overflow-y: auto; + box-sizing: border-box; + } + + .actions { + display: flex; + grid-column-start: 1; + flex-direction: column; + max-width: 100vw; + row-gap: ${spacingVerticalS}; + padding-top: ${spacingVerticalXXL}; + justify-self: stretch; + width: 100%; + } + ::slotted([slot='action']) { + width: 100%; + } + + @media screen and (min-width: 480px) { + ::slotted([slot='action']) { + width: fit-content; + } + dialog { + max-width: 600px; + width: 100%; + } + .actions { + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; + column-gap: ${spacingHorizontalS}; + padding-top: ${spacingVerticalS}; + box-sizing: border-box; + } + } +`; diff --git a/packages/web-components/src/dialog/dialog.template.ts b/packages/web-components/src/dialog/dialog.template.ts new file mode 100644 index 00000000000000..fcc54dba0d3c88 --- /dev/null +++ b/packages/web-components/src/dialog/dialog.template.ts @@ -0,0 +1,67 @@ +import { elements, ElementViewTemplate, html, ref, slotted, when } from '@microsoft/fast-element'; +import type { Dialog } from './dialog.js'; +import { DialogModalType } from './dialog.options.js'; + +const dismissed16Regular = html.partial(` + `); + +/** + * Template for the Dialog component + * @public + */ +export const template: ElementViewTemplate = html` + +`; diff --git a/packages/web-components/src/dialog/dialog.ts b/packages/web-components/src/dialog/dialog.ts new file mode 100644 index 00000000000000..4585bc3f28aa6a --- /dev/null +++ b/packages/web-components/src/dialog/dialog.ts @@ -0,0 +1,400 @@ +import { attr, FASTElement, observable, Updates } from '@microsoft/fast-element'; +import { isTabbable } from 'tabbable'; +import { keyEscape, keyTab } from '@microsoft/fast-web-utilities'; +import { Button as FluentButton } from '../button/button.js'; +import { DialogModalType } from './dialog.options.js'; + +/** + * Dialog component that extends the FASTElement class. + * + * @public + * @extends FASTElement + */ +export class Dialog extends FASTElement { + /** + * @private + * Indicates whether focus is being trapped within the dialog + */ + private isTrappingFocus: boolean = false; + + /** + * @public + * Lifecycle method called when the element is connected to the DOM + */ + public connectedCallback(): void { + super.connectedCallback(); + document.addEventListener('keydown', this.handleDocumentKeydown); + Updates.enqueue(() => { + this.updateTrapFocus(); + this.setComponent(); + }); + } + + /** + * @public + * Lifecycle method called when the element is disconnected from the DOM + */ + public disconnectedCallback(): void { + super.disconnectedCallback(); + document.removeEventListener('keydown', this.handleDocumentKeydown); + this.updateTrapFocus(false); + } + + /** + * @public + * The dialog element + */ + @observable + public dialog!: HTMLDialogElement; + + /** + * @public + * The title action elements + */ + @observable + public titleAction: HTMLElement[] = []; + + /** + * @public + * The default title action button + */ + @observable + public defaultTitleAction?: FluentButton; + + /** + * @public + * The ID of the element that describes the dialog + */ + @attr({ attribute: 'aria-describedby' }) + public ariaDescribedby?: string; + + /** + * @public + * The ID of the element that labels the dialog + */ + @attr({ attribute: 'aria-labelledby' }) + public ariaLabelledby?: string; + + /** + * @public + * The type of the dialog modal + */ + @attr({ attribute: 'modal-type' }) + public modalType: DialogModalType = DialogModalType.modal; + + /** + * @public + * Indicates whether the dialog is open + */ + @attr({ mode: 'boolean' }) + public open: boolean = false; + + /** + * @public + * Indicates whether the dialog has a title action + */ + @attr({ mode: 'boolean', attribute: 'no-title-action' }) + public noTitleAction: boolean = false; + + /** + * @private + * Indicates whether focus should be trapped within the dialog + */ + private trapFocus: boolean = false; + + /** + * @public + * Method called when the 'open' attribute changes + */ + public openChanged(oldValue: boolean, newValue: boolean): void { + if (newValue !== oldValue) { + if (newValue && !oldValue) { + this.show(); + } else if (!newValue && oldValue) { + this.hide(); + } + } + } + + /** + * @public + * Method called when the 'modalType' attribute changes + */ + public modalTypeChanged(oldValue: DialogModalType, newValue: DialogModalType): void { + if (newValue !== oldValue) { + if (newValue == DialogModalType.alert || newValue == DialogModalType.modal) { + this.trapFocus = true; + } else { + this.trapFocus = false; + } + } + } + + /** + * @public + * Method to set the component's state based on its attributes + */ + public setComponent(): void { + if (this.modalType == DialogModalType.modal || this.modalType == DialogModalType.alert) { + this.trapFocus = true; + } else { + this.trapFocus = false; + } + } + + /** + * @public + * Method to emit an event when the dialog's open state changes + * @param dismissed - Indicates whether the dialog was dismissed + */ + public onOpenChangeEvent = (dismissed: boolean = false): void => { + this.$emit('onOpenChange', { open: this.dialog.open, dismissed: dismissed }); + }; + + /** + * @public + * Method to show the dialog + */ + public show(): void { + Updates.enqueue(() => { + if (this.modalType === DialogModalType.alert || this.modalType === DialogModalType.modal) { + this.dialog.showModal(); + this.open = true; + this.updateTrapFocus(true); + } else if (this.modalType === DialogModalType.nonModal) { + this.dialog.show(); + this.open = true; + } + this.onOpenChangeEvent(); + }); + } + + /** + * @public + * Method to hide the dialog + * @param dismissed - Indicates whether the dialog was dismissed + */ + public hide(dismissed: boolean = false): void { + this.dialog.close(); + this.open = false; + this.onOpenChangeEvent(dismissed); + } + + /** + * @public + * Method to dismiss the dialog + */ + public dismiss(): void { + if (this.modalType === DialogModalType.alert) { + return; + } + this.hide(true); + } + + /** + * @public + * Handles click events on the dialog + * @param event - The click event + * @returns boolean + */ + public handleClick(event: Event): boolean { + event.preventDefault(); + if (this.dialog.open && this.modalType !== DialogModalType.alert && event.target === this.dialog) { + this.dismiss(); + } + return true; + } + + /** + * @public + * Handles keydown events on the dialog + * @param e - The keydown event + * @returns boolean | void + */ + public handleKeydown = (e: KeyboardEvent): boolean | void => { + if (e.defaultPrevented) { + return; + } + switch (e.key) { + case keyEscape: + if (this.modalType !== DialogModalType.alert) { + this.hide(true); + this.$emit('dismiss'); + } + break; + default: + return true; + } + }; + + /** + * @private + * Handles keydown events on the document + * @param e - The keydown event + */ + private handleDocumentKeydown = (e: KeyboardEvent): void => { + if (!e.defaultPrevented && this.dialog.open) { + switch (e.key) { + case keyTab: + this.handleTabKeyDown(e); + break; + } + } + }; + + /** + * @private + * Handles tab keydown events + * @param e - The keydown event + */ + private handleTabKeyDown = (e: KeyboardEvent): void => { + if (!this.trapFocus || !this.dialog.open) { + return; + } + + const bounds: (HTMLElement | SVGElement)[] = this.getTabQueueBounds(); + + if (bounds.length === 1) { + bounds[0].focus(); + e.preventDefault(); + return; + } + + if (e.shiftKey && e.target === bounds[0]) { + bounds[bounds.length - 1].focus(); + e.preventDefault(); + } else if (!e.shiftKey && e.target === bounds[bounds.length - 1]) { + bounds[0].focus(); + e.preventDefault(); + } + + return; + }; + + /** + * @private + * Gets the bounds of the tab queue + * @returns (HTMLElement | SVGElement)[] + */ + private getTabQueueBounds = (): (HTMLElement | SVGElement)[] => { + const bounds: HTMLElement[] = []; + + return Dialog.reduceTabbableItems(bounds, this); + }; + + /** + * @private + * Focuses the first element in the tab queue + */ + private focusFirstElement = (): void => { + const bounds: (HTMLElement | SVGElement)[] = this.getTabQueueBounds(); + + if (bounds.length > 0) { + bounds[0].focus(); + } else { + if (this.dialog instanceof HTMLElement) { + this.dialog.focus(); + } + } + }; + + /** + * @private + * Determines if focus should be forced + * @param currentFocusElement - The currently focused element + * @returns boolean + */ + private shouldForceFocus = (currentFocusElement: Element | null): boolean => { + return this.isTrappingFocus && !this.contains(currentFocusElement); + }; + + /** + * @private + * Determines if focus should be trapped + * @returns boolean + */ + private shouldTrapFocus = (): boolean => { + return this.trapFocus && this.dialog.open; + }; + + /** + * @private + * Handles focus events on the document + * @param e - The focus event + */ + private handleDocumentFocus = (e: Event): void => { + if (!e.defaultPrevented && this.shouldForceFocus(e.target as HTMLElement)) { + this.focusFirstElement(); + e.preventDefault(); + } + }; + + /** + * @private + * Updates the state of focus trapping + * @param shouldTrapFocusOverride - Optional override for whether focus should be trapped + */ + private updateTrapFocus = (shouldTrapFocusOverride?: boolean): void => { + const shouldTrapFocus = shouldTrapFocusOverride === undefined ? this.shouldTrapFocus() : shouldTrapFocusOverride; + + if (shouldTrapFocus && !this.isTrappingFocus) { + this.isTrappingFocus = true; + // Add an event listener for focusin events if we are trapping focus + document.addEventListener('focusin', this.handleDocumentFocus); + Updates.enqueue(() => { + if (this.shouldForceFocus(document.activeElement)) { + this.focusFirstElement(); + } + }); + } else if (!shouldTrapFocus && this.isTrappingFocus) { + this.isTrappingFocus = false; + // remove event listener if we are not trapping focus + document.removeEventListener('focusin', this.handleDocumentFocus); + } + }; + + /** + * @private + * Reduces the list of tabbable items + * @param elements - The current list of elements + * @param element - The element to consider adding to the list + * @returns HTMLElement[] + */ + private static reduceTabbableItems(elements: HTMLElement[], element: FASTElement): HTMLElement[] { + if (element.getAttribute('tabindex') === '-1') { + return elements; + } + + if (isTabbable(element) || (Dialog.isFocusableFastElement(element) && Dialog.hasTabbableShadow(element))) { + elements.push(element); + return elements; + } + + return Array.from(element.children).reduce( + (elements, currentElement) => Dialog.reduceTabbableItems(elements, currentElement as FASTElement), + elements, + ); + } + + /** + * @private + * Determines if an element is a focusable FASTElement + * @param element - The element to check + * @returns boolean + */ + private static isFocusableFastElement(element: FASTElement): boolean { + return !!element.$fastController?.definition.shadowOptions?.delegatesFocus; + } + + /** + * @private + * Determines if an element has a tabbable shadow + * @param element - The element to check + * @returns boolean + */ + private static hasTabbableShadow(element: FASTElement) { + return Array.from(element.shadowRoot?.querySelectorAll('*') ?? []).some(x => { + return isTabbable(x); + }); + } +} diff --git a/packages/web-components/src/dialog/index.ts b/packages/web-components/src/dialog/index.ts new file mode 100644 index 00000000000000..12f8a223acb9b5 --- /dev/null +++ b/packages/web-components/src/dialog/index.ts @@ -0,0 +1,4 @@ +export * from './dialog.js'; +export { definition as DialogDefinition } from './dialog.definition.js'; +export { template as DialogTemplate } from './dialog.template.js'; +export { styles as DialogStyles } from './dialog.styles.js'; diff --git a/packages/web-components/src/index.ts b/packages/web-components/src/index.ts index 92750616f07850..a5d24a242b792c 100644 --- a/packages/web-components/src/index.ts +++ b/packages/web-components/src/index.ts @@ -7,6 +7,7 @@ export * from './button/index.js'; export * from './checkbox/index.js'; export * from './compound-button/index.js'; export * from './counter-badge/index.js'; +export * from './dialog/index.js'; export * from './divider/index.js'; export * from './image/index.js'; export * from './label/index.js';