diff --git a/apps/vr-tests-web-components/package.json b/apps/vr-tests-web-components/package.json index 99a3830b50f54f..026b60422ff0b8 100644 --- a/apps/vr-tests-web-components/package.json +++ b/apps/vr-tests-web-components/package.json @@ -18,7 +18,7 @@ "html-react-parser": "4.0.0", "@fluentui/tokens": ">=1.0.0-alpha", "@fluentui/web-components": ">=3.0.0-alpha", - "@microsoft/fast-element": "2.0.0", + "@microsoft/fast-element": "^2.0.0", "tslib": "^2.1.0" } } diff --git a/change/@fluentui-chart-web-components-efc149c5-3d43-4300-a413-b3d9048e2248.json b/change/@fluentui-chart-web-components-efc149c5-3d43-4300-a413-b3d9048e2248.json new file mode 100644 index 00000000000000..e7def50970997e --- /dev/null +++ b/change/@fluentui-chart-web-components-efc149c5-3d43-4300-a413-b3d9048e2248.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: update fast-element dependency version", + "packageName": "@fluentui/chart-web-components", + "email": "863023+radium-v@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-headless-components-preview-812588d9-6ce9-49cb-b704-046cb6f998ff.json b/change/@fluentui-react-headless-components-preview-812588d9-6ce9-49cb-b704-046cb6f998ff.json new file mode 100644 index 00000000000000..1c60969d9ee800 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-812588d9-6ce9-49cb-b704-046cb6f998ff.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix(headless): fix Popover flipping behavior", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-web-components-38caae0f-3c50-46f0-b0c2-bb87de96d641.json b/change/@fluentui-web-components-38caae0f-3c50-46f0-b0c2-bb87de96d641.json new file mode 100644 index 00000000000000..9e94b949c3f1ed --- /dev/null +++ b/change/@fluentui-web-components-38caae0f-3c50-46f0-b0c2-bb87de96d641.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "fix: remove hoisted peer dependency entries", + "packageName": "@fluentui/web-components", + "email": "863023+radium-v@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-web-components-669e37a5-2c7c-4b19-95d4-8b070c6d72f7.json b/change/@fluentui-web-components-669e37a5-2c7c-4b19-95d4-8b070c6d72f7.json new file mode 100644 index 00000000000000..f103de031e7866 --- /dev/null +++ b/change/@fluentui-web-components-669e37a5-2c7c-4b19-95d4-8b070c6d72f7.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "fix: enhance accessibility attributes for drawer component", + "packageName": "@fluentui/web-components", + "email": "863023+radium-v@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-web-components-80c47489-fa3b-4929-8f16-f8ff8d79b89f.json b/change/@fluentui-web-components-80c47489-fa3b-4929-8f16-f8ff8d79b89f.json new file mode 100644 index 00000000000000..b6ae232147899a --- /dev/null +++ b/change/@fluentui-web-components-80c47489-fa3b-4929-8f16-f8ff8d79b89f.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "fix keyboard navigation regressions for tree and menu-list", + "packageName": "@fluentui/web-components", + "email": "machi@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-web-components-962383bb-b75c-46a4-bc98-038f6dd7f558.json b/change/@fluentui-web-components-962383bb-b75c-46a4-bc98-038f6dd7f558.json new file mode 100644 index 00000000000000..fd3625aedc557d --- /dev/null +++ b/change/@fluentui-web-components-962383bb-b75c-46a4-bc98-038f6dd7f558.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "test: add tests for compound-button, dialog-body, drawer-body, menu-button, and menu-item components", + "packageName": "@fluentui/web-components", + "email": "863023+radium-v@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/change/@fluentui-web-components-bc13b7de-bb06-4180-a517-c64ffe612490.json b/change/@fluentui-web-components-bc13b7de-bb06-4180-a517-c64ffe612490.json new file mode 100644 index 00000000000000..059d9e20d1efd2 --- /dev/null +++ b/change/@fluentui-web-components-bc13b7de-bb06-4180-a517-c64ffe612490.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "fix: synchronize compound-button template with button template", + "packageName": "@fluentui/web-components", + "email": "863023+radium-v@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-web-components-c28cec00-7029-4430-8cf5-17143006a866.json b/change/@fluentui-web-components-c28cec00-7029-4430-8cf5-17143006a866.json new file mode 100644 index 00000000000000..cd2eda4e6017d8 --- /dev/null +++ b/change/@fluentui-web-components-c28cec00-7029-4430-8cf5-17143006a866.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "fix: enhance accessibility attributes for dialog component", + "packageName": "@fluentui/web-components", + "email": "863023+radium-v@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/package.json b/package.json index 756753194acc26..9dc6138cef295c 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@microsoft/api-extractor": "7.51.0", "@microsoft/api-extractor-model": "7.31.2", "@microsoft/eslint-plugin-sdl": "1.0.1", + "@microsoft/fast-element": "2.10.4", "@microsoft/focusgroup-polyfill": "^1.4.1", "@microsoft/load-themed-styles": "1.10.26", "@microsoft/loader-load-themed-styles": "2.0.17", @@ -86,7 +87,7 @@ "@octokit/rest": "18.12.0", "@oddbird/css-anchor-positioning": "0.4.0", "@phenomnomnominal/tsquery": "6.1.3", - "@playwright/test": "1.55.1", + "@playwright/test": "1.56.1", "@react-native/babel-preset": "0.73.21", "@rnx-kit/eslint-plugin": "0.8.6", "@rollup/plugin-node-resolve": "13.3.0", @@ -256,7 +257,7 @@ "parse-diff": "0.7.1", "patch-package": "8.0.0", "path-browserify": "1.0.1", - "playwright": "1.55.1", + "playwright": "1.56.1", "plop": "2.6.0", "portfinder": "1.0.28", "postcss": "8.5.10", @@ -354,7 +355,7 @@ "prettier": "2.8.8", "puppeteer": "24.42.0", "ws": "8.17.1", - "playwright": "1.55.1", + "playwright": "1.56.1", "**/prismjs": "^1.30.0", "**/@tensile-perf/runner/express": "^4.22.1", "**/tar-fs": "^2.1.4", diff --git a/packages/charts/chart-web-components/package.json b/packages/charts/chart-web-components/package.json index ffaac36ba9d6dc..b190c9a5f06147 100644 --- a/packages/charts/chart-web-components/package.json +++ b/packages/charts/chart-web-components/package.json @@ -61,7 +61,6 @@ "test:dev": "node ./scripts/e2e.js" }, "devDependencies": { - "@microsoft/fast-element": "2.0.0", "@tensile-perf/web-components": "~0.2.2", "@storybook/html": "9.1.17", "@storybook/html-vite": "9.1.17", @@ -79,7 +78,7 @@ "tslib": "^2.1.0" }, "peerDependencies": { - "@microsoft/fast-element": "^2.0.0-beta.26 || ^2.0.0" + "@microsoft/fast-element": "^2.0.0" }, "beachball": { "disallowedChangeTypes": [ diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx index 7f849b3294e97a..e048c4c6421b0a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx @@ -54,18 +54,18 @@ describe('usePositioning', () => { result.current.containerRef(node); }); - expect(node).toHaveStyle({ position: 'absolute', inset: 'auto', margin: '0px' }); + expect(node).toHaveStyle({ position: 'fixed', inset: 'auto', margin: '0px' }); }); - it('containerRef honors strategy: "fixed"', () => { - const result = mountHook({ strategy: 'fixed' }); + it('containerRef honors strategy: "absolute"', () => { + const result = mountHook({ strategy: 'absolute' }); const node = document.createElement('div'); act(() => { result.current.containerRef(node); }); - expect(node).toHaveStyle({ position: 'fixed' }); + expect(node).toHaveStyle({ position: 'absolute' }); }); it('containerRef writes data-placement matching (position, align)', () => { @@ -79,7 +79,7 @@ describe('usePositioning', () => { expect(node).toHaveAttribute('data-placement', 'below-start'); }); - it('containerRef sets position-try-fallbacks to the three-try flip chain by default', () => { + it('containerRef sets position-try-fallbacks to the default flip chain', () => { const result = mountHook(); const node = document.createElement('div'); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts index 68bcd95c543733..53de720c0bbb58 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts @@ -17,6 +17,7 @@ import { usePlacementObserver } from './usePlacementObserver'; export type TargetElement = HTMLElement | PositioningVirtualElement; const DEFAULT_FLIP = ['flip-block', 'flip-inline', 'flip-block flip-inline']; + const EMPTY_FALLBACK_POSITIONS: PositioningShorthandValue[] = []; export function usePositioning(options: PositioningProps): PositioningReturn { @@ -28,7 +29,7 @@ export function usePositioning(options: PositioningProps): PositioningReturn { fallbackPositions = EMPTY_FALLBACK_POSITIONS, offset, coverTarget = false, - strategy = 'absolute', + strategy = 'fixed', matchTargetSize, positioningRef, } = options; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningMatchTargetSize.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningMatchTargetSize.stories.tsx index dfd2e812a21755..12450363816641 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningMatchTargetSize.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningMatchTargetSize.stories.tsx @@ -6,7 +6,7 @@ import styles from './positioning.module.css'; export const MatchTargetSize = (): React.ReactNode => (
- + diff --git a/packages/web-components/docs/web-components.api.md b/packages/web-components/docs/web-components.api.md index 45c4edfeca7653..6b004a99989099 100644 --- a/packages/web-components/docs/web-components.api.md +++ b/packages/web-components/docs/web-components.api.md @@ -2514,18 +2514,24 @@ export class Dialog extends FASTElement { ariaLabel: string | null; ariaLabelledby?: string; clickHandler(event: Event): boolean; - dialog: HTMLDialogElement; // (undocumented) - protected dialogChanged(): void; - emitBeforeToggle: () => void; + connectedCallback(): void; + dialog: HTMLDialogElement; + // @internal + get dialogDescribedby(): string | undefined; + // @internal + get dialogLabel(): string | null | undefined; + // @internal + get dialogLabelledby(): string | undefined; + // @internal + get dialogModal(): boolean | undefined; + // @internal + get dialogRole(): string | undefined; + emitBeforeToggle(): void; emitToggle: () => void; hide(): void; show(): void; type: DialogType; - // (undocumented) - protected typeChanged(prev: DialogType | undefined, next: DialogType): void; - // @internal - protected updateDialogAttributes(): void; } // @public @@ -2644,27 +2650,29 @@ export class Drawer extends FASTElement { cancelHandler(): void; // (undocumented) clickHandler(event: Event): boolean; - // @internal (undocumented) + // (undocumented) connectedCallback(): void; dialog: HTMLDialogElement; - // @internal (undocumented) - disconnectedCallback(): void; + // @internal + get dialogDescribedby(): string | undefined; + // @internal + get dialogLabel(): string | null | undefined; + // @internal + get dialogLabelledby(): string | undefined; + // @internal + get dialogModal(): boolean | undefined; + // @internal + get dialogRole(): string | null; emitBeforeToggle: () => void; emitToggle: () => void; hide(): void; - // (undocumented) - protected observeRoleAttr(): void; position: DrawerPosition; // (undocumented) - protected roleAttrObserver: MutationObserver; + role: string | null; show(): void; // (undocumented) size: DrawerSize; type: DrawerType; - // (undocumented) - protected typeChanged(): void; - // (undocumented) - protected updateDialogRole(): void; } // Warning: (ae-missing-release-tag) "DrawerBody" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/packages/web-components/package.json b/packages/web-components/package.json index 0f4cd54a7c993c..3064cc7dc3eaf4 100644 --- a/packages/web-components/package.json +++ b/packages/web-components/package.json @@ -90,8 +90,6 @@ }, "devDependencies": { "@custom-elements-manifest/analyzer": "0.10.10", - "@microsoft/fast-element": "2.0.0", - "@microsoft/focusgroup-polyfill": "^1.4.1", "@tensile-perf/web-components": "~0.2.2", "@storybook/html": "9.1.17", "@storybook/html-vite": "9.1.17", diff --git a/packages/web-components/src/compound-button/compound-button.spec.ts b/packages/web-components/src/compound-button/compound-button.spec.ts new file mode 100644 index 00000000000000..f0df3f89b8589a --- /dev/null +++ b/packages/web-components/src/compound-button/compound-button.spec.ts @@ -0,0 +1,723 @@ +import { expect, test } from '../../test/playwright/index.js'; +import { tagName } from './compound-button.options.js'; + +test.describe('Compound Button', () => { + test.use({ + tagName, + innerHTML: 'Button Description', + }); + + test('should create with document.createElement()', async ({ page, fastPage }) => { + await fastPage.setTemplate(); + + let hasError = false; + + page.on('pageerror', () => { + hasError = true; + }); + + await page.evaluate(tagName => { + document.createElement(tagName); + }, tagName); + + expect(hasError).toBe(false); + }); + + test('should NOT submit the parent form when clicked and `type` attribute is not set', async ({ fastPage, page }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
+ <${tagName}>Button +
+ `); + + await element.click(); + + await expect(page).not.toHaveURL(/foo/); + }); + + test('should NOT submit the parent form when clicked and `type` attribute is set to "button"', async ({ + fastPage, + page, + }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
+ <${tagName} type="button">Button +
+ `); + + await element.click(); + + await expect(page).not.toHaveURL(/foo/); + }); + + test('should NOT submit the parent form when clicked and `type` attribute is set to "reset"', async ({ + fastPage, + page, + }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
+ <${tagName} type="reset">Button +
+ `); + + await element.click(); + + await expect(page).not.toHaveURL(/foo/); + }); + + test("should submit the form with the submit button's name and value when clicked", async ({ fastPage, page }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
+ <${tagName} type="submit" name="bar" value="baz">Button +
+ `); + + await element.click(); + + await expect(page).toHaveURL(/foo\?bar=baz$/); + }); + + test('should NOT submit a value when the `name` attribute is NOT set and the `value` attribute is set', async ({ + fastPage, + page, + }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
+ <${tagName} type="submit" value="baz">Button +
+ `); + + await element.click(); + + await expect(page).toHaveURL(/foo\?$/); + }); + + test('should be focusable by default', async ({ fastPage }) => { + const { element } = fastPage; + + await fastPage.setTemplate(); + + await element.focus(); + + await expect(element).toBeFocused(); + }); + + test('should NOT be focusable when the `disabled` attribute is present', async ({ fastPage }) => { + const { element } = fastPage; + + await fastPage.setTemplate({ attributes: { disabled: true } }); + + await element.focus(); + + await expect(element).not.toBeFocused(); + }); + + test('should apply transparency correctly when the `disabled` attribute is present', async ({ fastPage }) => { + const { element } = fastPage; + await fastPage.setTemplate({ attributes: { disabled: true } }); + + const transparent = 'rgba(0, 0, 0, 0)'; + + await expect(element).not.toHaveCSS('border-color', transparent); + await expect(element).not.toHaveCSS('background-color', transparent); + + await element.evaluate(node => node.setAttribute('appearance', 'primary')); + await expect(element).toHaveCSS('border-color', transparent); + await expect(element).not.toHaveCSS('background-color', transparent); + + await element.evaluate(node => node.setAttribute('appearance', 'secondary')); + await expect(element).not.toHaveCSS('border-color', transparent); + await expect(element).not.toHaveCSS('background-color', transparent); + + await element.evaluate(node => node.setAttribute('appearance', 'outline')); + await expect(element).not.toHaveCSS('border-color', transparent); + await expect(element).toHaveCSS('background-color', transparent); + + await element.evaluate(node => node.setAttribute('appearance', 'subtle')); + await expect(element).toHaveCSS('border-color', transparent); + await expect(element).toHaveCSS('background-color', transparent); + + await element.evaluate(node => node.setAttribute('appearance', 'transparent')); + await expect(element).toHaveCSS('border-color', transparent); + await expect(element).toHaveCSS('background-color', transparent); + }); + + test('should be focusable when the `disabled-focusable` attribute is present', async ({ fastPage }) => { + const { element } = fastPage; + + await fastPage.setTemplate({ attributes: { 'disabled-focusable': true } }); + + await element.focus(); + + await expect(element).toBeFocused(); + }); + + test('should apply transparency correctly when the `disabled-focusable` attribute is present', async ({ + fastPage, + }) => { + const { element } = fastPage; + await fastPage.setTemplate({ attributes: { 'disabled-focusable': true } }); + + const transparent = 'rgba(0, 0, 0, 0)'; + + await expect(element).not.toHaveCSS('border-color', transparent); + await expect(element).not.toHaveCSS('background-color', transparent); + + await element.evaluate(node => node.setAttribute('appearance', 'primary')); + await expect(element).toHaveCSS('border-color', transparent); + await expect(element).not.toHaveCSS('background-color', transparent); + + await element.evaluate(node => node.setAttribute('appearance', 'secondary')); + await expect(element).not.toHaveCSS('border-color', transparent); + await expect(element).not.toHaveCSS('background-color', transparent); + + await element.evaluate(node => node.setAttribute('appearance', 'outline')); + await expect(element).not.toHaveCSS('border-color', transparent); + await expect(element).toHaveCSS('background-color', transparent); + + await element.evaluate(node => node.setAttribute('appearance', 'subtle')); + await expect(element).toHaveCSS('border-color', transparent); + await expect(element).toHaveCSS('background-color', transparent); + + await element.evaluate(node => node.setAttribute('appearance', 'transparent')); + await expect(element).toHaveCSS('border-color', transparent); + await expect(element).toHaveCSS('background-color', transparent); + }); + + test('should NOT be clickable when the `disabled` attribute is present', async ({ fastPage, page }) => { + const { element } = fastPage; + + await fastPage.setTemplate({ attributes: { disabled: true } }); + + const wasNotClicked = await page.evaluate(el => { + const event = new KeyboardEvent('click', { + bubbles: true, + cancelable: true, + view: window, + }); + + // The return value of dispatchEvent will be false if any event listener called preventDefault, or true otherwise. + return el?.dispatchEvent(event); + }, await element.elementHandle()); + + expect(wasNotClicked).toEqual(true); + }); + + for (const key of ['Enter', ' ']) { + test(`should NOT be actionable with \`${key}\` keypress when the \`disabled-focusable\` attribute is present`, async ({ + fastPage, + page, + }) => { + const { element } = fastPage; + + await fastPage.setTemplate({ attributes: { 'disabled-focusable': true } }); + + await element.focus(); + + const isActionable = await page.evaluate( + ([el, key]) => { + const event = new KeyboardEvent('keypress', { + key: key as string, + bubbles: true, + cancelable: true, + view: window, + }); + + // The return value of dispatchEvent will be false if any event listener called preventDefault, or true otherwise. + return (el as HTMLElement).dispatchEvent(event); + }, + [await element.elementHandle(), key], + ); + + expect(isActionable).toBe(false); + }); + } + + test('should NOT receive focus when the `tabindex` is manually set to -1', async ({ fastPage, page }) => { + const element = page.locator(tagName, { hasText: 'Not Focusable' }); + const focusable = page.locator(tagName, { hasText: 'Receives Focus' }); + + await fastPage.setTemplate(/* html */ ` + <${tagName}>Receives Focus + <${tagName} tabindex="-1">Not Focusable + `); + + await focusable.focus(); + + await expect(focusable).toBeFocused(); + + await focusable.press('Tab'); + + await expect(element).not.toBeFocused(); + }); + + test('should focus the element when the `autofocus` attribute is present', async ({ fastPage }) => { + const { element } = fastPage; + + await fastPage.setTemplate({ attributes: { autofocus: true } }); + + await expect(element).toBeFocused(); + }); + + test('should submit the parent form when clicked and `type` attribute is set to "submit"', async ({ fastPage }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
+ <${tagName} type="submit">Submit Button +
+ `); + + await element.click(); + + await expect(fastPage.page).toHaveURL(/foo/); + }); + + test('should reset the parent form when clicked and `type` attribute is set to "reset"', async ({ + fastPage, + page, + }) => { + const { element } = fastPage; + const input = page.locator('#text-input'); + + await fastPage.setTemplate(/* html */ ` +
+ + <${tagName} type="reset">Reset Button +
+ `); + + await expect(input).toHaveValue(''); + + await input.fill('foo'); + + await expect(input).toHaveValue('foo'); + + await element.click(); + + await expect(input).toHaveValue(''); + }); + + test('should NOT reset the parent form when the `type` attribute is set to "reset" and the `disabled` attribute is present', async ({ + fastPage, + page, + }) => { + const { element } = fastPage; + const input = page.locator('#text-input'); + + await fastPage.setTemplate(/* html */ ` +
+ + <${tagName} type="reset" disabled>Reset Button +
+ `); + + await input.fill('foo'); + + await element.click(); + + await expect(input).toHaveValue('foo'); + }); + + test('should do nothing when clicked while not in a form and the `type` attribute is set to "submit"', async ({ + fastPage, + page, + }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
Unrelated Form
+ <${tagName} type="submit">Submit Button + `); + + await element.click(); + + await expect(page).not.toHaveURL(/foo/); + }); + + test('should do nothing when clicked while not in a form and the `type` attribute is set to "reset"', async ({ + fastPage, + page, + }) => { + const { element } = fastPage; + const input = page.locator('#text-input'); + + await fastPage.setTemplate(/* html */ ` +
+ Unrelated Form + +
+ <${tagName} type="reset">Submit Button + `); + + await expect(input).toHaveValue(''); + + await input.fill('foo'); + + await expect(input).toHaveValue('foo'); + + await element.click(); + + await expect(input).toHaveValue('foo'); + + await expect(page).not.toHaveURL(/foo/); + }); + + test('should NOT submit the parent form when clicked and `disabled` attribute is present', async ({ fastPage }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
+ <${tagName} type="submit" disabled>Submit Button +
+ `); + + await element.click(); + + await expect(fastPage.page).not.toHaveURL(/foo/); + }); + + test('should submit the parent form when clicked and the `form` attribute is provided', async ({ + fastPage, + page, + }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
+ +
+ + <${tagName} type="submit" form="testform">Submit Button + `); + + await expect(page).not.toHaveURL(/foo/); + + await element.click(); + + await expect(page).toHaveURL(/foo/); + }); + + test('should override the form action when the `formaction` attribute is provided', async ({ fastPage, page }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
+ <${tagName} type="submit" formaction="bar">Submit Button +
+ `); + + await element.click(); + + await expect(page).not.toHaveURL(/foo/); + + await expect(page).toHaveURL(/bar/); + }); + + test('should override the action of the referenced form when the `formaction` and `form` attributes are provided', async ({ + fastPage, + page, + }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
+ +
+ <${tagName} type="submit" form="testform" formaction="bar">Submit Button + `); + + await element.click(); + + await expect(page).not.toHaveURL(/foo/); + + await expect(page).toHaveURL(/bar/); + }); + + test('should override the form method when the `formmethod` attribute is provided', async ({ fastPage, page }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
+ <${tagName} type="submit" formmethod="post">Submit Button +
+ `); + + await expect(page).not.toHaveURL(/foo/); + + const method = page.waitForRequest(request => request.method() === 'POST'); + + await element.click(); + + await expect(method).resolves.toBeTruthy(); + + await expect(page).toHaveURL(/foo/); + }); + + test('should override the method of the referenced form when the `formmethod` and `form` attributes are provided', async ({ + fastPage, + page, + }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
+ +
+ + <${tagName} type="submit" form="testform" formmethod="post">Submit Button + `); + + await expect(page).not.toHaveURL(/foo/); + + const method = page.waitForRequest(request => request.method() === 'POST'); + + await element.click(); + + await expect(method).resolves.toBeTruthy(); + + await expect(page).toHaveURL(/foo/); + }); + + test('should override the form encoding when the `formenctype` attribute is provided', async ({ fastPage, page }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
+ + <${tagName} type="submit" formenctype="plain/text">Submit Button +
+ `); + + await expect(page).not.toHaveURL(/foo/); + + await element.click(); + + await expect(page).toHaveURL(/foo\?testinput=hello\+world$/); + }); + + test('should override the encoding of the referenced form when the `formenctype` and `form` attributes are provided', async ({ + fastPage, + page, + }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
+ +
+ + <${tagName} type="submit" form="testform" formenctype="plain/text">Submit Button + `); + + await expect(page).not.toHaveURL(/foo/); + + await element.click(); + + await expect(page).toHaveURL(/foo\?testinput=hello\+world$/); + }); + + test('should override the form target when the `formtarget` attribute is provided', async ({ fastPage, page }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
+ <${tagName} type="submit" formtarget="_self">Submit Button +
+ `); + + await expect(page).not.toHaveURL(/foo/); + + await element.click(); + + await expect(page).toHaveURL(/foo/); + }); + + test('should override the target of the referenced form when the `formtarget` and `form` attributes are provided', async ({ + fastPage, + page, + }) => { + const { element } = fastPage; + + await fastPage.setTemplate(/* html */ ` +
+ +
+ + <${tagName} type="submit" form="testform" formtarget="_self">Submit Button + `); + + await expect(page).not.toHaveURL(/foo/); + + await element.click(); + + await expect(page).toHaveURL(/foo/); + }); + + test('should submit the parent form when form validation errors are present and the `formnovalidate` attribute is present', async ({ + fastPage, + page, + }) => { + const form = page.locator('#test-form'); + const { element } = fastPage; + const input = page.locator('#text-input'); + + await fastPage.setTemplate(/* html */ ` +
+ + <${tagName} type="submit" formnovalidate>Button +
+ `); + + await input.fill('foo'); + + const validity = await form.evaluate((node: HTMLFormElement) => node.checkValidity()); + + expect(validity).toBe(false); + + await expect(page).not.toHaveURL(/foo/); + + await element.click(); + + await expect(page).toHaveURL(/foo/); + }); + + test('should submit the referenced form when form validation errors are present and the `formnovalidate` and `form` attributes are present', async ({ + fastPage, + page, + }) => { + const form = page.locator('#test-form'); + const { element } = fastPage; + const input = page.locator('#text-input'); + + await fastPage.setTemplate(/* html */ ` +
+ +
+ + <${tagName} type="submit" form="test-form" formnovalidate>Button + `); + + await input.fill('foo'); + + expect(await form.evaluate((node: HTMLFormElement) => node.checkValidity())).toBeFalsy(); + + await expect(page).not.toHaveURL(/foo/); + + await element.click(); + + await expect(page).toHaveURL(/foo/); + }); + + test('should NOT submit the parent form when form validation errors are present and the `formnovalidate` is NOT present', async ({ + fastPage, + page, + }) => { + const button = page.locator(tagName); + const input = page.locator('#text-input'); + + await fastPage.setTemplate(/* html */ ` +
+ + <${tagName} type="submit">Button +
+ `); + + await input.fill('foo'); + + const wasNotSubmitted = input.evaluate( + node => + new Promise(resolve => { + node.addEventListener('invalid', () => resolve(true)); + }), + ); + + await button.click(); + + await expect(wasNotSubmitted).resolves.toBeTruthy(); + }); + + test('should NOT submit the referenced form when form validation errors are present and the `formnovalidate` attribute is NOT present and the `form` attribute is present', async ({ + fastPage, + page, + }) => { + const button = page.locator(tagName); + const input = page.locator('#text-input'); + + await fastPage.setTemplate(/* html */ ` +
+ +
+ + <${tagName} type="submit" form="test-form">Button + `); + + await input.fill('foo'); + + const wasInvalid = input.evaluate( + node => + new Promise(resolve => { + node.addEventListener('invalid', () => resolve(true)); + }), + ); + + await button.click(); + + await expect(wasInvalid).resolves.toBeTruthy(); + }); + + test.describe('description slot', () => { + test('should render content in the description slot', async ({ fastPage }) => { + const { element } = fastPage; + const description = element.locator('slot[name="description"]'); + + await fastPage.setTemplate({ + innerHTML: 'Primary text Secondary text', + }); + + await expect(description).toHaveCount(1); + await expect(element).toContainText('Primary text'); + await expect(element).toContainText('Secondary text'); + }); + + test('should render without description slot content', async ({ fastPage }) => { + const { element } = fastPage; + + await fastPage.setTemplate({ innerHTML: 'Button text only' }); + + await expect(element).toContainText('Button text only'); + }); + + test('should place the description slot inside the content part', async ({ fastPage }) => { + const { element } = fastPage; + const contentPart = element.locator('slot:not([name])'); + const descriptionSlot = element.locator('slot[name="description"]'); + + await fastPage.setTemplate({ + innerHTML: 'Click me More info', + }); + + expect( + await contentPart.evaluate((node: HTMLSlotElement) => + node.assignedNodes().map(n => ({ textContent: n.textContent })), + ), + ).toEqual([{ textContent: 'Click me ' }]); + + expect( + await descriptionSlot.evaluate((node: HTMLSlotElement) => + node.assignedElements().map(n => ({ textContent: n.textContent })), + ), + ).toEqual([{ textContent: 'More info' }]); + }); + }); +}); diff --git a/packages/web-components/src/compound-button/compound-button.template.ts b/packages/web-components/src/compound-button/compound-button.template.ts index bfe14d286e5959..49b5c4eb01c353 100644 --- a/packages/web-components/src/compound-button/compound-button.template.ts +++ b/packages/web-components/src/compound-button/compound-button.template.ts @@ -4,12 +4,16 @@ import type { CompoundButton } from './compound-button.js'; import type { CompoundButtonOptions } from './compound-button.options.js'; /** - * The template for the Compound Button component. + * Generates a template for the Button component. + * * @public */ export function buttonTemplate(options: CompoundButtonOptions = {}): ElementViewTemplate { return html` -