diff --git a/change/@fluentui-web-components-be24e8ac-7ef6-4b5c-b242-0636137aaec1.json b/change/@fluentui-web-components-be24e8ac-7ef6-4b5c-b242-0636137aaec1.json new file mode 100644 index 00000000000000..f301293043c510 --- /dev/null +++ b/change/@fluentui-web-components-be24e8ac-7ef6-4b5c-b242-0636137aaec1.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Refactor fluent-dialog and add fluent-dialog-body", + "packageName": "@fluentui/web-components", + "email": "rupertdavid@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/web-components/package.json b/packages/web-components/package.json index 91fae3a2c91c19..15db2cb167a4dc 100644 --- a/packages/web-components/package.json +++ b/packages/web-components/package.json @@ -63,6 +63,10 @@ "types": "./dist/dts/dialog/define.d.ts", "default": "./dist/esm/dialog/define.js" }, + "./dialog-body.js": { + "types": "./dist/dts/dialog-body/define.d.ts", + "default": "./dist/esm/dialog-body/define.js" + }, "./divider.js": { "types": "./dist/dts/divider/define.d.ts", "default": "./dist/esm/divider/define.js" @@ -156,6 +160,7 @@ "./dist/esm/compound-button/define.js", "./dist/esm/counter-badge/define.js", "./dist/esm/dialog/define.js", + "./dist/esm/dialog-body/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-body/README.md b/packages/web-components/src/dialog-body/README.md new file mode 100644 index 00000000000000..bd54dd85ae20a4 --- /dev/null +++ b/packages/web-components/src/dialog-body/README.md @@ -0,0 +1,8 @@ +### **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 | diff --git a/packages/web-components/src/dialog-body/define.ts b/packages/web-components/src/dialog-body/define.ts new file mode 100644 index 00000000000000..48cf2bf21ded93 --- /dev/null +++ b/packages/web-components/src/dialog-body/define.ts @@ -0,0 +1,4 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { definition } from './dialog-body.definition.js'; + +definition.define(FluentDesignSystem.registry); diff --git a/packages/web-components/src/dialog-body/dialog-body.bench.ts b/packages/web-components/src/dialog-body/dialog-body.bench.ts new file mode 100644 index 00000000000000..9afd3115ecbe45 --- /dev/null +++ b/packages/web-components/src/dialog-body/dialog-body.bench.ts @@ -0,0 +1,14 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { definition } from './dialog-body.definition.js'; + +definition.define(FluentDesignSystem.registry); + +const itemRenderer = () => { + const dialogBody = document.createElement('fluent-dialog-body'); + dialogBody.appendChild(document.createTextNode('DialogBody')); + + return dialogBody; +}; + +export default itemRenderer; +export { tests } from '../utils/benchmark-wrapper.js'; diff --git a/packages/web-components/src/dialog-body/dialog-body.definition.ts b/packages/web-components/src/dialog-body/dialog-body.definition.ts new file mode 100644 index 00000000000000..9a80d654099c38 --- /dev/null +++ b/packages/web-components/src/dialog-body/dialog-body.definition.ts @@ -0,0 +1,17 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { DialogBody } from './dialog-body.js'; +import { template } from './dialog-body.template.js'; +import { styles } from './dialog-body.styles.js'; + +/** + * The Fluent Dialog Body Element + * + * @public + * @remarks + * HTML Element: \ + */ +export const definition = DialogBody.compose({ + name: `${FluentDesignSystem.prefix}-dialog-body`, + template, + styles, +}); diff --git a/packages/web-components/src/dialog-body/dialog-body.spec.ts b/packages/web-components/src/dialog-body/dialog-body.spec.ts new file mode 100644 index 00000000000000..45b34a493b993b --- /dev/null +++ b/packages/web-components/src/dialog-body/dialog-body.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test'; +import { fixtureURL } from '../helpers.tests.js'; +import type { Dialog } from '../dialog/dialog.js'; +import type { DialogBody } from './dialog-body.js'; + +test.describe('Dialog Body', () => { + test.beforeEach(async ({ page }) => { + await page.goto(fixtureURL('components-dialog-dialog-body--default')); + + await page.waitForFunction(() => + Promise.all([ + customElements.whenDefined('fluent-button'), + customElements.whenDefined('fluent-dialog'), + customElements.whenDefined('fluent-dialog-body'), + ]), + ); + }); + + test('should render a dialog body', async ({ page }) => { + const element = page.locator('fluent-dialog-body'); + const closeButton = element.locator('.title-action'); + + await page.setContent(/* html */ ` + +
content
+
+ `); + + await expect(element).toBeVisible(); + await expect(closeButton).toBeHidden(); + }); + + test('should add default close button for non-modal dialogs', async ({ page }) => { + const element = page.locator('fluent-dialog-body'); + const closeButton = element.locator('.title-action'); + const dialog = page.locator('fluent-dialog'); + const content = element.locator('#content'); + + await page.setContent(/* html */ ` + + +
content
+
+
+ `); + + await test.step('should show the default close button in title for non-modal dialog', async () => { + await expect(content).toBeHidden(); + + dialog.evaluate((node: Dialog) => { + node.show(); + }); + + await expect(content).toBeVisible(); + + // Shows the close button in title in non-modal dialog + await expect(closeButton).toBeVisible(); + }); + + await test.step('should hide the close button when noTitleAction is set', async () => { + await element.evaluate((node: DialogBody) => { + node.noTitleAction = true; + }); + + await expect(closeButton).toBeHidden(); + }); + }); +}); diff --git a/packages/web-components/src/dialog-body/dialog-body.stories.ts b/packages/web-components/src/dialog-body/dialog-body.stories.ts new file mode 100644 index 00000000000000..3ffcab86a30f41 --- /dev/null +++ b/packages/web-components/src/dialog-body/dialog-body.stories.ts @@ -0,0 +1,172 @@ +import { html } from '@microsoft/fast-element'; +import type { Args } from '@storybook/html'; +import { renderComponent } from '../helpers.stories.js'; +import { Dialog as FluentDialog } from '../dialog/dialog.js'; +import type { DialogBody as FluentDialogBody } from './dialog-body.js'; +import './define.js'; +import '../button/define.js'; +import '../text/define.js'; + +type DialogStoryArgs = Args & FluentDialogBody; + +const dismissed20Regular = html` + +`; + +const dismissCircle20Regular = html``; + +const dialogTemplate = html` + +
This is a Dialog title
+ +

+ 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 + + +`; + +export default { + title: 'Components/Dialog/Dialog Body', + argTypes: { + 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, + }, + titleAction: { + description: + 'Slot for the title action elements (e.g. Close button). When the dialog type is set to non-modal and no title action is provided, a default title action button is rendered.', + }, + defaultTitleAction: { + description: 'The default title action button', + }, + }, +}; + +export const Default = renderComponent(dialogTemplate).bind({}); + +export const Basic = renderComponent(html` + +
Basic
+ +

+ 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" + Close Dialog + Call to Action +
+`); + +export const Actions = renderComponent(html` + +
Actions
+ ${dismissed20Regular} +
+ +

+ A dialog body 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 NoTitleAction = renderComponent(html` + +
No Title Action
+ +

Removing the title action will prevent the default close button from being rendered in a non-modal dialog.

+
+
+ no-title-action +
+`); + +export const CustomTitleAction = renderComponent(html` + +
Custom Title Action
+ + ${dismissCircle20Regular} + + +

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

+
+ slot="title-action" + Close Dialog +
+`); + +export const NoTitleAndNoAction = renderComponent(html` + + +

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

+
+
+ no-title-action +
+`); diff --git a/packages/web-components/src/dialog-body/dialog-body.styles.ts b/packages/web-components/src/dialog-body/dialog-body.styles.ts new file mode 100644 index 00000000000000..b706c2bf00eb1d --- /dev/null +++ b/packages/web-components/src/dialog-body/dialog-body.styles.ts @@ -0,0 +1,93 @@ +import { css } from '@microsoft/fast-element'; +import { display } from '../utils/index.js'; +import { + colorNeutralBackground1, + colorNeutralForeground1, + fontFamilyBase, + fontSizeBase300, + fontSizeBase500, + fontWeightRegular, + fontWeightSemibold, + lineHeightBase300, + lineHeightBase500, + spacingHorizontalXXL, + spacingVerticalL, + spacingVerticalS, + spacingVerticalXXL, +} from '../theme/design-tokens.js'; + +/** Dialog Body styles + * @public + */ +export const styles = css` + ${display('grid')} + + :host { + background: ${colorNeutralBackground1}; + box-sizing: border-box; + gap: ${spacingVerticalS}; + padding: ${spacingVerticalXXL} ${spacingHorizontalXXL}; + container: dialog-body / inline-size; + } + + .title { + box-sizing: border-box; + align-items: flex-start; + background: ${colorNeutralBackground1}; + color: ${colorNeutralForeground1}; + column-gap: 8px; + display: flex; + font-family: ${fontFamilyBase}; + font-size: ${fontSizeBase500}; + font-weight: ${fontWeightSemibold}; + inset-block-start: 0; + justify-content: space-between; + line-height: ${lineHeightBase500}; + margin-block-end: calc(${spacingVerticalS} * -1); + margin-block-start: calc(${spacingVerticalXXL} * -1); + padding-block-end: ${spacingVerticalS}; + padding-block-start: ${spacingVerticalXXL}; + position: sticky; + z-index: 1; + } + + .content { + box-sizing: border-box; + color: ${colorNeutralForeground1}; + font-family: ${fontFamilyBase}; + font-size: ${fontSizeBase300}; + font-weight: ${fontWeightRegular}; + line-height: ${lineHeightBase300}; + min-height: 32px; + } + + .actions { + box-sizing: border-box; + background: ${colorNeutralBackground1}; + display: flex; + flex-direction: column; + gap: ${spacingVerticalS}; + inset-block-end: 0; + margin-block-end: calc(${spacingVerticalXXL} * -1); + padding-block-end: ${spacingVerticalXXL}; + padding-block-start: ${spacingVerticalL}; + position: sticky; + z-index: 2; + } + + /* Hide slots if nothing is slotted to remove grid gap */ + :not(:has(:is([slot='title'], [slot='title-action']))) .title:not(:has(.title-action)), + :not(:has([slot='action'])) .actions { + display: none; + } + + @container (min-width: 480px) { + .actions { + align-items: center; + flex-direction: row; + justify-content: flex-end; + margin-block-start: calc(${spacingVerticalS} * -1); + padding-block-start: ${spacingVerticalS}; + } + } +`; diff --git a/packages/web-components/src/dialog-body/dialog-body.template.ts b/packages/web-components/src/dialog-body/dialog-body.template.ts new file mode 100644 index 00000000000000..ed8657f347c69b --- /dev/null +++ b/packages/web-components/src/dialog-body/dialog-body.template.ts @@ -0,0 +1,43 @@ +import { ElementViewTemplate, html, ref } from '@microsoft/fast-element'; +import { DialogType } from '../dialog/dialog.options.js'; + +const dismissed16Regular = html.partial(` + `); + +/** + * Template for the dialog form + * @public + */ +export const template: ElementViewTemplate = html` +
+ + + x.noTitleAction || x.parentNode?.type !== DialogType.nonModal} + tabindex="0" + part="title-action" + class="title-action" + appearance="transparent" + icon-only + @click=${x => x.parentNode?.hide()} + ${ref('defaultTitleAction')} + > + ${dismissed16Regular} + + +
+
+
+`; diff --git a/packages/web-components/src/dialog-body/dialog-body.ts b/packages/web-components/src/dialog-body/dialog-body.ts new file mode 100644 index 00000000000000..eeb70e03c5eae8 --- /dev/null +++ b/packages/web-components/src/dialog-body/dialog-body.ts @@ -0,0 +1,15 @@ +import { attr, FASTElement } from '@microsoft/fast-element'; +/** + * Dialog Body component that extends the FASTElement class. + * + * @public + * @extends FASTElement + */ +export class DialogBody extends FASTElement { + /** + * @public + * Indicates whether the dialog has a title action + */ + @attr({ mode: 'boolean', attribute: 'no-title-action' }) + public noTitleAction: boolean = false; +} diff --git a/packages/web-components/src/dialog-body/index.ts b/packages/web-components/src/dialog-body/index.ts new file mode 100644 index 00000000000000..3966f9046e006f --- /dev/null +++ b/packages/web-components/src/dialog-body/index.ts @@ -0,0 +1,4 @@ +export { DialogBody } from './dialog-body.js'; +export { definition as DialogBodyDefinition } from './dialog-body.definition.js'; +export { template as DialogBodyTemplate } from './dialog-body.template.js'; +export { styles as DialogBodyStyles } from './dialog-body.styles.js'; diff --git a/packages/web-components/src/dialog/README.md b/packages/web-components/src/dialog/README.md index df863504dd81f7..ac01bdffb911e4 100644 --- a/packages/web-components/src/dialog/README.md +++ b/packages/web-components/src/dialog/README.md @@ -23,29 +23,31 @@ Fluent WC3 Dialog has feature parity with the Fluent UI React 9 Dialog implement ### **Basic Implemenation** ```html - - - Dialog - - - Default Content - - - Do Something - Close + + + + 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\*\* | +| Name | Privacy | Type | Default | Description | +| ------------------ | ------- | ------------ | ------------------ | --------------------------------------------------------- | +| `type` | public | `DialogType` | `DialogType.modal` | Indicates that the type of modal to render. | +| `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. @@ -62,12 +64,9 @@ Fluent WC3 Dialog has feature parity with the Fluent UI React 9 Dialog implement ### **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 | +| Name | Description | +| ---- | ----------------------------------------------------------- | +| | default slot for content rendered inside the dialog element | ### **Events** diff --git a/packages/web-components/src/dialog/dialog.options.ts b/packages/web-components/src/dialog/dialog.options.ts index d6020b649da935..a3bf926348bd4a 100644 --- a/packages/web-components/src/dialog/dialog.options.ts +++ b/packages/web-components/src/dialog/dialog.options.ts @@ -4,10 +4,10 @@ import type { ValuesOf } from '../utils/index.js'; * Dialog modal type * @public */ -export const DialogModalType = { +export const DialogType = { modal: 'modal', nonModal: 'non-modal', alert: 'alert', } as const; -export type DialogModalType = ValuesOf; +export type DialogType = ValuesOf; diff --git a/packages/web-components/src/dialog/dialog.spec.ts b/packages/web-components/src/dialog/dialog.spec.ts new file mode 100644 index 00000000000000..15d2ec0ae824df --- /dev/null +++ b/packages/web-components/src/dialog/dialog.spec.ts @@ -0,0 +1,239 @@ +import { expect, test } from '@playwright/test'; +import type { Locator } from '@playwright/test'; +import { fixtureURL } from '../helpers.tests.js'; +import { Dialog } from './dialog.js'; + +async function getPointOutside(element: Locator) { + // Get the bounding box of the element + const boundingBox = (await element.boundingBox()) as { x: number; y: number; width: number; height: number }; + + // Calculate a point outside the bounding box + return { + x: boundingBox.x + boundingBox.width + 10, // 10 pixels to the right + y: boundingBox.y + boundingBox.height + 10, // 10 pixels below + }; +} + +test.describe('Dialog', () => { + test.beforeEach(async ({ page }) => { + await page.goto(fixtureURL('components-dialog-dialog--default')); + + await page.waitForFunction(() => + Promise.all([ + customElements.whenDefined('fluent-button'), + customElements.whenDefined('fluent-dialog'), + customElements.whenDefined('fluent-dialog-body'), + ]), + ); + }); + + test('should open and close dialog programmatically', async ({ page }) => { + const element = page.locator('fluent-dialog'); + const content = element.locator('#content'); + + await page.setContent(/* html */ ` + +
content
+
+ `); + + await test.step('should show the dialog content when the dialog is shown', async () => { + await expect(content).toBeHidden(); + + await element.evaluate((node: Dialog) => { + node.show(); + }); + + await expect(content).toBeVisible(); + }); + + await test.step('should hide the dialog content when the dialog is hidden', async () => { + await element.evaluate((node: Dialog) => { + node.hide(); + }); + + await expect(content).toBeHidden(); + }); + }); + + test('should handle dialog overlay clicks correctly based on type', async ({ page }) => { + const element = page.locator('fluent-dialog'); + const content = element.locator('#content'); + + await page.setContent(/* html */ ` + +
content
+
+ `); + + await test.step('should close modal dialog when clicking outside', async () => { + await element.evaluate((node: Dialog) => { + node.show(); + }); + + await expect(content).toBeVisible(); + + // Get point outside the element + const { x, y } = await getPointOutside(element); + + // Dispatch a click event at the calculated point + await page.mouse.click(x, y); + + await expect(content).toBeHidden(); + }); + + await test.step('should not close non-modal dialog when clicking outside', async () => { + await element.evaluate((node: Dialog) => { + node.setAttribute('type', 'non-modal'); + node.show(); + }); + + await expect(content).toBeVisible(); + + // Get point outside the element + const { x, y } = await getPointOutside(element); + + // Dispatch a click event at the calculated point + await page.mouse.click(x, y); + + await expect(content).toBeVisible(); + }); + + await test.step('should not close alert dialog when clicking outside', async () => { + await element.evaluate((node: Dialog) => { + node.setAttribute('type', 'alert'); + node.show(); + }); + + await expect(content).toBeVisible(); + + // Get point outside the element + const { x, y } = await getPointOutside(element); + + // Dispatch a click event at the calculated point + await page.mouse.click(x, y); + + await expect(content).toBeVisible(); + }); + }); + + test('should handle escape keypress correctly based on type', async ({ page }) => { + const element = page.locator('fluent-dialog'); + const content = element.locator('#content'); + + await page.setContent(/* html */ ` + +
content
+
+ `); + + await test.step('should close modal dialog when pressing escape', async () => { + await element.evaluate((node: Dialog) => { + node.show(); + }); + + await expect(content).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(content).toBeHidden(); + }); + + await test.step('should not close non-modal dialog when pressing escape', async () => { + await element.evaluate((node: Dialog) => { + node.setAttribute('type', 'non-modal'); + node.show(); + }); + + await expect(content).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(content).toBeVisible(); + }); + + await test.step('should not close alert dialog when pressing escape', async () => { + await element.evaluate((node: Dialog) => { + node.setAttribute('type', 'alert'); + node.show(); + }); + + await expect(content).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(content).toBeVisible(); + }); + }); + + test('should apply ARIA attributes correctly to dialog element based on type', async ({ page }) => { + const element = page.locator('fluent-dialog'); + const dialog = element.locator('dialog'); + + await page.setContent(/* html */ ` + +
content
+
+ `); + + await test.step('should set role correctly on the dialog element', async () => { + await element.evaluate((node: Dialog) => { + node.setAttribute('type', 'dialog'); + }); + + await expect(dialog).toHaveRole('dialog'); + + await element.evaluate((node: Dialog) => { + node.setAttribute('type', 'non-modal'); + }); + + await expect(dialog).toHaveRole('dialog'); + + await element.evaluate((node: Dialog) => { + node.setAttribute('type', 'alert'); + }); + + await expect(dialog).toHaveRole('alertdialog'); + }); + + await test.step('should set aria-modal correctly on the dialog element', async () => { + await element.evaluate((node: Dialog) => { + node.setAttribute('type', 'modal'); + }); + + await expect(dialog).toHaveAttribute('aria-modal'); + + await element.evaluate((node: Dialog) => { + node.setAttribute('type', 'alert'); + }); + + await expect(dialog).toHaveAttribute('aria-modal'); + + await element.evaluate((node: Dialog) => { + node.setAttribute('type', 'non-modal'); + }); + + await expect(dialog).not.toHaveAttribute('aria-modal'); + }); + + await test.step('should set aria-labelledby on the dialog element', async () => { + await expect(dialog).not.toHaveAttribute('aria-labelledby'); + + await element.evaluate((node: Dialog) => { + node.setAttribute('aria-labelledby', 'label'); + }); + + await expect(dialog).toHaveAttribute('aria-labelledby', 'label'); + }); + + await test.step('should set aria-describedby on the dialog element', async () => { + await expect(dialog).not.toHaveAttribute('aria-describedby'); + + await element.evaluate((node: Dialog) => { + node.setAttribute('aria-describedby', 'elementID'); + }); + + await expect(dialog).toHaveAttribute('aria-describedby', 'elementID'); + }); + }); +}); diff --git a/packages/web-components/src/dialog/dialog.stories.ts b/packages/web-components/src/dialog/dialog.stories.ts index 4435b669b5fd80..328dbb86f09222 100644 --- a/packages/web-components/src/dialog/dialog.stories.ts +++ b/packages/web-components/src/dialog/dialog.stories.ts @@ -5,7 +5,7 @@ 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'; +import { DialogType } from './dialog.options.js'; type DialogStoryArgs = Args & FluentDialog; type DialogStoryMeta = Meta; @@ -42,7 +42,7 @@ const dismissCircle20Regular = html` { const dialog = document.getElementById(id) as FluentDialog; - dialog.hide(dismissed); + dialog.hide(); }; const openDialog = (e: Event, id: string) => { @@ -50,16 +50,6 @@ const openDialog = (e: Event, id: string) => { 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 + + + 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', + title: 'Components/Dialog/Dialog', args: { - modalType: DialogModalType.modal, + type: DialogType.modal, }, argTypes: { - modalType: { + type: { 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 }, + defaultValue: { summary: DialogType.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', + options: Object.values(DialogType), }, - defaultValue: false, + defaultValue: DialogType.modal, }, - open: { - description: 'Controls the open state of the dialog', - table: { - defaultValue: { summary: false }, - }, + beforetoggle: { + description: `A CustomEvent emitted before the open state changes.

detail: An object containing the oldState and newState of the dialog with the string value of either 'open' or 'closed''`, }, - 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.', + toggle: { + description: `A CustomEvent emitted after the open state changes.

detail: An object containing the oldState and newState of the dialog with the string value of either 'open' or 'closed''`, }, }, } as DialogStoryMeta; export const Default = renderComponent(dialogTemplate).bind({}); +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. +

+
+
+ 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. +
+
+ type="modal" + Close Dialog + Do Something +
+
+
+`); + export const NonModal = renderComponent(html`
@@ -143,93 +152,70 @@ export const NonModal = renderComponent(html`
-

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

+

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" + 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 -
-
-`); + + +
Non-modal
+ +

+ A non-modal dialog by default presents no backdrop, allowing elements outside of the dialog to be interacted with. -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 + A non-modal 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.

+ + type="non-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" + 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 + + +
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. +
+
+ type="alert" + Close Dialog + Do Something +
`); export const Actions = renderComponent(html`
-

A dialog should have no more than two @@ -246,16 +232,17 @@ export const Actions = renderComponent(html`
openDialog(e, 'dialog-fluidactions')}>Open Dialog -

Actions
- - ${dismissed20Regular} - -
+ +
Actions
+ + ${dismissed20Regular} + + A dialog should have no more than @@ -271,20 +258,19 @@ export const Actions = renderComponent(html` >
slot="action" -
-
- - Something - Something Else - - Close Dialog - Something Else Entirely + + Something + Something Else + + Close Dialog + Something Else Entirely +
`); @@ -298,7 +284,7 @@ export const TitleCustomAction = renderComponent(html` 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 + ignoring the type property, here's an example replacing the simple close icon with a fluent button using a different icon. @@ -307,16 +293,17 @@ export const TitleCustomAction = renderComponent(html`
openDialog(e, 'dialog-titlecustomaction')}>Open Dialog -
Title Custom Action
- - ${dismissCircle20Regular} - -
+ +
Title Custom Action
+ + ${dismissCircle20Regular} + + By default a non-modal dialog renders a dismiss button with a close icon. @@ -324,209 +311,183 @@ export const TitleCustomAction = renderComponent(html` 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. + dialog, ignoring the type 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 + + 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.

+ The no-title-action attribute can be added to fluent-dialog-body to opt out of rendering any title + action. +


no-title-action
openDialog(e, 'dialog-notitleaction')}>Open Dialog - -
No Title Action
-
- + + +
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 + + Close Dialog + Do Something +
`); -export const ControlledAndUncontrolled = renderComponent(html` +export const TwoColumnLayout = 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() +

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


- openDialog(e, 'dialog-uncontrolled')}>Open Uncontrolled Dialog + 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 ImageAndText = renderComponent(html` +
+

+ You can supply an image outside of the dialog body to create a more visually engaging dialog experience. +


- -
Controlled Dialog
- - ${dismissed20Regular} - -
+ openDialog(e, 'dialog-image')}>Open Dialog + + + image layout story + + +
Welcome!
+ - 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. + This slot can be customized to add a different kind of action, that it'll be available in any kind of + dialog, ignoring the type property, here's an example replacing the simple close icon with a fluent button + using a different icon. - open -
-
- - - Close Controlled Dialog - - Do Something + + Cancel + Sign Up +
- -
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 ModalWithNoTitleOrActions = renderComponent(html` +
+

A dialog without a title or actions will render a close button by default.

+
+ openDialog(e, 'dialog-notitleactions')}>Open Dialog + + +

A dialog without a title or actions will render a close button by default.

+
`); -export const TwoColumnLayout = renderComponent(html` +export const NonModalWithNoTitleOrActions = renderComponent(html`
-

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

A non-modal dialog without a title or actions will render a close button by default.


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

- The dialog is designed with flexibility in mind, accommodating multiple column layouts within its structure. + A non-modal dialog without no title or title actions will render a close button by default.

-
-
-
- - 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. @@ -535,27 +496,31 @@ export const RTL = renderComponent(html`
openDialog(e, 'dialog-rtl')}>Open Dialog - -

أهلاً!
- - ${dismissed20Regular} - -

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

- - إلغاء - قم بشيء + + +
أهلاً!
+ + ${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 @@ -564,73 +529,79 @@ export const ScrollingLongContent = renderComponent(html`
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 + + image layout story + + +
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 index a2165df424447d..27ca2fbf82128d 100644 --- a/packages/web-components/src/dialog/dialog.styles.ts +++ b/packages/web-components/src/dialog/dialog.styles.ts @@ -1,23 +1,15 @@ import { css } from '@microsoft/fast-element'; -import { display } from '../utils/index.js'; import { borderRadiusXLarge, colorBackgroundOverlay, colorNeutralBackground1, colorNeutralForeground1, colorTransparentStroke, - fontFamilyBase, - fontSizeBase300, - fontSizeBase500, - fontWeightRegular, - fontWeightSemibold, - lineHeightBase300, - lineHeightBase500, + curveAccelerateMid, + curveDecelerateMid, + curveLinear, + durationGentle, shadow64, - spacingHorizontalS, - spacingHorizontalXXL, - spacingVerticalS, - spacingVerticalXXL, strokeWidthThin, } from '../theme/design-tokens.js'; @@ -25,97 +17,75 @@ import { * @public */ export const styles = css` - ${display('flex')} + @layer base { + :host { + --dialog-backdrop: ${colorBackgroundOverlay}; + --dialog-starting-scale: 0.85; + } - :host { - --dialog-backdrop: ${colorBackgroundOverlay}; - } + ::backdrop { + background: var(--dialog-backdrop, rgba(0, 0, 0, 0.4)); + } - 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 { + background: ${colorNeutralBackground1}; + border-radius: ${borderRadiusXLarge}; + border: ${strokeWidthThin} solid ${colorTransparentStroke}; + box-shadow: ${shadow64}; + color: ${colorNeutralForeground1}; + max-height: calc(-48px + 100vh); + padding: 0; + width: 100%; + max-width: 600px; + } - dialog::backdrop { - background: var(--dialog-backdrop, rgba(0, 0, 0, 0.4)); + :host([type='non-modal']) dialog { + inset: 0; + position: fixed; + z-index: 2; + overflow: auto; + } } - .root { - box-sizing: border-box; - display: flex; - flex-direction: column; - overflow: unset; - max-height: calc(100vh - 48px); - padding: ${spacingVerticalXXL} ${spacingHorizontalXXL}; - } + @layer animations { + /* Disable animations for reduced motion */ + @media (prefers-reduced-motion: no-preference) { + dialog, + ::backdrop { + transition: display allow-discrete, opacity, overlay allow-discrete, scale; + transition-duration: ${durationGentle}; + transition-timing-function: ${curveDecelerateMid}; + /* Set opacity to 0 when closed */ + opacity: 0; + } + ::backdrop { + transition-timing-function: ${curveLinear}; + } - .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; - } + /* Set opacity to 1 when open */ + [open], + [open]::backdrop { + opacity: 1; + } - .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; - } + /* Exit styles for dialog */ + dialog:not([open]) { + /* Make small when leaving */ + scale: var(--dialog-starting-scale); + /* Faster leaving the stage then entering */ + transition-timing-function: ${curveAccelerateMid}; + } + } - .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%; - } + @starting-style { + [open], + [open]::backdrop { + opacity: 0; + } - @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; + dialog { + scale: var(--dialog-starting-scale); + } } } `; diff --git a/packages/web-components/src/dialog/dialog.template.ts b/packages/web-components/src/dialog/dialog.template.ts index fcc54dba0d3c88..75308feba212b6 100644 --- a/packages/web-components/src/dialog/dialog.template.ts +++ b/packages/web-components/src/dialog/dialog.template.ts @@ -1,67 +1,25 @@ -import { elements, ElementViewTemplate, html, ref, slotted, when } from '@microsoft/fast-element'; +import { ElementViewTemplate, html, ref } from '@microsoft/fast-element'; import type { Dialog } from './dialog.js'; -import { DialogModalType } from './dialog.options.js'; - -const dismissed16Regular = html.partial(` - `); +import { DialogType } from './dialog.options.js'; /** * 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 index f9595aed8b07de..9dcdaa89a4de46 100644 --- a/packages/web-components/src/dialog/dialog.ts +++ b/packages/web-components/src/dialog/dialog.ts @@ -1,8 +1,5 @@ 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'; +import { DialogType } from './dialog.options.js'; /** * A Dialog Custom HTML Element. @@ -10,35 +7,6 @@ import { DialogModalType } from './dialog.options.js'; * @public */ export class Dialog extends FASTElement { - /** - * @internal - * 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 @@ -46,20 +14,6 @@ export class Dialog extends FASTElement { @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 @@ -78,76 +32,31 @@ export class Dialog extends FASTElement { * @public * The type of the dialog modal */ - @attr({ attribute: 'modal-type' }) - public modalType: DialogModalType = DialogModalType.modal; + @attr + public type: DialogType = DialogType.modal; /** * @public - * Indicates whether the dialog is open + * Method to emit an event before the dialog's open state changes + * HTML spec proposal: https://github.com/whatwg/html/issues/9733 */ - @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; - - /** - * @internal - * 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 emitBeforeToggle = (): void => { + this.$emit('beforetoggle', { + oldState: this.dialog.open ? 'open' : 'closed', + newState: this.dialog.open ? 'closed' : 'open', + }); + }; /** * @public - * Method to emit an event when the dialog's open state changes - * @param dismissed - Indicates whether the dialog was dismissed + * Method to emit an event after the dialog's open state changes + * HTML spec proposal: https://github.com/whatwg/html/issues/9733 */ - public onOpenChangeEvent = (dismissed: boolean = false): void => { - this.$emit('onOpenChange', { open: this.dialog.open, dismissed: dismissed }); + public emitToggle = (): void => { + this.$emit('toggle', { + oldState: this.dialog.open ? 'closed' : 'open', + newState: this.dialog.open ? 'open' : 'closed', + }); }; /** @@ -156,244 +65,37 @@ export class Dialog extends FASTElement { */ public show(): void { Updates.enqueue(() => { - if (this.modalType === DialogModalType.alert || this.modalType === DialogModalType.modal) { + this.emitBeforeToggle(); + if (this.type === DialogType.alert || this.type === DialogType.modal) { this.dialog.showModal(); - this.open = true; - this.updateTrapFocus(true); - } else if (this.modalType === DialogModalType.nonModal) { + } else if (this.type === DialogType.nonModal) { this.dialog.show(); - this.open = true; } - this.onOpenChangeEvent(); + this.emitToggle(); }); } /** * @public * Method to hide the dialog - * @param dismissed - Indicates whether the dialog was dismissed */ - public hide(dismissed: boolean = false): void { + public hide(): void { + this.emitBeforeToggle(); 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); + this.emitToggle(); } /** * @public - * Handles click events on the dialog + * Handles click events on the dialog overlay for light-dismiss * @param event - The click event * @returns boolean */ - public handleClick(event: Event): boolean { + public clickHandler(event: Event): boolean { event.preventDefault(); - if (this.dialog.open && this.modalType !== DialogModalType.alert && event.target === this.dialog) { - this.dismiss(); + if (this.dialog.open && this.type !== DialogType.alert && event.target === this.dialog) { + this.hide(); } 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; - } - }; - - /** - * @internal - * 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; - } - } - }; - - /** - * @internal - * 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; - }; - - /** - * @internal - * Gets the bounds of the tab queue - * @returns (HTMLElement | SVGElement)[] - */ - private getTabQueueBounds = (): (HTMLElement | SVGElement)[] => { - const bounds: HTMLElement[] = []; - - return Dialog.reduceTabbableItems(bounds, this); - }; - - /** - * @internal - * 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(); - } - } - }; - - /** - * @internal - * 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); - }; - - /** - * @internal - * Determines if focus should be trapped - * @returns boolean - */ - private shouldTrapFocus = (): boolean => { - return this.trapFocus && this.dialog.open; - }; - - /** - * @internal - * 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(); - } - }; - - /** - * @internal - * 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); - } - }; - - /** - * @internal - * 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, - ); - } - - /** - * @internal - * 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; - } - - /** - * @internal - * 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 index cd49aa25da03b7..df602f57b74ff2 100644 --- a/packages/web-components/src/dialog/index.ts +++ b/packages/web-components/src/dialog/index.ts @@ -1,5 +1,5 @@ export { Dialog } from './dialog.js'; -export { DialogModalType } from './dialog.options.js'; +export { DialogType } from './dialog.options.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-rollup.ts b/packages/web-components/src/index-rollup.ts index 837dc3dcd7e943..a8d05bd771bc03 100644 --- a/packages/web-components/src/index-rollup.ts +++ b/packages/web-components/src/index-rollup.ts @@ -8,6 +8,7 @@ import './checkbox/define.js'; import './compound-button/define.js'; import './counter-badge/define.js'; import './dialog/define.js'; +import './dialog-body/define.js'; import './divider/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 e3c38fa35632bd..626420da16e9e2 100644 --- a/packages/web-components/src/index.ts +++ b/packages/web-components/src/index.ts @@ -86,7 +86,8 @@ export { CounterBadgeStyles, CounterBadgeTemplate, } from './counter-badge/index.js'; -export { Dialog, DialogDefinition, DialogModalType, DialogStyles, DialogTemplate } from './dialog/index.js'; +export { Dialog, DialogType, DialogDefinition, DialogTemplate, DialogStyles } from './dialog/index.js'; +export { DialogBody, DialogBodyDefinition, DialogBodyTemplate, DialogBodyStyles } from './dialog-body/index.js'; export { Divider, DividerAlignContent,