diff --git a/change/@fluentui-web-components-a6989fe4-82bf-4df4-a2d6-22ac5f7f890a.json b/change/@fluentui-web-components-a6989fe4-82bf-4df4-a2d6-22ac5f7f890a.json new file mode 100644 index 00000000000000..8b30e63df96213 --- /dev/null +++ b/change/@fluentui-web-components-a6989fe4-82bf-4df4-a2d6-22ac5f7f890a.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add defaultindeterminate attribute to checkbox to support declarative scenarios", + "packageName": "@fluentui/web-components", + "email": "13071055+chrisdholt@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/web-components/docs/web-components.api.md b/packages/web-components/docs/web-components.api.md index af72cf614720f2..61f1f611459131 100644 --- a/packages/web-components/docs/web-components.api.md +++ b/packages/web-components/docs/web-components.api.md @@ -1219,6 +1219,13 @@ export type ButtonType = ValuesOf; // @public export class Checkbox extends BaseCheckbox { constructor(); + // @internal + clickHandler(e: MouseEvent): boolean | void; + defaultIndeterminate?: boolean; + // @internal + protected defaultIndeterminateChanged(prev: boolean | undefined, next: boolean | undefined): void; + // @internal + formResetCallback(): void; indeterminate?: boolean; // @internal protected indeterminateChanged(prev: boolean | undefined, next: boolean | undefined): void; diff --git a/packages/web-components/src/checkbox/checkbox.spec.ts b/packages/web-components/src/checkbox/checkbox.spec.ts index 86c0dffb92c5e7..ab444fe3d3fa69 100644 --- a/packages/web-components/src/checkbox/checkbox.spec.ts +++ b/packages/web-components/src/checkbox/checkbox.spec.ts @@ -153,6 +153,136 @@ test.describe('Checkbox', () => { await expect(element).toHaveJSProperty('indeterminate', false); }); + test('should initialize `defaultIndeterminate` and `indeterminate` from the `defaultindeterminate` attribute', async ({ + fastPage, + }) => { + const { element } = fastPage; + + await fastPage.setTemplate({ attributes: { defaultindeterminate: true } }); + + await expect(element).toHaveJSProperty('defaultIndeterminate', true); + await expect(element).toHaveJSProperty('indeterminate', true); + }); + + test('should reflect `defaultindeterminate` to `defaultIndeterminate`', async ({ fastPage }) => { + const { element } = fastPage; + + await fastPage.setTemplate(); + + await expect(element).toHaveJSProperty('defaultIndeterminate', undefined); + + await element.evaluate((node: Checkbox) => { + node.toggleAttribute('defaultindeterminate', true); + }); + + await expect(element).toHaveJSProperty('defaultIndeterminate', true); + + await element.evaluate((node: Checkbox) => { + node.toggleAttribute('defaultindeterminate', false); + }); + + await expect(element).toHaveJSProperty('defaultIndeterminate', false); + }); + + test('should update live `indeterminate` from `defaultindeterminate` before user interaction', async ({ + fastPage, + }) => { + const { element } = fastPage; + + await fastPage.setTemplate(); + + await element.evaluate((node: Checkbox) => { + node.toggleAttribute('defaultindeterminate', true); + }); + + await expect(element).toHaveJSProperty('defaultIndeterminate', true); + await expect(element).toHaveJSProperty('indeterminate', true); + + await element.evaluate((node: Checkbox) => { + node.toggleAttribute('defaultindeterminate', false); + }); + + await expect(element).toHaveJSProperty('defaultIndeterminate', false); + await expect(element).toHaveJSProperty('indeterminate', false); + }); + + test('should preserve live `indeterminate` after user interaction when `defaultindeterminate` changes', async ({ + fastPage, + }) => { + const { element } = fastPage; + + await fastPage.setTemplate({ attributes: { defaultindeterminate: true } }); + + await expect(element).toHaveJSProperty('indeterminate', true); + + await element.click(); + + await expect(element).toHaveJSProperty('indeterminate', false); + + await element.evaluate((node: Checkbox) => { + node.toggleAttribute('defaultindeterminate', false); + }); + + await expect(element).toHaveJSProperty('indeterminate', false); + + await element.evaluate((node: Checkbox) => { + node.toggleAttribute('defaultindeterminate', true); + }); + + await expect(element).toHaveJSProperty('indeterminate', false); + }); + + test.describe('dirty-state styling', () => { + test.skip(({ ssr }) => ssr === true, 'Dirty-state styling requires client-side user interaction.'); + + test('should stop applying declarative indeterminate styling after user interaction', async ({ fastPage }) => { + const { element } = fastPage; + const indeterminateIndicator = element.locator('.indeterminate-indicator'); + const transparent = 'rgba(0, 0, 0, 0)'; + + await fastPage.setTemplate({ attributes: { defaultindeterminate: true } }); + + await expect(indeterminateIndicator).not.toHaveCSS('background-color', transparent); + + await element.click(); + + await expect(element).toHaveJSProperty('indeterminate', false); + await expect(indeterminateIndicator).toHaveCSS('background-color', transparent); + }); + }); + + test('should reset live `indeterminate` to `true` when `defaultindeterminate` is present', async ({ + fastPage, + page, + }) => { + const { element } = fastPage; + const form = page.locator('form'); + + await fastPage.setTemplate(/* html */ ` +
+ <${tagName} defaultindeterminate> +
+ `); + + await expect(element).toHaveJSProperty('indeterminate', true); + + await element.click(); + + await expect(element).toHaveJSProperty('indeterminate', false); + + await form.evaluate((node: HTMLFormElement) => { + node.reset(); + }); + + await expect(element).toHaveJSProperty('indeterminate', true); + + await element.evaluate((node: Checkbox) => { + node.toggleAttribute('defaultindeterminate', false); + }); + + await expect(element).toHaveJSProperty('indeterminate', false); + }); + test('should NOT change the `indeterminate` property when the owning form is reset', async ({ fastPage, page }) => { const { element } = fastPage; const form = page.locator('form'); @@ -188,6 +318,20 @@ test.describe('Checkbox', () => { }); }); + test('should resolve `checked` and `defaultindeterminate` independently on activation', async ({ fastPage }) => { + const { element } = fastPage; + + await fastPage.setTemplate({ attributes: { checked: true, defaultindeterminate: true } }); + + await expect(element).toHaveJSProperty('checked', true); + await expect(element).toHaveJSProperty('indeterminate', true); + + await element.click(); + + await expect(element).toHaveJSProperty('indeterminate', false); + await expect(element).toHaveJSProperty('checked', false); + }); + test('should initialize to the provided `value` attribute when set pre-connection', async ({ fastPage, page }) => { const expectedValue = 'foobar'; diff --git a/packages/web-components/src/checkbox/checkbox.stories.ts b/packages/web-components/src/checkbox/checkbox.stories.ts index 2f92188787c8fe..6b9742745d4963 100644 --- a/packages/web-components/src/checkbox/checkbox.stories.ts +++ b/packages/web-components/src/checkbox/checkbox.stories.ts @@ -12,6 +12,7 @@ const { argTypes } = getStorybookHelpers('fluent-checkbox'); const storyTemplate = html>` >` + ${repeat(story => story.storyContent, html>`${fieldStoryTemplate}
`)} + `), + args: { + storyContent: [ + { + storyContent: storyTemplate, + defaultIndeterminate: true, + id: uniqueId('checkbox-'), + label: 'Default indeterminate', + labelPosition: LabelPosition.after, + slot: 'input', + }, + { + storyContent: storyTemplate, + defaultIndeterminate: true, + id: uniqueId('checkbox-'), + label: 'Default indeterminate circular', + labelPosition: LabelPosition.after, + shape: CheckboxShape.circular, + slot: 'input', + }, + ], + }, +}; + export const Disabled: Story = { render: renderComponent(html>` ${repeat(story => story.storyContent, html>`${fieldStoryTemplate}
`)} @@ -166,6 +194,25 @@ export const Disabled: Story = { shape: CheckboxShape.circular, slot: 'input', }, + { + storyContent: storyTemplate, + defaultIndeterminate: true, + disabled: true, + id: uniqueId('checkbox-'), + label: 'Disabled default indeterminate', + labelPosition: LabelPosition.after, + slot: 'input', + }, + { + storyContent: storyTemplate, + defaultIndeterminate: true, + disabled: true, + id: uniqueId('checkbox-'), + label: 'Disabled circular default indeterminate', + labelPosition: LabelPosition.after, + shape: CheckboxShape.circular, + slot: 'input', + }, ], }, }; diff --git a/packages/web-components/src/checkbox/checkbox.styles.css b/packages/web-components/src/checkbox/checkbox.styles.css index 0800defa4d9d94..ba58f53c8a2db8 100644 --- a/packages/web-components/src/checkbox/checkbox.styles.css +++ b/packages/web-components/src/checkbox/checkbox.styles.css @@ -79,16 +79,20 @@ } :host(:state(checked)), -:host(:state(indeterminate)) { +:host( + :is(:state(indeterminate), [defaultindeterminate]:not(:state(dirty-indeterminate))):not(:is([disabled], :disabled)) + ) { border-color: var(--colorCompoundBrandStroke); } :host(:state(checked)), -:host(:state(indeterminate)) .indeterminate-indicator { +:host(:state(indeterminate)) .indeterminate-indicator, +:host([defaultindeterminate]:not(:state(dirty-indeterminate)):not(:state(checked))) .indeterminate-indicator { background-color: var(--colorCompoundBrandBackground); } -:host(:state(indeterminate)) .indeterminate-indicator { +:host(:state(indeterminate)) .indeterminate-indicator, +:host([defaultindeterminate]:not(:state(dirty-indeterminate)):not(:state(checked))) .indeterminate-indicator { border-radius: var(--borderRadiusSmall); position: absolute; width: calc(var(--size) / 2); @@ -119,7 +123,9 @@ cursor: unset; } -:host(:is([disabled], :disabled):state(indeterminate)) .indeterminate-indicator { +:host(:is([disabled], :disabled):state(indeterminate)) .indeterminate-indicator, +:host(:is([disabled], :disabled)[defaultindeterminate]:not(:state(dirty-indeterminate)):not(:state(checked))) + .indeterminate-indicator { background-color: var(--colorNeutralStrokeDisabled); } @@ -148,12 +154,17 @@ } :host(:state(checked)), - :host(:state(indeterminate)) .indeterminate-indicator { + :host(:state(indeterminate)) .indeterminate-indicator, + :host([defaultindeterminate]:not(:state(dirty-indeterminate)):not(:state(checked))) .indeterminate-indicator { background-color: FieldText; } :host(:state(checked):not(:is([disabled], :disabled)):hover), - :host(:state(indeterminate):not(:is([disabled], :disabled)):hover) .indeterminate-indicator { + :host(:state(indeterminate):not(:is([disabled], :disabled)):hover) .indeterminate-indicator, + :host( + [defaultindeterminate]:not(:state(dirty-indeterminate)):not(:state(checked)):not(:is([disabled], :disabled)):hover + ) + .indeterminate-indicator { background-color: Highlight; } @@ -161,7 +172,9 @@ border-color: GrayText; } - :host(:is([disabled], :disabled):state(indeterminate)) .indeterminate-indicator { + :host(:is([disabled], :disabled):state(indeterminate)) .indeterminate-indicator, + :host(:is([disabled], :disabled)[defaultindeterminate]:not(:state(dirty-indeterminate)):not(:state(checked))) + .indeterminate-indicator { background-color: GrayText; } diff --git a/packages/web-components/src/checkbox/checkbox.styles.ts b/packages/web-components/src/checkbox/checkbox.styles.ts index 4ef59bb5236b54..7c93fe01b87efa 100644 --- a/packages/web-components/src/checkbox/checkbox.styles.ts +++ b/packages/web-components/src/checkbox/checkbox.styles.ts @@ -1,5 +1,10 @@ import { css } from '@microsoft/fast-element'; -import { checkedState, indeterminateState, nativeDisabledState } from '../styles/states/index.js'; +import { + checkedState, + dirtyIndeterminateState, + indeterminateState, + nativeDisabledState, +} from '../styles/states/index.js'; import { borderRadiusCircular, borderRadiusMedium, @@ -105,16 +110,20 @@ export const styles = css` } :host(${checkedState}), - :host(${indeterminateState}) { + :host( + :is(${indeterminateState}, [defaultindeterminate]:not(${dirtyIndeterminateState})):not(${nativeDisabledState}) + ) { border-color: ${colorCompoundBrandStroke}; } :host(${checkedState}), - :host(${indeterminateState}) .indeterminate-indicator { + :host(${indeterminateState}) .indeterminate-indicator, + :host([defaultindeterminate]:not(${dirtyIndeterminateState}):not(${checkedState})) .indeterminate-indicator { background-color: ${colorCompoundBrandBackground}; } - :host(${indeterminateState}) .indeterminate-indicator { + :host(${indeterminateState}) .indeterminate-indicator, + :host([defaultindeterminate]:not(${dirtyIndeterminateState}):not(${checkedState})) .indeterminate-indicator { border-radius: ${borderRadiusSmall}; position: absolute; width: calc(var(--size) / 2); @@ -145,7 +154,9 @@ export const styles = css` cursor: unset; } - :host(${nativeDisabledState}${indeterminateState}) .indeterminate-indicator { + :host(${nativeDisabledState}${indeterminateState}) .indeterminate-indicator, + :host(${nativeDisabledState}[defaultindeterminate]:not(${dirtyIndeterminateState}):not(${checkedState})) + .indeterminate-indicator { background-color: ${colorNeutralStrokeDisabled}; } @@ -174,12 +185,15 @@ export const styles = css` } :host(${checkedState}), - :host(${indeterminateState}) .indeterminate-indicator { + :host(${indeterminateState}) .indeterminate-indicator, + :host([defaultindeterminate]:not(${dirtyIndeterminateState}):not(${checkedState})) .indeterminate-indicator { background-color: FieldText; } :host(${checkedState}:not(${nativeDisabledState}):hover), - :host(${indeterminateState}:not(${nativeDisabledState}):hover) .indeterminate-indicator { + :host(${indeterminateState}:not(${nativeDisabledState}):hover) .indeterminate-indicator, + :host([defaultindeterminate]:not(${dirtyIndeterminateState}):not(${checkedState}):not(${nativeDisabledState}):hover) + .indeterminate-indicator { background-color: Highlight; } @@ -187,7 +201,9 @@ export const styles = css` border-color: GrayText; } - :host(${nativeDisabledState}${indeterminateState}) .indeterminate-indicator { + :host(${nativeDisabledState}${indeterminateState}) .indeterminate-indicator, + :host(${nativeDisabledState}[defaultindeterminate]:not(${dirtyIndeterminateState}):not(${checkedState})) + .indeterminate-indicator { background-color: GrayText; } diff --git a/packages/web-components/src/checkbox/checkbox.ts b/packages/web-components/src/checkbox/checkbox.ts index 4936f229710f15..2b910721209dc6 100644 --- a/packages/web-components/src/checkbox/checkbox.ts +++ b/packages/web-components/src/checkbox/checkbox.ts @@ -17,6 +17,37 @@ import { CheckboxShape, CheckboxSize } from './checkbox.options.js'; * @public */ export class Checkbox extends BaseCheckbox { + /** + * Indicates that the indeterminate state has been changed by the user. + * + * @internal + */ + private dirtyIndeterminate: boolean = false; + + /** + * The initial indeterminate state of the element. + * + * @public + * @remarks + * HTML Attribute: `defaultindeterminate` + */ + @attr({ attribute: 'defaultindeterminate', mode: 'boolean' }) + public defaultIndeterminate?: boolean; + + /** + * Updates the indeterminate state when the `defaultindeterminate` attribute changes, + * unless the indeterminate state has been changed by the user. + * + * @param prev - The previous initial indeterminate state + * @param next - The current initial indeterminate state + * @internal + */ + protected defaultIndeterminateChanged(prev: boolean | undefined, next: boolean | undefined): void { + if (!this.dirtyIndeterminate) { + this.indeterminate = !!next; + } + } + /** * Indicates that the element is in an indeterminate or mixed state. * @@ -62,6 +93,40 @@ export class Checkbox extends BaseCheckbox { this.elementInternals.role = 'checkbox'; } + /** + * Toggles the checked state when the user clicks the element. + * + * @param e - the event object + * @internal + */ + public clickHandler(e: MouseEvent): boolean | void { + if (this.disabled) { + return; + } + + this.dirtyIndeterminate = true; + toggleState(this.elementInternals, 'dirty-indeterminate', true); + return super.clickHandler(e); + } + + /** + * Resets the form value to its initial value when the form is reset. + * + * @internal + */ + formResetCallback(): void { + const shouldResetIndeterminate = this.hasAttribute('defaultindeterminate'); + + super.formResetCallback(); + + if (shouldResetIndeterminate) { + this.indeterminate = !!this.defaultIndeterminate; + } + + this.dirtyIndeterminate = false; + toggleState(this.elementInternals, 'dirty-indeterminate', false); + } + /** * Sets the ARIA checked state. If the `indeterminate` flag is true, the value will be 'mixed'. * diff --git a/packages/web-components/src/styles/states/index.ts b/packages/web-components/src/styles/states/index.ts index fe06c0764bf492..ff83229f4a754f 100644 --- a/packages/web-components/src/styles/states/index.ts +++ b/packages/web-components/src/styles/states/index.ts @@ -75,6 +75,12 @@ export const hasMessageState = stateSelector('has-message'); */ export const indeterminateState = stateSelector('indeterminate'); +/** + * Selector for the dirty indeterminate state. + * @internal + */ +export const dirtyIndeterminateState = stateSelector('dirty-indeterminate'); + /** * Selector for the `multiselect` state. * @public