From 4c62603cbae827dd8decdcb326dec3aa9191df94 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 27 Jun 2024 11:52:27 -0700 Subject: [PATCH 01/14] add getRootActiveElement utility function --- .../web-components/src/utils/root-active-element.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 packages/web-components/src/utils/root-active-element.ts diff --git a/packages/web-components/src/utils/root-active-element.ts b/packages/web-components/src/utils/root-active-element.ts new file mode 100644 index 00000000000000..1f6e7a44f78835 --- /dev/null +++ b/packages/web-components/src/utils/root-active-element.ts @@ -0,0 +1,10 @@ +// returns the active element in the shadow context of the element in question. +export function getRootActiveElement(element: Element): Element | null { + const rootNode = element.getRootNode(); + + if (rootNode instanceof ShadowRoot) { + return rootNode.activeElement; + } + + return document.activeElement; +} From ac87c522d432ea523b01990ec79e06cd6bf5da9d Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 27 Jun 2024 12:02:27 -0700 Subject: [PATCH 02/14] move checkedState and disabledState to states module --- .../web-components/src/checkbox/checkbox.styles.ts | 8 +------- packages/web-components/src/styles/states/index.ts | 12 ++++++++++++ packages/web-components/src/switch/switch.styles.ts | 10 +++------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/web-components/src/checkbox/checkbox.styles.ts b/packages/web-components/src/checkbox/checkbox.styles.ts index 9b1abfb1082318..e68c2d860f0a5b 100644 --- a/packages/web-components/src/checkbox/checkbox.styles.ts +++ b/packages/web-components/src/checkbox/checkbox.styles.ts @@ -1,4 +1,5 @@ import { css } from '@microsoft/fast-element'; +import { checkedState, circularState, largeState } from '../styles/states/index.js'; import { borderRadiusCircular, borderRadiusMedium, @@ -23,13 +24,6 @@ import { } from '../theme/design-tokens.js'; import { forcedColorsStylesheetBehavior } from '../utils/behaviors/match-media-stylesheet-behavior.js'; import { display } from '../utils/display.js'; -import { circularState, largeState } from '../styles/states/index.js'; - -/** - * Selector for the `checked` state. - * @public - */ -const checkedState = css.partial`:is([state--checked], :state(checked))`; /** * Selector for the `indeterminate` state. diff --git a/packages/web-components/src/styles/states/index.ts b/packages/web-components/src/styles/states/index.ts index 911040e896c983..5abf24b1c35bd9 100644 --- a/packages/web-components/src/styles/states/index.ts +++ b/packages/web-components/src/styles/states/index.ts @@ -1,5 +1,17 @@ import { css } from '@microsoft/fast-element'; +/** + * Selector for the `checked` state. + * @public + */ +export const checkedState = css.partial`:is([state--checked], :state(checked))`; + +/** + * Selector for the `disabled` state. + * @public + */ +export const disabledState = css.partial`:is([state--disabled], :state(disabled))`; + /** * Selector for the `filled-lighter` state. * @public diff --git a/packages/web-components/src/switch/switch.styles.ts b/packages/web-components/src/switch/switch.styles.ts index 9edab8cffae2b8..e642d3fb9ec256 100644 --- a/packages/web-components/src/switch/switch.styles.ts +++ b/packages/web-components/src/switch/switch.styles.ts @@ -1,5 +1,5 @@ import { css } from '@microsoft/fast-element'; -import { display, forcedColorsStylesheetBehavior } from '../utils/index.js'; +import { checkedState } from '../styles/states/index.js'; import { borderRadiusCircular, colorCompoundBrandBackground, @@ -26,12 +26,8 @@ import { spacingHorizontalXXS, strokeWidthThick, } from '../theme/design-tokens.js'; - -/** - * Selector for the `checked` state. - * @public - */ -const checkedState = css.partial`:is([state--checked], :state(checked))`; +import { forcedColorsStylesheetBehavior } from '../utils/behaviors/match-media-stylesheet-behavior.js'; +import { display } from '../utils/display.js'; export const styles = css` ${display('inline-flex')} From 44a92aed07905d94fb08c4f0c29786bd3b70ed4f Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 27 Jun 2024 12:09:36 -0700 Subject: [PATCH 03/14] adjust base checkbox ARIA and elementInternals to improve extensibility --- .../src/checkbox/checkbox.spec.ts | 36 ++- .../web-components/src/checkbox/checkbox.ts | 205 ++++++++++++------ .../web-components/src/switch/switch.spec.ts | 6 +- packages/web-components/src/switch/switch.ts | 7 +- 4 files changed, 169 insertions(+), 85 deletions(-) diff --git a/packages/web-components/src/checkbox/checkbox.spec.ts b/packages/web-components/src/checkbox/checkbox.spec.ts index adacd38386bbf6..aeeb7463e5b9bb 100644 --- a/packages/web-components/src/checkbox/checkbox.spec.ts +++ b/packages/web-components/src/checkbox/checkbox.spec.ts @@ -14,17 +14,21 @@ test.describe('Checkbox', () => { await expect(element).toHaveCount(1); - await page.setContent(/* html */ ` - - `); - - await expect(element).toHaveJSProperty('shape', 'circular'); + await test.step('should set the `shape` property to `circular`', async () => { + await page.setContent(/* html */ ` + + `); - await element.evaluate((node: Checkbox) => { - node.shape = 'square'; + await expect(element).toHaveJSProperty('shape', 'circular'); }); - await expect(element).toHaveAttribute('shape', 'square'); + await test.step('should set the `shape` attribute to `square`', async () => { + await element.evaluate((node: Checkbox) => { + node.shape = 'square'; + }); + + await expect(element).toHaveAttribute('shape', 'square'); + }); }); test('should add a custom state matching the `shape` attribute when provided', async ({ page }) => { @@ -62,13 +66,21 @@ test.describe('Checkbox', () => { node.size = 'medium'; }); - await expect(element).toHaveAttribute('size', 'medium'); + await test.step('should set the `size` attribute to `medium`', async () => { + await element.evaluate((node: Checkbox) => { + node.size = 'medium'; + }); - await element.evaluate((node: Checkbox) => { - node.setAttribute('size', 'large'); + await expect(element).toHaveAttribute('size', 'medium'); }); - await expect(element).toHaveJSProperty('size', 'large'); + await test.step('should set the `size` property to `large`', async () => { + await element.evaluate((node: Checkbox) => { + node.setAttribute('size', 'large'); + }); + + await expect(element).toHaveJSProperty('size', 'large'); + }); }); test('should add a custom state matching the `size` attribute when provided', async ({ page }) => { diff --git a/packages/web-components/src/checkbox/checkbox.ts b/packages/web-components/src/checkbox/checkbox.ts index 5e1a6bf7542ed1..fdf2ab2fb6123d 100644 --- a/packages/web-components/src/checkbox/checkbox.ts +++ b/packages/web-components/src/checkbox/checkbox.ts @@ -3,13 +3,7 @@ import { toggleState } from '../utils/element-internals.js'; import { CheckboxShape, CheckboxSize } from './checkbox.options.js'; /** - * A Checkbox Custom HTML Element. - * Implements the {@link https://www.w3.org/TR/wai-aria-1.1/#checkbox | ARIA checkbox }. - * - * @slot checked-indicator - The checked indicator - * @slot indeterminate-indicator - The indeterminate indicator - * @fires change - Emits a custom change event when the checked state changes - * @fires input - Emits a custom input event when the checked state changes + * The base class for a component with a toggleable checked state. * * @public */ @@ -32,58 +26,68 @@ export class BaseCheckbox extends FASTElement { */ public get checked(): boolean { Observable.track(this, 'checked'); - return this._checked; + return !!this._checked; } - public set checked(state: boolean) { - this._checked = state; - - this.setFormValue(state ? this.value : null); + public set checked(next: boolean) { + this._checked = next; + Observable.notify(this, 'checked'); + this.setFormValue(next ? this.value : null); this.setValidity(); this.setAriaChecked(); - toggleState(this.elementInternals, 'checked', state); - - Observable.notify(this, 'checked'); + toggleState(this.elementInternals, 'checked', next); } /** - * The element's disabled state. + * The disabled state of the control. + * * @public - * @remarks - * HTML Attribute: `disabled` */ - @attr({ mode: 'boolean' }) - public disabled: boolean = false; + @observable + public disabled?: boolean; /** - * The id of a form to associate the element to. - * @see The {@link https://developer.mozilla.org/docs/Web/HTML/Element/input#form | `form`} attribute + * Toggles the disabled state when the user changes the `disabled` property. * - * @public - * @remarks - * HTML Attribute: `form` + * @internal */ - @attr({ attribute: 'form' }) - public formAttribute?: string; + protected disabledChanged(prev: boolean | undefined, next: boolean | undefined): void { + this.elementInternals.ariaDisabled = this.disabled ? 'true' : 'false'; + toggleState(this.elementInternals, 'disabled', this.disabled); + } /** - * Indicates that the element is in an indeterminate or mixed state. + * The initial disabled state of the control. * * @public + * @remarks + * HTML Attribute: `disabled` */ - @observable - public indeterminate?: boolean; + @attr({ attribute: 'disabled', mode: 'boolean' }) + public disabledAttribute?: boolean; /** - * Synchronizes the element's indeterminate state with the internal ElementInternals state. + * Sets the disabled state when the `disabled` attribute changes. * + * @param prev - the previous value + * @param next - the current value * @internal */ - public indeterminateChanged(prev: boolean, next: boolean): void { - this.setAriaChecked(); - toggleState(this.elementInternals, 'indeterminate', next); + public disabledAttributeChanged(prev: boolean | undefined, next: boolean | undefined): void { + this.disabled = !!next; } + /** + * The id of a form to associate the element to. + * @see The {@link https://developer.mozilla.org/docs/Web/HTML/Element/input#form | `form`} attribute + * + * @public + * @remarks + * HTML Attribute: `form` + */ + @attr({ attribute: 'form' }) + public formAttribute?: string; + /** * The element's checked state. * @@ -99,9 +103,9 @@ export class BaseCheckbox extends FASTElement { * * @internal */ - public initialCheckedChanged(prev: boolean | undefined, next: boolean): void { + public initialCheckedChanged(prev: boolean | undefined, next: boolean | undefined): void { if (!this.dirtyChecked) { - this.checked = next; + this.checked = !!next; } } @@ -156,7 +160,7 @@ export class BaseCheckbox extends FASTElement { public requiredChanged(prev: boolean, next: boolean): void { if (this.$fastController.isConnected) { this.setValidity(); - this.elementInternals.ariaRequired = `${!!next}`; + this.elementInternals.ariaRequired = this.required ? 'true' : 'false'; } } @@ -165,7 +169,7 @@ export class BaseCheckbox extends FASTElement { * * @internal */ - private _checked: boolean = this.initialChecked ?? false; + private _checked?: boolean; /** * Indicates that the checked state has been changed by the user. @@ -205,8 +209,8 @@ export class BaseCheckbox extends FASTElement { * * @public */ - public get labels(): ReadonlyArray { - return Object.freeze(Array.from(this.elementInternals.labels)); + public get labels(): ReadonlyArray { + return Object.freeze(Array.from(this.elementInternals.labels) as HTMLLabelElement[]); } /** @@ -290,15 +294,6 @@ export class BaseCheckbox extends FASTElement { return this.elementInternals.willValidate; } - /** - * Sets the `elementInternals.ariaChecked` value based on the checked state. - * - * @internal - */ - private setAriaChecked(): void { - this.elementInternals.ariaChecked = this.indeterminate ? 'mixed' : `${this.checked}`; - } - /** * Checks the validity of the element and returns the result. * @@ -322,25 +317,26 @@ export class BaseCheckbox extends FASTElement { } this.dirtyChecked = true; + + const previousChecked = this.checked; + this.toggleChecked(); - this.$emit('change'); - this.$emit('input'); + + if (previousChecked !== this.checked) { + this.$emit('change'); + this.$emit('input'); + } + return true; } - public connectedCallback() { + connectedCallback() { super.connectedCallback(); - this.setFormValue(this.checked ? this.value : null); this.setAriaChecked(); this.setValidity(); } - constructor() { - super(); - this.elementInternals.role = 'checkbox'; - } - /** * Updates the form value when a user changes the `checked` state. * @@ -348,7 +344,7 @@ export class BaseCheckbox extends FASTElement { * @internal */ public inputHandler(e: Event): boolean | void { - this.elementInternals.setFormValue(this.value); + this.setFormValue(this.value); this.setValidity(); return true; @@ -388,7 +384,6 @@ export class BaseCheckbox extends FASTElement { formResetCallback(): void { this.checked = this.initialChecked ?? false; this.dirtyChecked = false; - this.indeterminate = false; this.setValidity(); } @@ -403,6 +398,16 @@ export class BaseCheckbox extends FASTElement { return this.elementInternals.reportValidity(); } + /** + * Sets the ARIA checked state. + * + * @param value - The value to set + * @internal + */ + protected setAriaChecked(value: boolean = this.checked) { + this.elementInternals.ariaChecked = value ? 'true' : 'false'; + } + /** * Reflects the {@link https://developer.mozilla.org/docs/Web/API/ElementInternals/setFormValue | `ElementInternals.setFormValue()`} method. * @@ -432,18 +437,18 @@ export class BaseCheckbox extends FASTElement { * * @internal */ - public setValidity( - flags: Partial = { valueMissing: !!this.required && !this.checked }, - message: string = this.validationMessage, - anchor?: HTMLElement, - ): void { + public setValidity(flags?: Partial, message?: string, anchor?: HTMLElement): void { if (this.$fastController.isConnected) { - if (this.disabled) { + if (this.disabled || !this.required) { this.elementInternals.setValidity({}); return; } - this.elementInternals.setValidity(flags, message, anchor); + this.elementInternals.setValidity( + { valueMissing: !!this.required && !this.checked, ...flags }, + message ?? this.validationMessage, + anchor, + ); } } @@ -452,13 +457,41 @@ export class BaseCheckbox extends FASTElement { * * @public */ - private toggleChecked(force: boolean = !this.checked): void { - this.indeterminate = false; + public toggleChecked(force: boolean = !this.checked): void { this.checked = force; } } +/** + * A Checkbox Custom HTML Element. + * Implements the {@link https://w3c.github.io/aria/#checkbox | ARIA checkbox }. + * + * @slot checked-indicator - The checked indicator + * @slot indeterminate-indicator - The indeterminate indicator + * @fires change - Emits a custom change event when the checked state changes + * @fires input - Emits a custom input event when the checked state changes + * + * @public + */ export class Checkbox extends BaseCheckbox { + /** + * Indicates that the element is in an indeterminate or mixed state. + * + * @public + */ + @observable + public indeterminate?: boolean; + + /** + * Synchronizes the element's indeterminate state with the internal ElementInternals state. + * + * @internal + */ + public indeterminateChanged(prev: boolean | undefined, next: boolean | undefined): void { + this.setAriaChecked(); + toggleState(this.elementInternals, 'indeterminate', next); + } + /** * Indicates the shape of the checkbox. * @@ -506,4 +539,38 @@ export class Checkbox extends BaseCheckbox { toggleState(this.elementInternals, next, true); } } + + public setAriaChecked(value: boolean = this.checked) { + if (this.indeterminate) { + this.elementInternals.ariaChecked = 'mixed'; + return; + } + + this.elementInternals.ariaChecked = value ? 'true' : 'false'; + } + + constructor() { + super(); + this.elementInternals.role = 'checkbox'; + } + + /** + * Resets the form value to its initial value when the form is reset. + * + * @internal + */ + formResetCallback(): void { + this.indeterminate = false; + super.formResetCallback(); + } + + /** + * Toggles the checked state of the control. + * + * @public + */ + public toggleChecked(force: boolean = !this.checked): void { + this.indeterminate = false; + super.toggleChecked(force); + } } diff --git a/packages/web-components/src/switch/switch.spec.ts b/packages/web-components/src/switch/switch.spec.ts index d0bf836324ef08..b8a6d05895116b 100644 --- a/packages/web-components/src/switch/switch.spec.ts +++ b/packages/web-components/src/switch/switch.spec.ts @@ -9,14 +9,14 @@ test.describe('Switch', () => { await page.waitForFunction(() => customElements.whenDefined('fluent-switch')); }); - test('should have a role of `checkbox`', async ({ page }) => { + test('should have a role of `switch`', async ({ page }) => { const element = page.locator('fluent-switch'); await page.setContent(/* html */ ` `); - await expect(element).toHaveJSProperty('elementInternals.role', 'checkbox'); + await expect(element).toHaveJSProperty('elementInternals.role', 'switch'); }); test('should set the `ariaChecked` property to `false` when `checked` is not defined', async ({ page }) => { @@ -156,7 +156,7 @@ test.describe('Switch', () => { await page.setContent(/* html */ `
- checkbox +
`); diff --git a/packages/web-components/src/switch/switch.ts b/packages/web-components/src/switch/switch.ts index b907cb3c8f9c05..1ff36ea0e654fb 100644 --- a/packages/web-components/src/switch/switch.ts +++ b/packages/web-components/src/switch/switch.ts @@ -5,4 +5,9 @@ export type SwitchOptions = { switch?: StaticallyComposableHTML; }; -export class Switch extends BaseCheckbox {} +export class Switch extends BaseCheckbox { + constructor() { + super(); + this.elementInternals.role = 'switch'; + } +} From 151df415c5049f7be0e418275c9b4b067dc60dca Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 27 Jun 2024 12:10:46 -0700 Subject: [PATCH 04/14] improve relationship between slotted labels and inputs in the field component --- .../web-components/src/field/field.options.ts | 2 + .../web-components/src/field/field.styles.ts | 49 +++++++--- .../src/field/field.template.ts | 2 +- packages/web-components/src/field/field.ts | 97 ++++++++++++++++--- 4 files changed, 118 insertions(+), 32 deletions(-) diff --git a/packages/web-components/src/field/field.options.ts b/packages/web-components/src/field/field.options.ts index 4f416273a4d393..3446324b12413b 100644 --- a/packages/web-components/src/field/field.options.ts +++ b/packages/web-components/src/field/field.options.ts @@ -23,6 +23,8 @@ export type SlottableInput = HTMLElement & required: boolean; disabled: boolean; readOnly: boolean; + checked?: boolean; + value?: string; }; /** diff --git a/packages/web-components/src/field/field.styles.ts b/packages/web-components/src/field/field.styles.ts index 87c2fafac5c459..fd70c662e40dd9 100644 --- a/packages/web-components/src/field/field.styles.ts +++ b/packages/web-components/src/field/field.styles.ts @@ -1,4 +1,5 @@ import { css } from '@microsoft/fast-element'; +import { disabledState } from '../styles/states/index.js'; import { borderRadiusMedium, colorNeutralForeground1, @@ -13,6 +14,8 @@ import { lineHeightBase300, lineHeightBase400, spacingHorizontalM, + spacingHorizontalS, + spacingVerticalM, spacingVerticalS, spacingVerticalXXS, strokeWidthThick, @@ -20,12 +23,6 @@ import { import { display } from '../utils/display.js'; import { ValidationFlags } from './field.options.js'; -/** - * Selector for the `disabled` state. - * @public - */ -const disabledState = css.partial`:is([state--disabled], :state(disabled))`; - /** * Selector for the `focus-visible` state. * @public @@ -98,6 +95,12 @@ const validState = css.partial`:is([state-${ValidationFlags.valid}], :state(${Va */ const valueMissingState = css.partial`:is([state--${ValidationFlags.valueMissing}], :state(${ValidationFlags.valueMissing}))`; +/** + * Selector for the `has-message` state. + * @public + */ +const hasMessageState = css.partial`:is([state--has-message], :state(has-message))`; + /** * The styles for the {@link Field} component. * @@ -107,11 +110,12 @@ export const styles = css` ${display('inline-grid')} :host { + color: ${colorNeutralForeground1}; align-items: center; - cursor: pointer; gap: 0 ${spacingHorizontalM}; justify-items: start; - padding: ${spacingVerticalS}; + padding: ${spacingVerticalS} ${spacingHorizontalS}; + position: relative; } :has([slot='message']) { @@ -144,15 +148,28 @@ export const styles = css` :host([label-position='below']) { grid-template-areas: 'input' 'label' 'message'; + justify-items: center; } - ::slotted([slot='label']) { - cursor: pointer; - grid-area: label; + :host([label-position='below']) ::slotted([slot='label']) { + margin-block-start: ${spacingVerticalM}; + } + + :host([label-position='below']:not(${hasMessageState})) { + grid-template-areas: 'input' 'label'; + } + + ::slotted([slot='label'])::after { + content: ''; + display: block; + position: absolute; + inset: 0; } ::slotted([slot='input']) { grid-area: input; + position: relative; + z-index: 1; } ::slotted([slot='message']) { @@ -165,13 +182,14 @@ export const styles = css` outline: ${strokeWidthThick} solid ${colorStrokeFocus2}; } - ::slotted(label) { + ::slotted(label), + ::slotted([slot='label']) { + cursor: inherit; display: inline-flex; - color: ${colorNeutralForeground1}; - cursor: pointer; font-family: ${fontFamilyBase}; font-size: ${fontSizeBase300}; font-weight: ${fontWeightRegular}; + grid-area: label; line-height: ${lineHeightBase300}; user-select: none; } @@ -191,8 +209,7 @@ export const styles = css` font-weight: ${fontWeightSemibold}; } - :host(${disabledState}) ::slotted(label) { - color: ${colorNeutralForeground1}; + :host(${disabledState}) { cursor: default; } diff --git a/packages/web-components/src/field/field.template.ts b/packages/web-components/src/field/field.template.ts index c6db3121a56748..a7f4a5d2655d90 100644 --- a/packages/web-components/src/field/field.template.ts +++ b/packages/web-components/src/field/field.template.ts @@ -20,7 +20,7 @@ export const template: ElementViewTemplate = html` filter: elements(), })} > - + diff --git a/packages/web-components/src/field/field.ts b/packages/web-components/src/field/field.ts index 9c2b55fbfd5b82..435bbf9536b175 100644 --- a/packages/web-components/src/field/field.ts +++ b/packages/web-components/src/field/field.ts @@ -1,4 +1,5 @@ import { attr, FASTElement, observable } from '@microsoft/fast-element'; +import { uniqueId } from '@microsoft/fast-web-utilities'; import { toggleState } from '../utils/element-internals.js'; import { LabelPosition, SlottableInput, ValidationFlags } from './field.options.js'; @@ -18,6 +19,27 @@ export class Field extends FASTElement { @attr({ attribute: 'label-position' }) public labelPosition: LabelPosition = LabelPosition.above; + /** + * The slotted label elements. + * + * @internal + */ + @observable + public labelSlot: Node[] = []; + + /** + * Updates attributes on the slotted label elements. + * + * @param prev - the previous list of slotted label elements + * @param next - the current list of slotted label elements + */ + protected labelSlotChanged(prev: Node[], next: Node[]) { + if (next && this.input) { + this.setLabelProperties(); + this.setStates(); + } + } + /** * The slotted message elements. Filtered to only include elements with a `flag` attribute. * @@ -34,6 +56,8 @@ export class Field extends FASTElement { * @internal */ public messageSlotChanged(prev: Element[], next: Element[]) { + toggleState(this.elementInternals, 'has-message', !!next.length); + if (!next.length) { this.removeEventListener('invalid', this.invalidHandler, { capture: true }); return; @@ -60,9 +84,8 @@ export class Field extends FASTElement { * @internal */ public slottedInputsChanged(prev: SlottableInput[] | undefined, next: SlottableInput[] | undefined) { - this.input = next?.[0] as SlottableInput; - - if (this.input) { + if (next?.length) { + this.input = next?.[0] as SlottableInput; this.setStates(); } } @@ -79,33 +102,54 @@ export class Field extends FASTElement { * * @public */ + @observable public input!: SlottableInput; + /** + * Updates the field's states and label properties when the assigned input changes. + * + * @param prev - the previous input + * @param next - the current input + */ + public inputChanged(prev: SlottableInput | undefined, next: SlottableInput | undefined) { + if (next) { + this.setStates(); + this.setLabelProperties(); + } + } + /** * Calls the `setStates` method when a `change` event is emitted from the slotted input. * * @param e - the event object * @internal */ - public changeHandler(e: Event): void { + public changeHandler(e: Event): boolean | void { this.setStates(); + this.setValidationStates(); + + return true; } /** * Redirects `click` events to the slotted input. * + * @param e - the event object * @internal */ public clickHandler(e: MouseEvent): boolean | void { - if (this.isSameNode(e.target as Node | null)) { - this.input.focus(); + if (this === e.target) { this.input.click(); - return; } return true; } + constructor() { + super(); + this.elementInternals.role = 'presentation'; + } + /** * Applies the `focus-visible` state to the element when the slotted input receives visible focus. * @@ -113,7 +157,7 @@ export class Field extends FASTElement { * @internal */ public focusinHandler(e: FocusEvent): boolean | void { - if ((e.target as HTMLElement).matches(':focus-visible')) { + if (this.matches(':focus-within:has(> :focus-visible)')) { toggleState(this.elementInternals, 'focus-visible', true); } @@ -142,7 +186,27 @@ export class Field extends FASTElement { e.preventDefault(); } - this.setStates(); + this.setValidationStates(); + } + + /** + * Sets ARIA and form-related attributes on slotted label elements. + * + * @internal + */ + private setLabelProperties() { + if (this.$fastController.isConnected) { + this.input.id = this.input.id || uniqueId('input'); + + this.labelSlot?.forEach(label => { + if (label instanceof HTMLLabelElement) { + label.htmlFor = label.htmlFor || this.input.id; + label.id = label.id || `${this.input.id}--label`; + label.setAttribute('aria-hidden', 'true'); + this.input.setAttribute('aria-labelledby', label.id); + } + }); + } } /** @@ -155,14 +219,17 @@ export class Field extends FASTElement { toggleState(this.elementInternals, 'disabled', !!this.input.disabled); toggleState(this.elementInternals, 'readonly', !!this.input.readOnly); toggleState(this.elementInternals, 'required', !!this.input.required); + toggleState(this.elementInternals, 'checked', !!this.input.checked); + } + } - if (!this.input.validity) { - return; - } + public setValidationStates() { + if (!this.input.validity) { + return; + } - for (const [flag, value] of Object.entries(ValidationFlags)) { - toggleState(this.elementInternals, value, !!this.input.validity[flag as keyof ValidityState]); - } + for (const [flag, value] of Object.entries(ValidationFlags)) { + toggleState(this.elementInternals, value, this.input.validity[flag as keyof ValidityState]); } } } From 8feeeae6fd4c701c03f6765ce2f18e7f6394d473 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 27 Jun 2024 12:10:26 -0700 Subject: [PATCH 05/14] use ElementInternals in radio component --- packages/web-components/src/radio/index.ts | 2 +- .../src/radio/radio.form-associated.ts | 13 - .../web-components/src/radio/radio.options.ts | 17 ++ .../web-components/src/radio/radio.spec.ts | 269 ++++++++---------- .../web-components/src/radio/radio.stories.ts | 58 ++-- .../web-components/src/radio/radio.styles.ts | 209 +++++++------- .../src/radio/radio.template.ts | 49 ++-- packages/web-components/src/radio/radio.ts | 138 ++++----- 8 files changed, 353 insertions(+), 402 deletions(-) delete mode 100644 packages/web-components/src/radio/radio.form-associated.ts create mode 100644 packages/web-components/src/radio/radio.options.ts diff --git a/packages/web-components/src/radio/index.ts b/packages/web-components/src/radio/index.ts index 4146614388b1e4..1cf1410def73a3 100644 --- a/packages/web-components/src/radio/index.ts +++ b/packages/web-components/src/radio/index.ts @@ -1,5 +1,5 @@ export { definition as RadioDefinition } from './radio.definition.js'; export { Radio } from './radio.js'; -export type { RadioControl, RadioOptions } from './radio.js'; +export type { RadioControl, RadioOptions } from './radio.options.js'; export { styles as RadioStyles } from './radio.styles.js'; export { template as RadioTemplate } from './radio.template.js'; diff --git a/packages/web-components/src/radio/radio.form-associated.ts b/packages/web-components/src/radio/radio.form-associated.ts deleted file mode 100644 index dcb8e3df668371..00000000000000 --- a/packages/web-components/src/radio/radio.form-associated.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FASTElement } from '@microsoft/fast-element'; -import { CheckableFormAssociated } from '../form-associated/form-associated.js'; - -class _Radio extends FASTElement {} -/* eslint-disable-next-line @typescript-eslint/no-empty-interface */ -interface _Radio extends CheckableFormAssociated {} - -/** - * @beta - */ -export class FormAssociatedRadio extends CheckableFormAssociated(_Radio) { - proxy = document.createElement('input'); -} diff --git a/packages/web-components/src/radio/radio.options.ts b/packages/web-components/src/radio/radio.options.ts new file mode 100644 index 00000000000000..98736f326b990b --- /dev/null +++ b/packages/web-components/src/radio/radio.options.ts @@ -0,0 +1,17 @@ +import type { StaticallyComposableHTML } from '../utils/template-helpers.js'; +import type { Radio } from './radio.js'; + +/** + * @public + */ +export type RadioControl = Pick; + +/** + * Radio configuration options + * @public + */ +export type RadioOptions = { + checkedIndicator?: StaticallyComposableHTML; +}; + +export type { CheckboxSize as RadioSize } from '../checkbox/checkbox.options.js'; diff --git a/packages/web-components/src/radio/radio.spec.ts b/packages/web-components/src/radio/radio.spec.ts index c162ee4311ce9a..1433540352d7fc 100644 --- a/packages/web-components/src/radio/radio.spec.ts +++ b/packages/web-components/src/radio/radio.spec.ts @@ -1,168 +1,128 @@ import { expect, test } from '@playwright/test'; -import type { Locator, Page } from '@playwright/test'; import { fixtureURL } from '../helpers.tests.js'; import type { Radio } from './radio.js'; test.describe('Radio', () => { - let page: Page; - let element: Locator; - let root: Locator; - - test.beforeAll(async ({ browser }) => { - page = await browser.newPage(); - - element = page.locator('fluent-radio'); - - root = page.locator('#root'); - + test.beforeEach(async ({ page }) => { await page.goto(fixtureURL('components-radio--radio')); - }); - - test.afterAll(async () => { - await page.close(); - }); - test('should have a role of `radio`', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - Radio - `; - }); - - await expect(element).toHaveAttribute('role', 'radio'); + await page.waitForFunction(() => customElements.whenDefined('fluent-radio')); }); - test('should set ARIA attributes to match the state', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - Radio - `; - }); - - // Checked - await expect(element).toHaveAttribute('aria-checked', 'false'); + test('should have a role of `radio`', async ({ page }) => { + const element = page.locator('fluent-radio'); - await element.evaluate((node: Radio) => (node.checked = true)); + await page.setContent(/* html */ ` + Radio + `); - await expect(element).toHaveAttribute('aria-checked', 'true'); + await expect(element).toHaveJSProperty('elementInternals.role', 'radio'); + }); - await element.evaluate((node: Radio) => (node.checked = false)); + test('should set ARIA attributes to match the state', async ({ page }) => { + const element = page.locator('fluent-radio'); - await expect(element).toHaveAttribute('aria-checked', 'false'); + await page.setContent(/* html */ ` + Radio + `); - // Required - await expect(element).toHaveAttribute('aria-required', 'false'); + await test.step('ariaChecked', async () => { + await expect(element).toHaveJSProperty('elementInternals.ariaChecked', 'false'); - await element.evaluate((node: Radio) => (node.required = true)); + await element.evaluate((node: Radio) => (node.checked = true)); - await expect(element).toHaveAttribute('aria-required', 'true'); + await expect(element).toHaveJSProperty('elementInternals.ariaChecked', 'true'); - await element.evaluate((node: Radio) => (node.required = false)); + await element.evaluate((node: Radio) => (node.checked = false)); - await expect(element).toHaveAttribute('aria-required', 'false'); + await expect(element).toHaveJSProperty('elementInternals.ariaChecked', 'false'); + }); - // Disabled - await expect(element).toHaveAttribute('aria-disabled', 'false'); + await test.step('ariaDisabled', async () => { + await expect(element).toHaveJSProperty('elementInternals.ariaDisabled', null); - await element.evaluate((node: Radio) => (node.disabled = true)); + await element.evaluate((node: Radio) => (node.disabled = true)); - await expect(element).toHaveAttribute('aria-disabled', 'true'); + await expect(element).toHaveJSProperty('elementInternals.ariaDisabled', 'true'); - await element.evaluate((node: Radio) => (node.disabled = false)); + await element.evaluate((node: Radio) => (node.disabled = false)); - await expect(element).toHaveAttribute('aria-disabled', 'false'); + await expect(element).toHaveJSProperty('elementInternals.ariaDisabled', 'false'); + }); }); - test('should set a tabindex of 0 on the element', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - Radio - `; - }); + test('should set a tabindex of 0 on the element', async ({ page }) => { + const element = page.locator('fluent-radio'); + + await page.setContent(/* html */ ` + Radio + `); await expect(element).toHaveAttribute('tabindex', '0'); }); - test('should NOT set a tabindex when disabled is `true`', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - `; - }); + test('should NOT set a tabindex when disabled is `true`', async ({ page }) => { + const element = page.locator('fluent-radio'); + + await page.setContent(/* html */ ` + + `); await expect(element).not.toHaveAttribute('tabindex', ''); }); - test('should initialize to the initial value if no value property is set', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - Radio - `; - }); + test('should initialize to the initial value if no value property is set', async ({ page }) => { + const element = page.locator('fluent-radio'); + + await page.setContent(/* html */ ` + Radio + `); await expect(element).toHaveJSProperty('value', 'on'); await expect(element).toHaveJSProperty('initialValue', 'on'); }); - test('should initialize to the provided value attribute if set pre-connection', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - Radio - `; - }); + test('should initialize to the provided value attribute if set pre-connection', async ({ page }) => { + const element = page.locator('fluent-radio'); + + await page.setContent(/* html */ ` + Radio + `); await element.evaluate((node: Radio) => node.setAttribute('value', 'foo')); await expect(element).toHaveJSProperty('value', 'foo'); }); - test('should initialize to the provided value attribute if set post-connection', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - Radio - `; - }); + test('should initialize to the provided value attribute if set post-connection', async ({ page }) => { + const element = page.locator('fluent-radio'); + + await page.setContent(/* html */ ` + Radio + `); await element.evaluate((node: Radio) => node.setAttribute('value', 'foo')); await expect(element).toHaveJSProperty('value', 'foo'); }); - test('should initialize to the provided value property if set pre-connection', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - `; - }); + test('should initialize to the provided value property if set pre-connection', async ({ page }) => { + const element = page.locator('fluent-radio'); + + await page.setContent(/* html */ ` + + `); await expect(element).toHaveJSProperty('value', 'foo'); }); - test('should set the `label__hidden` class on the internal label when default slotted content does not exist', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - label - `; - }); - - const label = element.locator('label'); - - await expect(label).toHaveClass(/^label$/); + test('should fire an event when spacebar is pressed', async ({ page }) => { + const element = page.locator('fluent-radio'); - await element.evaluate(node => { - node.textContent = ''; - }); - - await expect(label).toHaveClass(/label__hidden/); - }); - - test('should fire an event when spacebar is pressed', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - Radio - `; - }); + await page.setContent(/* html */ ` + Radio + `); const [wasPressed] = await Promise.all([ element.evaluate( @@ -180,12 +140,12 @@ test.describe('Radio', () => { expect(wasPressed).toBeTruthy(); }); - test('should NOT fire events when clicked', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - Radio - `; - }); + test('should NOT fire events when clicked', async ({ page }) => { + const element = page.locator('fluent-radio'); + + await page.setContent(/* html */ ` + Radio + `); const [wasClicked] = await Promise.all([ element.evaluate( @@ -205,82 +165,87 @@ test.describe('Radio', () => { }); test.describe('whose parent form has its reset() method invoked', () => { - test('should set its checked property to false if the checked attribute is unset', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` -
- Radio -
- `; - }); - + test('should set its checked property to false if the checked attribute is unset', async ({ page }) => { + const element = page.locator('fluent-radio'); const form = page.locator('form'); - await element.evaluate((node: Radio) => (node.checked = true)); + await page.setContent(/* html */ ` +
+ Radio +
+ `); - await expect(element).toBeChecked(); + await element.evaluate((node: Radio) => { + node.checked = true; + }); + + await expect(element).toHaveJSProperty('checked', true); await form.evaluate((node: HTMLFormElement) => { node.reset(); }); - await expect(element).not.toBeChecked(); + await expect(element).toHaveJSProperty('checked', false); }); - test('should set its checked property to true if the checked attribute is set', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` -
- -
- `; - }); - + test('should set its `checked` property to true if the `checked` attribute is set', async ({ page }) => { + const element = page.locator('fluent-radio'); const form = page.locator('form'); - expect(await element.evaluate(node => node.hasAttribute('checked'))).toBeTruthy(); + await page.setContent(/* html */ ` +
+ +
+ `); - await expect(element).toBeChecked(); + await expect(element).toHaveAttribute('checked'); + + await expect(element).toHaveJSProperty('checked', true); await element.evaluate((node: Radio) => (node.checked = false)); - await expect(element).not.toBeChecked(); + await expect(element).toHaveAttribute('checked'); + + await expect(element).toHaveJSProperty('checked', false); await form.evaluate((node: HTMLFormElement) => { node.reset(); }); - await expect(element).toBeChecked(); - }); + await expect(element).toHaveAttribute('checked'); - test('should put the control into a clean state, where `checked` attribute modifications modify the `checked` property prior to user or programmatic interaction', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` -
- Radio -
- `; - }); + await expect(element).toHaveJSProperty('checked', true); + }); + test('should put the control into a clean state, where `checked` attribute modifications modify the `checked` property prior to user or programmatic interaction', async ({ + page, + }) => { + const element = page.locator('fluent-radio'); const form = page.locator('form'); + await page.setContent(/* html */ ` +
+ Radio +
+ `); + await element.evaluate((node: Radio) => { node.checked = true; }); await element.evaluate(node => node.removeAttribute('checked')); - await expect(element).toBeChecked(); + await expect(element).toHaveJSProperty('checked', true); await form.evaluate((node: HTMLFormElement) => { node.reset(); }); - await expect(element).not.toBeChecked(); + await expect(element).toHaveJSProperty('checked', false); await element.evaluate(node => node.setAttribute('checked', '')); - await expect(element).toBeChecked(); + await expect(element).toHaveJSProperty('checked', true); }); }); }); diff --git a/packages/web-components/src/radio/radio.stories.ts b/packages/web-components/src/radio/radio.stories.ts index be7f648996d688..22044eb9cf0fbb 100644 --- a/packages/web-components/src/radio/radio.stories.ts +++ b/packages/web-components/src/radio/radio.stories.ts @@ -1,24 +1,32 @@ import { html } from '@microsoft/fast-element'; -import type { Args, Meta } from '@storybook/html'; +import { uniqueId } from '@microsoft/fast-web-utilities'; +import type { Meta, Story, StoryArgs } from '../helpers.stories.js'; import { renderComponent } from '../helpers.stories.js'; import type { Radio as FluentRadio } from './radio.js'; -type RadioStoryArgs = Args & FluentRadio; -type RadioStoryMeta = Meta; +const storyTemplate = html>` + +`; -const storyTemplate = html` -
- Option 1 -
+const fieldTemplate = html>` + + + ${storyTemplate} + `; export default { title: 'Components/Radio', args: { - checked: false, - disabled: false, + slot: 'input', }, argTypes: { checked: { @@ -47,15 +55,29 @@ export default { }, }, }, + slot: { table: { disable: true } }, }, -} as RadioStoryMeta; +} as Meta; -export const Radio = renderComponent(storyTemplate).bind({}); +export const Radio: Story = renderComponent(storyTemplate).bind({}); +Radio.args = { + slot: null, + checked: false, + disabled: false, +}; -export const Checked = renderComponent(html` - Apple +export const Checked: Story = renderComponent(html>` + `); -export const Disabled = renderComponent(html` - Apple -`); +export const Disabled: Story = renderComponent(fieldTemplate).bind({}); +Disabled.args = { + value: 'Disabled Radio', + disabled: true, +}; + +export const Field: Story = renderComponent(fieldTemplate).bind({}); +Field.args = { + id: uniqueId('radio-'), + value: 'Apple', +}; diff --git a/packages/web-components/src/radio/radio.styles.ts b/packages/web-components/src/radio/radio.styles.ts index 1651a33fae3e90..99fbc204f886f7 100644 --- a/packages/web-components/src/radio/radio.styles.ts +++ b/packages/web-components/src/radio/radio.styles.ts @@ -1,142 +1,145 @@ import { css } from '@microsoft/fast-element'; -import { display, forcedColorsStylesheetBehavior } from '../utils/index.js'; +import { checkedState, disabledState } from '../styles/states/index.js'; import { borderRadiusCircular, - borderRadiusSmall, - colorCompoundBrandForeground1, - colorCompoundBrandForeground1Pressed, - colorCompoundBrandStrokeHover, - colorCompoundBrandStrokePressed, - colorNeutralForeground2, - colorNeutralForeground3, - colorNeutralForegroundDisabled, + borderRadiusMedium, + colorCompoundBrandBackground, + colorCompoundBrandBackgroundHover, + colorCompoundBrandBackgroundPressed, + colorCompoundBrandStroke, + colorNeutralBackground1, + colorNeutralBackgroundDisabled, + colorNeutralForegroundInverted, colorNeutralStrokeAccessible, colorNeutralStrokeAccessibleHover, colorNeutralStrokeAccessiblePressed, - colorStrokeFocus1, + colorNeutralStrokeDisabled, colorStrokeFocus2, - fontFamilyBase, - fontSizeBase300, - fontWeightRegular, - lineHeightBase300, - spacingHorizontalS, - spacingHorizontalXS, - spacingVerticalS, + colorTransparentStroke, + strokeWidthThick, + strokeWidthThin, } from '../theme/design-tokens.js'; +import { forcedColorsStylesheetBehavior } from '../utils/behaviors/match-media-stylesheet-behavior.js'; +import { display } from '../utils/display.js'; -/** Radio styles +/** + * Styles for the Radio component + * * @public */ export const styles = css` - ${display('inline-grid')} + ${display('inline-flex')} :host { - grid-auto-flow: column; - grid-template-columns: max-content; - gap: ${spacingHorizontalXS}; - align-items: center; - height: 32px; - cursor: pointer; - outline: none; - position: relative; - user-select: none; - color: blue; - color: var(--state-color, ${colorNeutralForeground3}); - padding-inline-end: ${spacingHorizontalS}; - --control-border-color: ${colorNeutralStrokeAccessible}; - --checked-indicator-background-color: ${colorCompoundBrandForeground1}; - --state-color: ${colorNeutralForeground3}; - } - :host([disabled]) { - --control-border-color: ${colorNeutralForegroundDisabled}; - --checked-indicator-background-color: ${colorNeutralForegroundDisabled}; - --state-color: ${colorNeutralForegroundDisabled}; - } - .label { - cursor: pointer; - font-family: ${fontFamilyBase}; - font-size: ${fontSizeBase300}; - font-weight: ${fontWeightRegular}; - line-height: ${lineHeightBase300}; - } - .label__hidden { - display: none; - } - .control { - box-sizing: border-box; - align-items: center; - border: 1px solid var(--control-border-color, ${colorNeutralStrokeAccessible}); + --size: 16px; + aspect-ratio: 1; + background-color: ${colorNeutralBackground1}; + border: ${strokeWidthThin} solid ${colorNeutralStrokeAccessible}; border-radius: ${borderRadiusCircular}; - display: flex; - height: 16px; - justify-content: center; - margin: ${spacingVerticalS} ${spacingHorizontalS}; + box-sizing: border-box; position: relative; - width: 16px; - justify-self: center; + width: var(--size); + } + + :host([size='large']) { + --size: 20px; } + .checked-indicator { + aspect-ratio: 1; border-radius: ${borderRadiusCircular}; - height: 10px; - opacity: 0; - width: 10px; + color: ${colorNeutralForegroundInverted}; + inset: 0; + margin: auto; + position: absolute; + width: calc(var(--size) * 0.625); } - :host([aria-checked='false']:hover) .control { - color: ${colorNeutralForeground2}; + + :host(:not([slot='input']))::after { + content: '' / ''; + position: absolute; + display: block; + inset: -8px; + box-sizing: border-box; + outline: none; + border: ${strokeWidthThick} solid ${colorTransparentStroke}; + border-radius: ${borderRadiusMedium}; } - :host(:focus-visible) { - border-radius: ${borderRadiusSmall}; - box-shadow: 0 0 0 3px ${colorStrokeFocus2}; - outline: 1px solid ${colorStrokeFocus1}; + + :host(:not([slot='input']):focus-visible)::after { + border-color: ${colorStrokeFocus2}; } - :host(:hover) .control { + + :host(:hover) { border-color: ${colorNeutralStrokeAccessibleHover}; } - :host(:active) .control { - border-color: ${colorNeutralStrokeAccessiblePressed}; - } - :host([aria-checked='true']) .checked-indicator { - opacity: 1; - } - :host([aria-checked='true']) .control { - border-color: var(--control-border-color, ${colorNeutralStrokeAccessible}); - } - :host([aria-checked='true']) .checked-indicator { - background-color: var(--checked-indicator-background-color, ${colorCompoundBrandForeground1}); + + :host(${checkedState}) { + border-color: ${colorCompoundBrandStroke}; } - :host([aria-checked='true']:hover) .control { - border-color: ${colorCompoundBrandStrokeHover}; + + :host(${checkedState}) .checked-indicator { + background-color: ${colorCompoundBrandBackground}; } - :host([aria-checked='true']:hover) .checked-indicator { - background-color: ${colorCompoundBrandStrokeHover}; + + :host(${checkedState}:hover) .checked-indicator { + background-color: ${colorCompoundBrandBackgroundHover}; } - :host([aria-checked='true']:active) .control { - border-color: ${colorCompoundBrandStrokePressed}; + + :host(:active) { + border-color: ${colorNeutralStrokeAccessiblePressed}; } - :host([aria-checked='true']:active) .checked-indicator { - background: ${colorCompoundBrandForeground1Pressed}; + + :host(${checkedState}:active) .checked-indicator { + background-color: ${colorCompoundBrandBackgroundPressed}; } - :host([disabled]) { - color: ${colorNeutralForegroundDisabled}; - pointer-events: none; + + :host(:focus-visible) { + outline: none; } - :host([disabled]) .control { - pointer-events: none; - border-color: ${colorNeutralForegroundDisabled}; + + :host(${disabledState}) { + background-color: ${colorNeutralBackgroundDisabled}; + border-color: ${colorNeutralStrokeDisabled}; } - :host([disabled]) .checked-indicator { - background: ${colorNeutralForegroundDisabled}; + + :host(${checkedState}${disabledState}) .checked-indicator { + background-color: ${colorNeutralStrokeDisabled}; } `.withBehaviors( forcedColorsStylesheetBehavior(css` - :host .control { - border-color: InactiveBorder; + :host { + border-color: FieldText; + } + + :host(:not([slot='input']:focus-visible))::after { + border-color: Canvas; } - :host([aria-checked='true']) .checked-indicator, - :host([aria-checked='true']:active) .checked-indicator, - :host([aria-checked='true']:hover) .checked-indicator { + + :host(:not(${disabledState}):hover), + :host(:not([slot='input']):focus-visible)::after { + border-color: Highlight; + } + + .checked-indicator { + color: HighlightText; + } + + :host(${checkedState}) .checked-indicator { + background-color: FieldText; + } + + :host(${checkedState}:not(${disabledState}):hover) .checked-indicator { background-color: Highlight; - border-color: ActiveBorder; + } + + :host(${disabledState}) { + border-color: GrayText; + color: GrayText; + } + + :host(${disabledState}${checkedState}) .checked-indicator { + background-color: GrayText; } `), ); diff --git a/packages/web-components/src/radio/radio.template.ts b/packages/web-components/src/radio/radio.template.ts index 81ee30c02fb06b..9ae83e6a078a60 100644 --- a/packages/web-components/src/radio/radio.template.ts +++ b/packages/web-components/src/radio/radio.template.ts @@ -1,34 +1,33 @@ -import { ElementViewTemplate, html, slotted } from '@microsoft/fast-element'; -import { staticallyCompose, whitespaceFilter } from '../utils/index.js'; -import type { Radio, RadioOptions } from './radio.js'; +import { ElementViewTemplate, html } from '@microsoft/fast-element'; +import { staticallyCompose } from '../utils/index.js'; +import type { Radio } from './radio.js'; +import type { RadioOptions } from './radio.options.js'; +const checkedIndicator = html.partial(/* html */ ` + +`); + +/** + * Generates a template for the {@link (Radio:class)} component. + * + * @param options - Radio configuration options + * @public + */ export function radioTemplate(options: RadioOptions = {}): ElementViewTemplate { return html` `; } -export const template: ElementViewTemplate = radioTemplate({ - checkedIndicator: html`
`, -}); +/** + * Template for the Radio component + * + * @public + */ +export const template: ElementViewTemplate = radioTemplate({ checkedIndicator }); diff --git a/packages/web-components/src/radio/radio.ts b/packages/web-components/src/radio/radio.ts index 5fc1b859f4ee85..796fc96a811da2 100644 --- a/packages/web-components/src/radio/radio.ts +++ b/packages/web-components/src/radio/radio.ts @@ -1,122 +1,80 @@ -import { observable } from '@microsoft/fast-element'; -import { keySpace } from '@microsoft/fast-web-utilities'; -import type { RadioGroup } from '../radio-group/index.js'; -import type { StaticallyComposableHTML } from '../utils/template-helpers.js'; -import { FormAssociatedRadio } from './radio.form-associated.js'; - -/** - * @public - */ -export type RadioControl = Pick; - -/** - * Radio configuration options - * @public - */ -export type RadioOptions = { - checkedIndicator?: StaticallyComposableHTML; -}; +import { BaseCheckbox } from '../checkbox/checkbox.js'; /** * A Radio Custom HTML Element. - * Implements the {@link https://www.w3.org/TR/wai-aria-1.1/#radio | ARIA radio }. + * Implements the {@link https://w3c.github.io/aria/#radio | ARIA `radio` role}. * - * @slot checked-indicator - The checked indicator - * @slot - The default slot for the label - * @csspart control - The element representing the visual radio control - * @csspart label - The label + * @slot checked-indicator - The checked indicator slot * @fires change - Emits a custom change event when the checked state changes + * @fires input - Emits a custom input event when the checked state changes * * @public */ -export class Radio extends FormAssociatedRadio implements RadioControl { +export class Radio extends BaseCheckbox { /** - * The name of the radio. See {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname | name attribute} for more info. - */ - @observable - public name!: string; - - /** - * The element's value to be included in form submission when checked. - * Default to "on" to reach parity with input[type="radio"] + * Indicates the position of the radio in a radio group. * - * @internal + * @public + * @remarks + * Reflects the {@link https://developer.mozilla.org/docs/Web/API/ElementInternals/ariaPosInSet | `ElementInternals.ariaPosInSet`} property. */ - public initialValue: string = 'on'; + public get ariaPosInSet(): string | null { + return this.elementInternals.ariaPosInSet; + } + + public set ariaPosInSet(value: string | null) { + this.elementInternals.ariaPosInSet = value; + } /** - * @internal + * Indicates the number of radio buttons in the associated radio group. + * + * @public + * @remarks + * Reflects the {@link https://developer.mozilla.org/docs/Web/API/ElementInternals/ariaSetSize | `ElementInternals.ariaSetSize`} property. */ - @observable - public defaultSlottedNodes!: Node[]; + public get ariaSetSize(): string | null { + return this.elementInternals.ariaSetSize; + } - private get radioGroup() { - return (this as HTMLElement).closest('[role=radiogroup]') as RadioGroup | null; + public set ariaSetSize(value: string | null) { + this.elementInternals.ariaSetSize = value; } - /** - * @internal - */ - public defaultCheckedChanged(): void { - if (this.$fastController.isConnected && !this.dirtyChecked) { - // Setting this.checked will cause us to enter a dirty state, - // but if we are clean when defaultChecked is changed, we want to stay - // in a clean state, so reset this.dirtyChecked - if (!this.isInsideRadioGroup()) { - this.checked = this.defaultChecked ?? false; - this.dirtyChecked = false; - } - } + connectedCallback() { + super.connectedCallback(); + + this.tabIndex = this.disabled ? -1 : 0; } constructor() { super(); - this.proxy.setAttribute('type', 'radio'); + this.elementInternals.role = 'radio'; } - /** - * @internal - */ - public connectedCallback(): void { - super.connectedCallback(); - this.validate(); - - if (this.parentElement?.getAttribute('role') !== 'radiogroup' && this.getAttribute('tabindex') === null) { - if (!this.disabled) { - this.setAttribute('tabindex', '0'); - } + protected disabledChanged(prev: boolean | undefined, next: boolean | undefined): void { + super.disabledChanged(prev, next); + if (next) { + this.checked = false; + this.tabIndex = -1; } - if (this.checkedAttribute) { - if (!this.dirtyChecked) { - // Setting this.checked will cause us to enter a dirty state, - // but if we are clean when defaultChecked is changed, we want to stay - // in a clean state, so reset this.dirtyChecked - if (!this.isInsideRadioGroup()) { - this.checked = this.defaultChecked ?? false; - this.dirtyChecked = false; - } - } - } + this.$emit('disabled', next, { bubbles: true }); } - private isInsideRadioGroup(): boolean { - return this.radioGroup !== null; + public requiredChanged(): void { + return; } - /** - * Handles key presses on the radio. - * @beta - */ - public keypressHandler(e: KeyboardEvent): boolean | void { - switch (e.key) { - case keySpace: - if (!this.checked && !this.radioGroup?.readOnly) { - this.checked = true; - } - return; - } + public setFormValue(): void { + return; + } + + public setValidity(): void { + this.elementInternals.setValidity({}); + } - return true; + public toggleChecked(state: boolean = true): void { + super.toggleChecked(state); } } From c4878f367d3fd2a7bffb186305aae4e5e50eebdf Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 27 Jun 2024 12:12:20 -0700 Subject: [PATCH 06/14] use ElementInternals in radio-group component --- .../src/radio-group/radio-group.spec.ts | 587 +++++++------- .../src/radio-group/radio-group.stories.ts | 331 ++++---- .../src/radio-group/radio-group.styles.ts | 66 +- .../src/radio-group/radio-group.template.ts | 35 +- .../src/radio-group/radio-group.ts | 728 ++++++++++-------- 5 files changed, 879 insertions(+), 868 deletions(-) diff --git a/packages/web-components/src/radio-group/radio-group.spec.ts b/packages/web-components/src/radio-group/radio-group.spec.ts index 6b9723c4d70adf..3980f9bfb742bf 100644 --- a/packages/web-components/src/radio-group/radio-group.spec.ts +++ b/packages/web-components/src/radio-group/radio-group.spec.ts @@ -1,220 +1,70 @@ import { expect, test } from '@playwright/test'; -import type { Locator, Page } from '@playwright/test'; -import type { Radio } from '../radio/index.js'; import { fixtureURL } from '../helpers.tests.js'; +import type { Radio } from '../radio/index.js'; import type { RadioGroup } from './radio-group.js'; -test.describe('Radio', () => { - let page: Page; - let element: Locator; - let root: Locator; - let radios: Locator; - - test.beforeAll(async ({ browser }) => { - page = await browser.newPage(); - - element = page.locator('fluent-radio-group'); - - root = page.locator('#root'); - - radios = element.locator('fluent-radio'); - +test.describe('RadioGroup', () => { + test.beforeEach(async ({ page }) => { await page.goto(fixtureURL('components-radiogroup--radio-group')); - }); - test.afterAll(async () => { - await page.close(); + await page.waitForFunction(() => + Promise.all([customElements.whenDefined('fluent-radio'), customElements.whenDefined('fluent-radio-group')]), + ); }); - test('should set and retrieve the `stacked` property correctly', async () => { - await element.evaluate((node: RadioGroup) => { - node.stacked = true; - }); - - const isStackedTrue = await element.evaluate((node: RadioGroup) => node.stacked); - expect(isStackedTrue).toBe(true); + test('should have a role of `radiogroup`', async ({ page }) => { + const element = page.locator('fluent-radio-group'); - await element.evaluate((node: RadioGroup) => { - node.stacked = false; - }); + await page.setContent(/* html */ ` + + `); - const isStackedFalse = await element.evaluate((node: RadioGroup) => node.stacked); - expect(isStackedFalse).toBe(false); + await expect(element).toHaveJSProperty('elementInternals.role', 'radiogroup'); }); - test('should have a role of `radiogroup`', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - `; - }); - - await expect(element).toHaveAttribute('role', 'radiogroup'); - }); + test('should set a default `aria-orientation` value when `orientation` is not defined', async ({ page }) => { + const element = page.locator('fluent-radio-group'); - test('should set a default `aria-orientation` value when `orientation` is not defined', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - `; - }); + await page.setContent(/* html */ ` + + `); - await expect(element).toHaveAttribute('aria-orientation', 'horizontal'); + await expect(element).toHaveJSProperty('elementInternals.ariaOrientation', 'horizontal'); }); - test('should set a matching class on the `positioning-region` when an orientation is provided', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - `; - }); - - const positioningRegion = element.locator('.positioning-region'); - - // Horizontal by default - await expect(positioningRegion).toHaveClass(/horizontal/); - - await element.evaluate((node: RadioGroup, RadioGroupOrientation) => { - node.orientation = 'vertical'; - }); - - await expect(positioningRegion).toHaveClass(/vertical/); - - await element.evaluate((node: RadioGroup, RadioGroupOrientation) => { - node.orientation = 'horizontal'; - }); - - await expect(positioningRegion).toHaveClass(/horizontal/); - }); + test('should set the `aria-orientation` attribute equal to the `orientation` value', async ({ page }) => { + const element = page.locator('fluent-radio-group'); - test('should set the `aria-orientation` attribute equal to the `orientation` value', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - `; - }); + await page.setContent(/* html */ ` + + `); - await element.evaluate((node: RadioGroup, RadioGroupOrientation) => { + await element.evaluate((node: RadioGroup) => { node.orientation = 'horizontal'; }); - await expect(element).toHaveAttribute('aria-orientation', 'horizontal'); + await expect(element).toHaveJSProperty('elementInternals.ariaOrientation', 'horizontal'); - await element.evaluate((node: RadioGroup, RadioGroupOrientation) => { + await element.evaluate((node: RadioGroup) => { node.orientation = 'vertical'; }); - await expect(element).toHaveAttribute('aria-orientation', 'vertical'); - }); - - test('should set the `stacked` attribute equal to the `stacked` value', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - `; - }); - - await element.evaluate((node: RadioGroup, RadioGroupOrientation) => { - node.stacked = true; - }); - - await expect(element).toHaveAttribute('stacked', ''); - - await element.evaluate((node: RadioGroup, RadioGroupOrientation) => { - node.stacked = false; - }); - - await expect(element).not.toHaveAttribute('stacked', ''); + await expect(element).toHaveJSProperty('elementInternals.ariaOrientation', 'vertical'); }); - test('should set the `aria-disabled` attribute when disabled', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - `; - }); - - await expect(element).toHaveAttribute('aria-disabled', 'true'); - }); - - test('should set the `aria-disabled` attribute equal to the `disabled` property', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - `; - }); - - const hasAriaDisabledInitially = await element.evaluate((node: Element) => node.hasAttribute('aria-disabled')); - expect(hasAriaDisabledInitially).toBe(false); - - await element.evaluate(node => { - node.disabled = true; - }); - - await expect(element).toHaveAttribute('aria-disabled', 'true'); - - await element.evaluate(node => { - node.disabled = false; - }); - - await expect(element).toHaveAttribute('aria-disabled', 'false'); - }); - - test('should set the `aria-readonly` attribute when the `readonly` attribute is present', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - `; - }); - - await expect(element).toHaveAttribute('aria-readonly', 'true'); - }); - - test('should set the `aria-readonly` attribute equal to the `readonly` property', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - `; - }); - - const hasAriaReadOnly = await element.evaluate((node: Element) => node.hasAttribute('aria-readonly')); - expect(hasAriaReadOnly).toBe(false); - - await element.evaluate(node => { - node.readOnly = true; - }); - - await expect(element).toHaveAttribute('aria-readonly', 'true'); - - await element.evaluate(node => { - node.readOnly = false; - }); - - await expect(element).toHaveAttribute('aria-readonly', 'false'); - }); - - test('should NOT set a default `aria-disabled` value when `disabled` is not defined', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - `; - }); - - const hasAriaDisabled = await element.evaluate((node: Element) => node.hasAttribute('aria-disabled')); - - expect(hasAriaDisabled).toBe(false); - }); + test('should NOT modify child radio elements disabled state when the `disabled` attribute is present', async ({ + page, + }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); - test('should NOT modify child radio elements disabled state when the `disabled` attribute is present', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - - - - - `; - }); + await page.setContent(/* html */ ` + + + + + + `); const hasDisabledAttribute = await element.evaluate((node: Element) => node.hasAttribute('disabled')); expect(hasDisabledAttribute).toBe(false); @@ -245,24 +95,23 @@ test.describe('Radio', () => { expect(await thirdRadio.evaluate(radio => radio.hasAttribute('disabled'))).toEqual(expectedThird); }); - test('should NOT be focusable when disabled', async () => { - const first: Locator = page.locator('button', { hasText: 'First' }); - const second: Locator = page.locator('button', { hasText: 'Second' }); - - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - - - - - - - `; - }); + test('should NOT be focusable when disabled', async ({ page }) => { + const element = page.locator('fluent-radio-group'); - const isDisabled = await element.evaluate((node: Element) => node.hasAttribute('disabled')); - expect(isDisabled).toBe(true); + const first = page.locator('button', { hasText: 'First' }); + const second = page.locator('button', { hasText: 'Second' }); + + await page.setContent(/* html */ ` + + + + + + + + `); + + await expect(element).toHaveAttribute('disabled'); await first.focus(); @@ -271,23 +120,21 @@ test.describe('Radio', () => { await first.press('Tab'); await expect(second).toBeFocused(); - - expect(await element.evaluate(node => node.getAttribute('tabindex') === '-1')).toBeTruthy(); }); - test('should NOT be focusable via click when disabled', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - - - - - - `; - }); + test('should NOT be focusable via click when disabled', async ({ page }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); + + await page.setContent(/* html */ ` + + + + + + + `); - const radios = page.locator('fluent-radio'); const radioItemsCount = await radios.count(); const button = page.locator('button', { hasText: 'Button' }); @@ -300,7 +147,6 @@ test.describe('Radio', () => { await expect(item).toBeFocused(); } - const element = page.locator('fluent-radio-group'); await element.evaluate(node => node.setAttribute('disabled', '')); const isDisabled = await element.evaluate((node: Element) => node.hasAttribute('disabled')); @@ -325,153 +171,252 @@ test.describe('Radio', () => { await expect(isClickable).toBe(false); } }); - test('should set tabindex of 0 to a child radio with a matching `value`', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - - - - - `; - }); - await expect(radios.nth(0)).toHaveAttribute('tabindex', '0'); - }); + test('should set tabindex of 0 to a child radio with a matching `value`', async ({ page }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); - test('should NOT set `tabindex` of 0 to a child radio if its value does not match the radiogroup `value`', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - - - - - `; - }); + await page.setContent(/* html */ ` + + + + + + `); - expect( - await radios.evaluateAll(radios => radios.every(radio => radio.getAttribute('tabindex') === '-1')), - ).toBeTruthy(); + await expect(radios.nth(0)).toHaveAttribute('tabindex', '0'); }); - test('should set a child radio with a matching `value` to `checked`', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - - - - - `; - }); + test('should check the first radio with a matching `value`', async ({ page }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); + + await page.setContent(/* html */ ` + + + + + + `); - await expect(radios.nth(0)).not.toBeChecked(); + await expect(radios.nth(0)).toHaveJSProperty('checked', false); - await expect(radios.nth(1)).toBeChecked(); + await expect(radios.nth(1)).toHaveJSProperty('checked', true); - await expect(radios.nth(2)).not.toBeChecked(); + await expect(radios.nth(2)).toHaveJSProperty('checked', false); }); - test('should set a child radio with a matching `value` to `checked` when value changes', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` + test('should set a child radio with a matching `value` to `checked` when value changes', async ({ page }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); + + await page.setContent(/* html */ ` - - `; - }); + + `); await element.evaluate((node: RadioGroup) => { node.value = 'bar'; }); - await expect(radios.nth(0)).not.toBeChecked(); + await expect(radios.nth(0)).toHaveJSProperty('checked', false); - await expect(radios.nth(1)).toBeChecked(); + await expect(radios.nth(1)).toHaveJSProperty('checked', true); - await expect(radios.nth(2)).not.toBeChecked(); + await expect(radios.nth(2)).toHaveJSProperty('checked', false); }); - test('should mark only the last radio defaulted to checked as checked', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - - - - - `; - }); + test('should mark only the last radio defaulted to checked as checked', async ({ page }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); - expect(await radios.evaluateAll(radios => radios.filter(radio => radio.checked).length)).toBe(1); + await page.setContent(/* html */ ` + + + + + + `); - await expect(radios.nth(0)).not.toBeChecked(); + expect(await radios.evaluateAll((radios: Radio[]) => radios.filter(radio => radio.checked).length)).toBe(1); - await expect(radios.nth(1)).not.toBeChecked(); + await expect(radios.nth(0)).toHaveJSProperty('checked', false); - await expect(radios.nth(2)).toBeChecked(); + await expect(radios.nth(1)).toHaveJSProperty('checked', false); + + await expect(radios.nth(2)).toHaveJSProperty('checked', true); }); - test('should mark radio matching value on radio-group over any checked attributes', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` - - - - - - `; - }); + test('should mark radio matching value on radio-group over any checked attributes', async ({ page }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); + + await page.setContent(/* html */ ` + + + + + + `); - expect(await radios.evaluateAll(radios => radios.filter(radio => radio.checked).length)).toBe(1); + expect(await radios.evaluateAll((radios: Radio[]) => radios.filter(radio => radio.checked).length)).toBe(1); - await expect(radios.nth(0)).toBeChecked(); + await expect(radios.nth(0)).toHaveJSProperty('checked', true); - await expect(radios.nth(1)).not.toBeChecked(); + await expect(radios.nth(1)).toHaveJSProperty('checked', false); - // radio-group explicitly sets non-matching radio's checked to false if - // a value match was found, but the attribute should still persist. - expect(await radios.nth(1).evaluate(node => node.hasAttribute('checked'))).toBeTruthy(); + await expect(radios.nth(1)).toHaveAttribute('checked'); - await expect(radios.nth(2)).not.toBeChecked(); + await expect(radios.nth(2)).toHaveJSProperty('checked', false); }); - test('should allow resetting of elements by the parent form', async () => { - await root.evaluate(node => { - node.innerHTML = /* html */ ` -
- - - - - -
- `; - }); + test('should allow resetting of elements by the parent form', async ({ page }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); + + await page.setContent(/* html */ ` +
+ + + + + +
+ `); const form = page.locator('form'); - await radios.nth(2).evaluate(node => { - node.checked = true; - }); + await expect(radios.nth(0)).toHaveJSProperty('checked', false); + + await expect(radios.nth(1)).toHaveJSProperty('checked', true); + + await expect(radios.nth(2)).toHaveJSProperty('checked', false); + + await radios.nth(2).click(); - await expect(radios.nth(0)).not.toBeChecked(); + await expect(radios.nth(0)).toHaveJSProperty('checked', false); - await expect(radios.nth(1)).not.toBeChecked(); + await expect(radios.nth(1)).toHaveJSProperty('checked', false); - await expect(radios.nth(2)).toBeChecked(); + await expect(radios.nth(2)).toHaveJSProperty('checked', true); - await form.evaluate(node => { + await form.evaluate((node: HTMLFormElement) => { node.reset(); }); - await expect(radios.nth(0)).not.toBeChecked(); + await expect(radios.nth(0)).toHaveJSProperty('checked', false); + + await expect(radios.nth(1)).toHaveJSProperty('checked', true); + + await expect(radios.nth(2)).toHaveJSProperty('checked', false); + }); + + test('should focus the first radio when the radio group is focused', async ({ page }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); + + await page.setContent(/* html */ ` + + + + + + `); + + await page.keyboard.press('Tab'); + + await expect(radios.nth(0)).toBeFocused(); + }); + + test('should focus the second radio when the radio group is focused and the first radio is disabled', async ({ + page, + }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); + + await page.setContent(/* html */ ` + + + + + + `); + + await page.keyboard.press('Tab'); + + await expect(radios.nth(1)).toBeFocused(); + }); + + test('should focus the third radio when the radio group is focused and the first two radios are disabled', async ({ + page, + }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); + + await page.setContent(/* html */ ` + + + + + + `); + + await page.keyboard.press('Tab'); + + await expect(radios.nth(2)).toBeFocused(); + }); + + test('should NOT focus any radio when the radio group is focused and all radios are disabled', async ({ page }) => { + const element = page.locator('fluent-radio-group'); + + await page.setContent(/* html */ ` + + + + + + `); + + await page.keyboard.press('Tab'); + + await expect(element).not.toBeFocused(); + }); - await expect(radios.nth(1)).toBeChecked(); + test('should move focus to the next radio when the radio group is focused and the arrow down key is pressed', async ({ + page, + }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); + + await page.setContent(/* html */ ` + + + + + + `); + + await page.keyboard.press('Tab'); + + await expect(radios.nth(0)).toBeFocused(); + + await page.keyboard.press('ArrowDown'); + + await expect(radios.nth(1)).toBeFocused(); - await expect(radios.nth(2)).not.toBeChecked(); + await test.step('should move focus to the next radio when the radio group is focused and the arrow down key is pressed', async () => { + await page.keyboard.press('ArrowDown'); + + await expect(radios.nth(2)).toBeFocused(); + }); + + await test.step('should move focus to the first radio when the last radio is focused and the arrow down key is pressed', async () => { + await page.keyboard.press('ArrowDown'); + + await expect(radios.nth(0)).toBeFocused(); + }); }); }); diff --git a/packages/web-components/src/radio-group/radio-group.stories.ts b/packages/web-components/src/radio-group/radio-group.stories.ts index a953f3ec671cf9..78b90bdbca1c96 100644 --- a/packages/web-components/src/radio-group/radio-group.stories.ts +++ b/packages/web-components/src/radio-group/radio-group.stories.ts @@ -1,213 +1,184 @@ -import { html } from '@microsoft/fast-element'; -import type { Args, Meta } from '@storybook/html'; +import { html, repeat } from '@microsoft/fast-element'; +import type { Field as FluentField } from '../field/field.js'; +import type { Meta, Story, StoryArgs } from '../helpers.stories.js'; import { renderComponent } from '../helpers.stories.js'; -import { RadioGroup as FluentRadioGroup } from './radio-group.js'; +import type { RadioGroup as FluentRadioGroup } from './radio-group.js'; import { RadioGroupOrientation } from './radio-group.options.js'; -type RadioGroupStoryArgs = Args & FluentRadioGroup; -type RadioGroupStoryMeta = Meta; +const fieldTemplate = html, StoryArgs>` + + + + +`; -const storyTemplate = html` - x.disabled} - ?stacked=${x => x.stacked} - orientation=${x => x.orientation} - name="radio-story" - > - Favorite Fruit - Apple - Pear - Banana - Orange - +const storyTemplate = html>` + + + x.disabled} + ?stacked=${x => x.stacked} + orientation=${x => x.orientation} + name="${x => x.name}" + value="${x => x.value}" + > + ${repeat(x => x.storyItems, fieldTemplate)} + + `; export default { title: 'Components/RadioGroup', args: { - disabled: false, - orientation: RadioGroupOrientation.horizontal, + label: 'Favorite Fruit', + name: 'favorite-fruit', + storyItems: [ + { id: 'apple', label: 'Apple', value: 'apple' }, + { id: 'pear', label: 'Pear', value: 'pear' }, + { id: 'banana', label: 'Banana', value: 'banana' }, + { id: 'orange', label: 'Orange', value: 'orange' }, + ], }, argTypes: { - disabled: { - control: { - type: 'boolean', - }, - table: { - type: { - summary: 'Sets disabled state on radio', - }, - defaultValue: { - summary: 'false', - }, - }, - }, - checked: { - control: { - type: 'boolean', - }, + storyItems: { table: { - type: { - summary: 'Sets checked state on radio', - }, - defaultValue: { - summary: 'false', - }, + disable: true, }, }, - stacked: { - control: { - type: 'boolean', - }, + disabled: { + control: 'boolean', table: { - type: { - summary: 'Creates a stacked layout for horizontal radio buttons', - }, - defaultValue: { - summary: 'false', - }, + type: { summary: 'Sets disabled state on radio' }, + defaultValue: { summary: 'false' }, }, }, orientation: { - control: { - type: 'select', - options: Object.values(RadioGroupOrientation), - }, - defaultValue: RadioGroupOrientation.horizontal, - table: { - type: { - summary: 'Sets orientation of radio group', - }, - defaultValue: { - summary: RadioGroupOrientation.horizontal, - }, - }, - }, - change: { - action: 'change', + control: 'select', + options: Object.values(RadioGroupOrientation), table: { - type: { - summary: 'Event that is fired when the selected radio button changes', - }, - defaultValue: { - summary: null, - }, + type: { summary: 'Sets orientation of radio group' }, + defaultValue: { summary: RadioGroupOrientation.horizontal }, }, }, }, -} as RadioGroupStoryMeta; +} as Meta; -export const RadioGroup = renderComponent(storyTemplate).bind({}); - -export const RadioGroupLabelledby = renderComponent(html` - - Favorite Fruit - Apple - Pear - Banana - Orange - -`); - -export const RadioGroupLayoutVertical = renderComponent(html` - - Favorite Fruit - Apple - Pear - Banana - Orange - -`); - -export const RadioGroupLayoutHorizontal = renderComponent(html` - - Favorite Fruit - Apple - Pear - Banana - Orange - -`); - -export const RadioGroupLayoutHorizontalStacked = renderComponent(html` - - Favorite Fruit - Apple - Pear - Banana - Orange - -`); - -export const RadioGroupDefaultChecked = renderComponent(html` - - Favorite Fruit - Apple - Pear - Banana - Orange - -`); - -export const RadioGroupDisabled = renderComponent(html` - - Favorite Fruit - Apple - Pear - Banana - Orange - -`); - -export const RadioGroupDisabledItem = renderComponent(html` - - Favorite Fruit - Apple - Pear - Banana - Orange - -`); - -const getLabelContent = (): string | undefined => { - const radioGroup = document.querySelector('#radio-group-fruit') as FluentRadioGroup; +export const RadioGroup: Story = renderComponent(storyTemplate).bind({}); +RadioGroup.args = { + id: 'radio-group', + orientation: RadioGroupOrientation.vertical, +}; - if (!radioGroup) return; // add a check to make sure radioGroup exists +export const LayoutHorizontal: Story = RadioGroup.bind({}); +LayoutHorizontal.args = { + id: 'radio-group-horizontal', + orientation: RadioGroupOrientation.horizontal, +}; - const selectedRadio = radioGroup.value as string; +export const LayoutHorizontalStacked: Story = RadioGroup.bind({}); +LayoutHorizontalStacked.args = { + orientation: RadioGroupOrientation.horizontal, + id: 'radio-group-horizontal-stacked', + storyItems: [ + { id: 'apple', label: 'Apple', value: 'apple', labelPosition: 'below' }, + { id: 'pear', label: 'Pear', value: 'pear', labelPosition: 'below' }, + { id: 'banana', label: 'Banana', value: 'banana', labelPosition: 'below' }, + { id: 'orange', label: 'Orange', value: 'orange', labelPosition: 'below' }, + ], +}; - if (selectedRadio) { - return `Favorite fruit: ${selectedRadio.charAt(0).toUpperCase() + selectedRadio.slice(1)}`; - } else { - return 'Please select your favorite fruit'; - } +export const DefaultValue: Story = RadioGroup.bind({}); +DefaultValue.args = { + orientation: RadioGroupOrientation.horizontal, + id: 'radio-group-default', + value: 'banana', + storyItems: [ + { id: 'apple', label: 'Apple', value: 'apple' }, + { id: 'pear', label: 'Pear', value: 'pear' }, + { id: 'banana', label: 'Banana', value: 'banana' }, + { id: 'orange', label: 'Orange', value: 'orange' }, + ], }; -const handleChange = (event: CustomEvent) => { - const radioGroup = document.querySelector('#radio-group-fruit') as FluentRadioGroup; +export const CheckedItem: Story = RadioGroup.bind({}); +CheckedItem.args = { + orientation: RadioGroupOrientation.horizontal, + id: 'radio-group-checked', + storyItems: [ + { id: 'apple', label: 'Apple', value: 'apple' }, + { id: 'pear', label: 'Pear', value: 'pear', checked: true }, + { id: 'banana', label: 'Banana', value: 'banana' }, + { id: 'orange', label: 'Orange', value: 'orange' }, + ], +}; - if (!radioGroup) return; // add a check to make sure radioGroup exists +export const Disabled: Story = RadioGroup.bind({}); +Disabled.args = { + orientation: RadioGroupOrientation.horizontal, + id: 'radio-group-disabled', + disabled: true, +}; - const selectedRadio = radioGroup.value as string; - const labelElement = radioGroup.querySelector('[slot="label"]') as HTMLSpanElement; - if (selectedRadio) { - const labelContent = selectedRadio.charAt(0).toUpperCase() + selectedRadio.slice(1); - labelElement.textContent = `Favorite fruit: ${labelContent}`; - } +export const DisabledItems: Story = RadioGroup.bind({}); +DisabledItems.args = { + orientation: RadioGroupOrientation.vertical, + id: 'radio-group-disabled-items', + storyItems: [ + { id: 'apple', label: 'Apple', value: 'apple' }, + { id: 'pear', label: 'Pear', value: 'pear', disabled: true }, + { id: 'banana', label: 'Banana', value: 'banana', disabled: true }, + { id: 'orange', label: 'Orange', value: 'orange' }, + { id: 'grape', label: 'Grape', value: 'grape' }, + { id: 'kiwi', label: 'Kiwi', value: 'kiwi', disabled: true }, + ], }; -export const RadioGroupChangeEvent = renderComponent(html` - = renderComponent(html` +
- ${getLabelContent} - Apple - Pear - Banana - Orange - -`); + + + x.disabled} + ?stacked=${x => x.stacked} + orientation="${x => x.orientation}" + name="${x => x.name}" + value="${x => x.value}" + style="grid-row: 2/3" + > + ${repeat(x => x.storyItems, fieldTemplate)} + + Please select a fruit. + + Submit + Reset +
+`).bind({}); +Required.args = { + id: 'radio-group-form', + orientation: RadioGroupOrientation.horizontal, + required: true, + storyItems: [ + { id: 'apple', label: 'Apple', value: 'apple' }, + { id: 'pear', label: 'Pear', value: 'pear' }, + { id: 'banana', label: 'Banana', value: 'banana' }, + { id: 'orange', label: 'Orange', value: 'orange' }, + ], +}; diff --git a/packages/web-components/src/radio-group/radio-group.styles.ts b/packages/web-components/src/radio-group/radio-group.styles.ts index 7c7ab91caf656a..048cbdee2e6821 100644 --- a/packages/web-components/src/radio-group/radio-group.styles.ts +++ b/packages/web-components/src/radio-group/radio-group.styles.ts @@ -1,16 +1,12 @@ import { css } from '@microsoft/fast-element'; -import { display } from '../utils/index.js'; +import { checkedState, disabledState } from '../styles/states/index.js'; import { colorNeutralForeground1, + colorNeutralForeground2, + colorNeutralForeground3, colorNeutralForegroundDisabled, - fontFamilyBase, - fontSizeBase300, - fontWeightRegular, - lineHeightBase300, - spacingHorizontalS, - spacingHorizontalXS, - spacingVerticalS, } from '../theme/design-tokens.js'; +import { display } from '../utils/index.js'; /** RadioGroup styles * @public @@ -19,44 +15,36 @@ export const styles = css` ${display('flex')} :host { - align-items: flex-start; - flex-direction: column; - row-gap: ${spacingVerticalS}; - } - :host([disabled]) ::slotted([role='radio']) { - --control-border-color: ${colorNeutralForegroundDisabled}; - --checked-indicator-background-color: ${colorNeutralForegroundDisabled}; - --state-color: ${colorNeutralForegroundDisabled}; - } - ::slotted([slot='label']) { - color: ${colorNeutralForeground1}; - padding: ${spacingVerticalS} ${spacingHorizontalS} ${spacingVerticalS} ${spacingHorizontalXS}; - font: ${fontWeightRegular} ${fontSizeBase300} / ${lineHeightBase300} ${fontFamilyBase}; - cursor: default; - } - .positioning-region { - display: flex; - flex-wrap: wrap; + cursor: pointer; } - :host([orientation='vertical']) .positioning-region { + + :host(${disabledState}), + :host([orientation='vertical']) { flex-direction: column; justify-content: flex-start; } - :host([orientation='horizontal']) .positioning-region { + + :host([orientation='horizontal']) { flex-direction: row; } - :host([orientation='horizontal']) ::slotted([role='radio']) { - padding-inline-end: ${spacingHorizontalS}; + + ::slotted(*) { + color: ${colorNeutralForeground3}; } - :host([orientation='horizontal'][stacked]) ::slotted([role='radio']) { - display: flex; - flex-direction: column; - padding-inline: ${spacingHorizontalS}; - height: auto; - align-items: center; - justify-content: center; + + ::slotted(:hover) { + color: ${colorNeutralForeground2}; + } + + ::slotted(:active) { + color: ${colorNeutralForeground1}; } - :host([disabled]) ::slotted([role='radio']) { - pointer-events: none; + + ::slotted(${disabledState}) { + color: ${colorNeutralForegroundDisabled}; + } + + ::slotted(${checkedState}) { + color: ${colorNeutralForeground1}; } `; diff --git a/packages/web-components/src/radio-group/radio-group.template.ts b/packages/web-components/src/radio-group/radio-group.template.ts index f69f713f960a4d..68b7029751d028 100644 --- a/packages/web-components/src/radio-group/radio-group.template.ts +++ b/packages/web-components/src/radio-group/radio-group.template.ts @@ -1,34 +1,25 @@ -import { elements, html, slotted } from '@microsoft/fast-element'; import type { ElementViewTemplate } from '@microsoft/fast-element'; +import { children, html } from '@microsoft/fast-element'; +import { Radio } from '../radio/radio.js'; import type { RadioGroup } from './radio-group.js'; -import { RadioGroupOrientation } from './radio-group.options.js'; export function radioGroupTemplate(): ElementViewTemplate { return html` `; } diff --git a/packages/web-components/src/radio-group/radio-group.ts b/packages/web-components/src/radio-group/radio-group.ts index 67f8b0d2a2c337..109065a474ca8e 100644 --- a/packages/web-components/src/radio-group/radio-group.ts +++ b/packages/web-components/src/radio-group/radio-group.ts @@ -1,426 +1,542 @@ -import { attr, FASTElement, observable } from '@microsoft/fast-element'; -import { - ArrowKeys, - Direction, - keyArrowDown, - keyArrowLeft, - keyArrowRight, - keyArrowUp, - keyEnter, -} from '@microsoft/fast-web-utilities'; +import { attr, FASTElement, Observable, observable, Updates } from '@microsoft/fast-element'; +import { findLastIndex } from '@microsoft/fast-web-utilities'; import { Radio } from '../radio/radio.js'; -import { getDirection } from '../utils/index.js'; +import { getDirection } from '../utils/direction.js'; +import { getRootActiveElement } from '../utils/root-active-element.js'; import { RadioGroupOrientation } from './radio-group.options.js'; /** - * The base class used for constructing a fluent-radio-group custom element + * A Radio Group Custom HTML Element. + * Implements the {@link https://w3c.github.io/aria/#radiogroup | ARIA `radiogroup` role}. + * * @public + * + * @slot - The default slot for the radio group */ export class RadioGroup extends FASTElement { /** - * sets radio layout styles + * The index of the checked radio, scoped to the enabled radios. * - * @public - * @remarks - * HTML Attribute: stacked + * @internal */ - @attr({ mode: 'boolean' }) - public stacked: boolean = false; + @observable + protected checkedIndex!: number; /** - * When true, the child radios will be immutable by user interaction. See {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/readonly | readonly HTML attribute} for more information. - * @public - * @remarks - * HTML Attribute: readonly + * Sets the checked state of the nearest enabled radio when the `checkedIndex` changes. + * + * @param prev - the previous index + * @param next - the current index + * @internal */ - @attr({ attribute: 'readonly', mode: 'boolean' }) - public readOnly!: boolean; + protected checkedIndexChanged(prev: number | undefined, next: number): void { + if (!this.enabledRadios) { + return; + } + + this.checkRadio(next); + } + + private dirtyState: boolean = false; /** * Disables the radio group and child radios. * * @public * @remarks - * HTML Attribute: disabled + * HTML Attribute: `disabled` */ @attr({ attribute: 'disabled', mode: 'boolean' }) - public disabled!: boolean; + public disabled: boolean = false; /** - * The name of the radio group. Setting this value will set the name value - * for all child radio elements. + * Sets the `disabled` attribute on all child radios when the `disabled` property changes. + * + * @param prev - the previous disabled value + * @param next - the current disabled value + * @internal + */ + protected disabledChanged(prev?: boolean, next?: boolean): void { + if (this.$fastController.isConnected) { + this.checkedIndex = -1; + this.radios?.forEach(radio => { + radio.disabled = radio.disabledAttribute || this.disabled; + }); + this.restrictFocus(); + } + } + + /** + * The value of the checked radio. * * @public * @remarks - * HTML Attribute: name + * HTML Attribute: `value` + */ + @attr({ attribute: 'value', mode: 'fromView' }) + public initialValue?: string; + + /** + * Sets the matching radio to checked when the value changes. If no radio matches the value, no radio will be checked. + * + * @param prev - the previous value + * @param next - the current value + */ + public initialValueChanged(prev: string | undefined, next: string | undefined): void { + this.value = next ?? ''; + } + + /** + * The name of the radio group. + * + * @public + * @remarks + * HTML Attribute: `name` */ @attr public name!: string; - protected nameChanged(): void { - if (this.slottedRadioButtons) { - this.slottedRadioButtons.forEach((radio: HTMLElement) => { - radio.setAttribute('name', this.name); - }); - } + + /** + * Sets the `name` attribute on all child radios when the `name` property changes. + * + * @internal + */ + protected nameChanged(prev: string | undefined, next: string | undefined): void { + this.radios?.forEach(radio => { + radio.name = this.name; + }); } /** - * The value of the checked radio + * The orientation of the group. * * @public * @remarks - * HTML Attribute: value + * HTML Attribute: `orientation` */ @attr - public value!: string; - protected valueChanged(): void { - if (this.slottedRadioButtons) { - this.slottedRadioButtons.forEach((radio: HTMLElement) => { - if (radio instanceof Radio) { - if (radio.value === this.value) { - radio.checked = true; - this.selectedRadio = radio; - } - } - }); + public orientation?: RadioGroupOrientation; + + /** + * Sets the ariaOrientation attribute when the orientation changes. + * + * @param prev - the previous orientation + * @param next - the current orientation + * @internal + */ + public orientationChanged(prev: RadioGroupOrientation | undefined, next: RadioGroupOrientation | undefined): void { + this.elementInternals.ariaOrientation = this.orientation ?? RadioGroupOrientation.horizontal; + } + + /** + * The collection of all child radios. + * + * @public + */ + @observable + public radios?: Radio[]; + + /** + * Updates the enabled radios collection when properties on the child radios change. + * + * @param prev - the previous radios + * @param next - the current radios + */ + public radiosChanged(prev: Radio[] | undefined, next: Radio[] | undefined): void { + if (!next?.length) { + return; + } + + // TODO: Switch to standard `Array.findLastIndex` when TypeScript 5 is available + const lastCheckedIndex = findLastIndex(this.enabledRadios, x => x.checked); + + if (!this.dirtyState) { + this.value = this.initialValue ?? next[lastCheckedIndex]?.value ?? ''; } - this.$emit('change'); + + next.forEach((radio, index, radios) => { + radio.ariaPosInSet = (index + 1).toString(); + radio.ariaSetSize = radios.length.toString(); + radio.checked = !this.disabled && index === lastCheckedIndex; + radio.disabled = this.disabled || radio.disabledAttribute; + radio.name = this.name; + }); + + this.checkedIndex = lastCheckedIndex; + + this.setAttribute('aria-owns', next.map(radio => radio.id).join(' ')); + + Updates.enqueue(() => this.restrictFocus()); } /** - * The orientation of the group + * Indicates whether the radio group is required. * * @public * @remarks - * HTML Attribute: orientation + * HTML Attribute: `required` */ - @attr - public orientation: RadioGroupOrientation = RadioGroupOrientation.horizontal; + @attr({ mode: 'boolean' }) + public required!: boolean; - @observable - public childItems!: HTMLElement[]; + /** + * + * @param prev - the previous required value + * @param next - the current required value + */ + public requiredChanged(prev: boolean, next: boolean): void { + this.elementInternals.ariaRequired = next ? 'true' : null; + this.setValidity(); + } /** + * The internal {@link https://developer.mozilla.org/docs/Web/API/ElementInternals | `ElementInternals`} instance for the component. + * * @internal */ - @observable - public slottedRadioButtons!: HTMLElement[]; - protected slottedRadioButtonsChanged(oldValue: unknown, newValue: HTMLElement[]): void { - if (this.slottedRadioButtons && this.slottedRadioButtons.length > 0) { - this.setupRadioButtons(); + public elementInternals: ElementInternals = this.attachInternals(); + + /** + * A collection of child radios that are not disabled. + * + * @internal + */ + public get enabledRadios(): Radio[] { + if (this.disabled) { + return []; } + + return this.radios?.filter(x => !x.disabled) ?? []; } - private selectedRadio!: Radio | null; - private focusedRadio!: Radio | null; - private direction!: Direction; + /** + * The form-associated flag. + * @see {@link https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements-face-example | Form-associated custom elements} + * + * @public + */ + public static formAssociated = true; + + /** + * The fallback validation message, taken from a native checkbox `` element. + * + * @internal + */ + private _validationFallbackMessage!: string; + + /** + * The validation message. Uses the browser's default validation message for native checkboxes if not otherwise + * specified (e.g., via `setCustomValidity`). + * + * @internal + */ + public get validationMessage(): string { + if (this.elementInternals.validationMessage) { + return this.elementInternals.validationMessage; + } + + if (this.enabledRadios?.[0]?.validationMessage) { + return this.enabledRadios[0].validationMessage; + } - private get parentToolbar(): HTMLElement | null { - return this.closest('[role="toolbar"]'); + if (!this._validationFallbackMessage) { + const validationMessageFallbackControl = document.createElement('input'); + validationMessageFallbackControl.type = 'radio'; + validationMessageFallbackControl.required = true; + validationMessageFallbackControl.checked = false; + + this._validationFallbackMessage = validationMessageFallbackControl.validationMessage; + } + + return this._validationFallbackMessage; } - private get isInsideToolbar(): boolean { - return (this.parentToolbar ?? false) as boolean; + /** + * The element's validity state. + * + * @public + * @remarks + * Reflects the {@link https://developer.mozilla.org/docs/Web/API/ElementInternals/validity | `ElementInternals.validity`} property. + */ + public get validity(): ValidityState { + return this.elementInternals.validity; } - private get isInsideFoundationToolbar(): boolean { - return !!this.parentToolbar?.hasOwnProperty('$fastController'); + /** + * The current value of the checked radio. + * + * @public + */ + public get value(): string | null { + Observable.notify(this, 'value'); + return this.enabledRadios.find(x => x.checked)?.value ?? null; + } + + public set value(next: string | null) { + const index = this.enabledRadios.findIndex(x => x.value === next); + this.checkedIndex = index; + + if (this.$fastController.isConnected) { + this.setFormValue(next); + this.setValidity(); + } + + Observable.track(this, 'value'); } /** - * @internal + * Sets the checked state of all radios when any radio emits a `change` event. + * + * @param e - the change event */ - public connectedCallback(): void { - super.connectedCallback(); - this.direction = getDirection(this); + public changeHandler(e: Event): boolean | void { + if (this === e.target) { + return true; + } - this.setupRadioButtons(); + this.dirtyState = true; + const radioIndex = this.enabledRadios.indexOf(e.target as Radio) ?? this.checkedIndex; + this.checkRadio(radioIndex); + return true; } - public disconnectedCallback(): void { - this.slottedRadioButtons.forEach((radio: HTMLElement) => { - if (radio instanceof Radio) { - radio.removeEventListener('change', this.radioChangeHandler); + /** + * Checks the radio at the specified index. + * + * @param index - the index of the radio to check + * @internal + */ + public checkRadio(index: number = this.checkedIndex): void { + let checkedIndex = this.checkedIndex; + + this.enabledRadios.forEach((item, i) => { + const shouldCheck = i === index; + item.checked = shouldCheck; + if (shouldCheck) { + checkedIndex = i; } }); + + this.checkedIndex = checkedIndex; + this.setFormValue(this.value); + this.setValidity(); } - private setupRadioButtons(): void { - const checkedRadios: HTMLElement[] = this.slottedRadioButtons.filter((radio: HTMLElement) => { - return radio.hasAttribute('checked'); - }); - const numberOfCheckedRadios: number = checkedRadios ? checkedRadios.length : 0; - if (numberOfCheckedRadios > 1) { - const lastCheckedRadio: Radio = checkedRadios[numberOfCheckedRadios - 1] as Radio; - lastCheckedRadio.checked = true; - } - let foundMatchingVal: boolean = false; - - this.slottedRadioButtons.forEach((radio: HTMLElement) => { - if (radio instanceof Radio) { - if (this.name !== undefined) { - radio.setAttribute('name', this.name); - } - - if (this.value && this.value === radio.value) { - this.selectedRadio = radio; - this.focusedRadio = radio; - radio.checked = true; - radio.setAttribute('tabindex', '0'); - foundMatchingVal = true; - } else { - if (!this.isInsideFoundationToolbar) { - radio.setAttribute('tabindex', '-1'); - } - radio.checked = false; - } - - radio.addEventListener('change', this.radioChangeHandler); - } - }); + /** + * Checks the validity of the element and returns the result. + * + * @public + * @remarks + * Reflects the {@link https://developer.mozilla.org/docs/Web/API/ElementInternals/checkValidity | `HTMLInputElement.checkValidity()`} method. + */ + public checkValidity(): boolean { + return this.elementInternals.checkValidity(); + } - if (this.value === undefined && this.slottedRadioButtons.length > 0) { - const checkedRadios: HTMLElement[] = this.slottedRadioButtons.filter((radio: HTMLElement) => { - return radio.hasAttribute('checked'); - }); - const numberOfCheckedRadios: number = checkedRadios !== null ? checkedRadios.length : 0; - if (numberOfCheckedRadios > 0 && !foundMatchingVal) { - const lastCheckedRadio: Radio = checkedRadios[numberOfCheckedRadios - 1] as Radio; - lastCheckedRadio.checked = true; - this.focusedRadio = lastCheckedRadio; - lastCheckedRadio.setAttribute('tabindex', '0'); - } else { - this.slottedRadioButtons[0].setAttribute('tabindex', '0'); - this.focusedRadio = this.slottedRadioButtons[0] as Radio; - } + /** + * Handles click events for the radio group. + * + * @param e - the click event + * @internal + */ + public clickHandler(e: MouseEvent): boolean | void { + if (this === e.target) { + this.enabledRadios[Math.max(0, this.checkedIndex)]?.focus(); } + + return true; } - private radioChangeHandler = (e: Event): boolean | void => { - const changedRadio: Radio = e.target as Radio; - - if (changedRadio.checked) { - this.slottedRadioButtons.forEach((radio: HTMLElement) => { - if (radio instanceof Radio && radio !== changedRadio) { - radio.checked = false; - if (!this.isInsideFoundationToolbar) { - radio.setAttribute('tabindex', '-1'); - } - } - }); - this.selectedRadio = changedRadio; - this.value = changedRadio.value; - changedRadio.setAttribute('tabindex', '0'); - this.focusedRadio = changedRadio; - } - e.stopPropagation(); - }; - - private moveToRadioByIndex = (group: HTMLElement[], index: number) => { - const radio: Radio = group[index] as Radio; - if (!this.isInsideToolbar) { - radio.setAttribute('tabindex', '0'); - radio.checked = true; - this.selectedRadio = radio; - } - this.focusedRadio = radio; - radio.focus(); - }; + constructor() { + super(); - private moveRightOffGroup = () => { - (this.nextElementSibling as Radio)?.focus(); - }; + this.elementInternals.role = 'radiogroup'; + this.elementInternals.ariaOrientation = this.orientation ?? RadioGroupOrientation.horizontal; + } - private moveLeftOffGroup = () => { - (this.previousElementSibling as Radio)?.focus(); - }; + connectedCallback(): void { + super.connectedCallback(); + this.setFormValue(this.value); + this.setValidity(); + } /** + * Focuses the checked radio or the first enabled radio. + * * @internal */ - public focusOutHandler = (e: FocusEvent): boolean | void => { - const group: HTMLElement[] = this.slottedRadioButtons; - const radio: Radio | null = e.target as Radio; - const index: number = radio !== null ? group.indexOf(radio) : 0; - const focusedIndex: number = this.focusedRadio ? group.indexOf(this.focusedRadio) : -1; - - if ( - (focusedIndex === 0 && index === focusedIndex) || - (focusedIndex === group.length - 1 && focusedIndex === index) - ) { - if (!this.selectedRadio) { - this.focusedRadio = group[0] as Radio; - this.focusedRadio.setAttribute('tabindex', '0'); - group.forEach((nextRadio: HTMLElement) => { - if (radio instanceof Radio && nextRadio !== this.focusedRadio) { - nextRadio.setAttribute('tabindex', '-1'); - } - }); - } else { - this.focusedRadio = this.selectedRadio; - - if (!this.isInsideFoundationToolbar) { - this.selectedRadio.setAttribute('tabindex', '0'); - group.forEach((nextRadio: HTMLElement) => { - if (nextRadio !== this.selectedRadio) { - nextRadio.setAttribute('tabindex', '-1'); - } - }); - } - } - } - return true; - }; + public focus() { + this.enabledRadios[Math.max(0, this.checkedIndex)]?.focus(); + } /** + * Enables tabbing through the radio group when the group receives focus. + * + * @param e - the focus event * @internal */ - public handleDisabledClick = (e: MouseEvent): void | boolean => { - // prevent focus events on items from the click handler when disabled - if (this.disabled) { - e.preventDefault(); - return; + public focusinHandler(e: FocusEvent): boolean | void { + if (!this.disabled) { + this.enabledRadios.forEach(radio => { + radio.tabIndex = 0; + }); } return true; - }; + } /** + * Sets the tabindex of the radios based on the checked state when the radio group loses focus. + * + * @param e - the focusout event * @internal */ - public clickHandler = (e: MouseEvent): void | boolean => { - if (this.disabled) { - return; + public focusoutHandler(e: FocusEvent): boolean | void { + if (this.radios?.includes(e.relatedTarget as Radio) && this.radios?.some(x => x.checked)) { + this.restrictFocus(); } - e.preventDefault(); - const radio: Radio | null = e.target as Radio; + return true; + } - if (radio && radio instanceof Radio) { - radio.checked = true; - radio.setAttribute('tabindex', '0'); - this.selectedRadio = radio; - this.focusedRadio = radio; - } - }; - - private shouldMoveOffGroupToTheRight = (index: number, group: HTMLElement[], key: string): boolean => { - return index === group.length && this.isInsideToolbar && key === keyArrowRight; - }; - - private shouldMoveOffGroupToTheLeft = (group: HTMLElement[], key: string): boolean => { - const index = this.focusedRadio ? group.indexOf(this.focusedRadio) - 1 : 0; - return index < 0 && this.isInsideToolbar && key === keyArrowLeft; - }; - - private checkFocusedRadio = (): void => { - if (this.focusedRadio !== null && !this.focusedRadio.checked) { - this.focusedRadio.checked = true; - this.focusedRadio.setAttribute('tabindex', '0'); - this.focusedRadio.focus(); - this.selectedRadio = this.focusedRadio; + formResetCallback(): void { + this.dirtyState = false; + this.checkedIndex = -1; + this.setFormValue(this.value); + this.setValidity(); + } + + private getEnabledIndexInBounds(index: number, upperBound = this.enabledRadios.length): number { + if (upperBound === 0) { + return -1; } - }; - private moveRight = (e: KeyboardEvent): void => { - const group: HTMLElement[] = this.slottedRadioButtons; - let index: number = 0; + return (index + upperBound) % upperBound; + } - index = this.focusedRadio ? group.indexOf(this.focusedRadio) + 1 : 1; - if (this.shouldMoveOffGroupToTheRight(index, group, e.key)) { - this.moveRightOffGroup(); - return; - } else if (index === group.length) { - index = 0; - } - /* looping to get to next radio that is not disabled */ - /* matching native radio/radiogroup which does not select an item if there is only 1 in the group */ - while (index < group.length && group.length > 1) { - if (!(group[index] as Radio).disabled) { - this.moveToRadioByIndex(group, index); + /** + * Handles keydown events for the radio group. + * + * @param e - the keyboard event + * @internal + */ + public keydownHandler(e: KeyboardEvent): boolean | void { + const isRtl = getDirection(this) === 'rtl'; + const checkedIndex = this.enabledRadios.findIndex(x => x === getRootActiveElement(this)) ?? this.checkedIndex; + let increment = 0; + + switch (e.key) { + case 'ArrowLeft': { + increment = isRtl ? 1 : -1; break; - } else if (this.focusedRadio && index === group.indexOf(this.focusedRadio)) { + } + + case 'ArrowUp': { + increment = -1; break; - } else if (index + 1 >= group.length) { - if (this.isInsideToolbar) { - break; - } else { - index = 0; - } - } else { - index += 1; } - } - }; - private moveLeft = (e: KeyboardEvent): void => { - const group: HTMLElement[] = this.slottedRadioButtons; - let index: number = 0; + case 'ArrowRight': { + increment = isRtl ? -1 : 1; + break; + } - index = this.focusedRadio ? group.indexOf(this.focusedRadio) - 1 : 0; - index = index < 0 ? group.length - 1 : index; + case 'ArrowDown': { + increment = 1; + break; + } - if (this.shouldMoveOffGroupToTheLeft(group, e.key)) { - this.moveLeftOffGroup(); - return; - } - /* looping to get to next radio that is not disabled */ - while (index >= 0 && group.length > 1) { - if (!(group[index] as Radio).disabled) { - this.moveToRadioByIndex(group, index); + case 'Tab': { + this.restrictFocus(); break; - } else if (this.focusedRadio && index === group.indexOf(this.focusedRadio)) { + } + + case ' ': { + this.checkRadio(); break; - } else if (index - 1 < 0) { - index = group.length - 1; - } else { - index -= 1; } } - }; + + if (!increment) { + return true; + } + + const nextIndex = checkedIndex + increment; + this.checkedIndex = this.getEnabledIndexInBounds(nextIndex); + this.enabledRadios[this.checkedIndex]?.focus(); + } + + /** + * + * @param e - the disabled event + */ + disabledRadioHandler(e: CustomEvent): void { + if (e.detail === true && (e.target as Radio).checked) { + this.checkedIndex = -1; + } + } /** - * keyboard handling per https://w3c.github.io/aria-practices/#for-radio-groups-not-contained-in-a-toolbar - * navigation is different when there is an ancestor with role='toolbar' + * Reports the validity of the element. + * + * @public + * @remarks + * Reflects the {@link https://developer.mozilla.org/docs/Web/API/ElementInternals/reportValidity | `HTMLInputElement.reportValidity()`} method. + */ + public reportValidity(): boolean { + return this.elementInternals.reportValidity(); + } + + /** + * Resets the `tabIndex` for all child radios when the radio group loses focus. * * @internal */ - public keydownHandler = (e: KeyboardEvent): boolean | void => { - const key = e.key; + private restrictFocus() { + let activeIndex = Math.max(this.checkedIndex, 0); + const focusedRadioIndex = this.enabledRadios.indexOf(getRootActiveElement(this) as Radio); - if (key in ArrowKeys && (this.isInsideFoundationToolbar || this.disabled)) { - return true; + if (focusedRadioIndex !== -1) { + activeIndex = focusedRadioIndex; } - switch (key) { - case keyEnter: { - this.checkFocusedRadio(); - break; - } + activeIndex = this.getEnabledIndexInBounds(activeIndex); - case keyArrowRight: - case keyArrowDown: { - if (this.direction === Direction.ltr) { - this.moveRight(e); - } else { - this.moveLeft(e); - } - break; - } + this.enabledRadios.forEach((item, index) => { + item.tabIndex = index === activeIndex ? 0 : -1; + }); + } - case keyArrowLeft: - case keyArrowUp: { - if (this.direction === Direction.ltr) { - this.moveLeft(e); - } else { - this.moveRight(e); - } - break; - } + /** + * Reflects the {@link https://developer.mozilla.org/docs/Web/API/ElementInternals/setFormValue | `ElementInternals.setFormValue()`} method. + * + * @internal + */ + public setFormValue(value: File | string | FormData | null, state?: File | string | FormData | null): void { + this.elementInternals.setFormValue(value, value ?? state); + } - default: { - return true; + /** + * Sets the validity of the control. + * + * @param flags - Validity flags to set. + * @param message - Optional message to supply. If not provided, the control's `validationMessage` will be used. + * @param anchor - Optional anchor to use for the validation message. + * + * @internal + */ + public setValidity(flags?: Partial, message?: string, anchor?: HTMLElement): void { + if (this.$fastController.isConnected) { + if (this.disabled || !this.required) { + this.elementInternals.setValidity({}); + return; } + + this.elementInternals.setValidity( + { valueMissing: this.required && !this.value, ...flags }, + message ?? this.validationMessage, + anchor ?? this.enabledRadios[0], + ); } - }; + } } From aee645e2d9f6382d19bd144b6edcfff5e705cc52 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 27 Jun 2024 12:25:03 -0700 Subject: [PATCH 07/14] update api-report --- packages/web-components/docs/api-report.md | 195 ++++++++++++++++----- 1 file changed, 155 insertions(+), 40 deletions(-) diff --git a/packages/web-components/docs/api-report.md b/packages/web-components/docs/api-report.md index 422c2332c91923..5078267974c21c 100644 --- a/packages/web-components/docs/api-report.md +++ b/packages/web-components/docs/api-report.md @@ -588,14 +588,22 @@ export const ButtonType: { export type ButtonType = ValuesOf; // Warning: (ae-forgotten-export) The symbol "BaseCheckbox" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "Checkbox" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public export class Checkbox extends BaseCheckbox { + constructor(); + // @internal + formResetCallback(): void; + indeterminate?: boolean; + // @internal + indeterminateChanged(prev: boolean | undefined, next: boolean | undefined): void; + // (undocumented) + setAriaChecked(value?: boolean): void; shape?: CheckboxShape; shapeChanged(prev: CheckboxShape | undefined, next: CheckboxShape | undefined): void; size?: CheckboxSize; sizeChanged(prev: CheckboxSize | undefined, next: CheckboxSize | undefined): void; + toggleChecked(force?: boolean): void; } // @public @@ -1967,6 +1975,72 @@ export const DividerStyles: ElementStyles; // @public export const DividerTemplate: ElementViewTemplate; +// Warning: (ae-missing-release-tag) "Drawer" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class Drawer extends FASTElement { + ariaDescribedby?: string; + ariaLabelledby?: string; + // (undocumented) + clickHandler(event: Event): boolean; + dialog: HTMLDialogElement; + emitBeforeToggle: () => void; + emitToggle: () => void; + hide(): void; + position: DrawerPosition; + show(): void; + // (undocumented) + size: DrawerSize; + type: DrawerType; +} + +// @public (undocumented) +export const DrawerDefinition: FASTElementDefinition; + +// Warning: (ae-missing-release-tag) "DrawerPosition" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const DrawerPosition: { + readonly start: "start"; + readonly end: "end"; +}; + +// @public +export type DrawerPosition = ValuesOf; + +// Warning: (ae-missing-release-tag) "DrawerSize" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const DrawerSize: { + readonly small: "small"; + readonly medium: "medium"; + readonly large: "large"; + readonly full: "full"; +}; + +// @public +export type DrawerSize = ValuesOf; + +// @public +export const DrawerStyles: ElementStyles; + +// Warning: (ae-missing-release-tag) "template" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const DrawerTemplate: ElementViewTemplate; + +// Warning: (ae-missing-release-tag) "DrawerType" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const DrawerType: { + readonly nonModal: "non-modal"; + readonly modal: "modal"; + readonly inline: "inline"; +}; + +// @public +export type DrawerType = ValuesOf; + // @public export const durationFast = "var(--durationFast)"; @@ -1993,8 +2067,9 @@ export const durationUltraSlow = "var(--durationUltraSlow)"; // @public export class Field extends FASTElement { + constructor(); // @internal - changeHandler(e: Event): void; + changeHandler(e: Event): boolean | void; // @internal clickHandler(e: MouseEvent): boolean | void; // @internal @@ -2004,15 +2079,21 @@ export class Field extends FASTElement { // @internal focusoutHandler(e: FocusEvent): boolean | void; input: SlottableInput; + inputChanged(prev: SlottableInput | undefined, next: SlottableInput | undefined): void; // @internal invalidHandler(e: Event): boolean | void; labelPosition: FieldLabelPosition; // @internal + labelSlot: Node[]; + protected labelSlotChanged(prev: Node[], next: Node[]): void; + // @internal messageSlot: Element[]; // @internal messageSlotChanged(prev: Element[], next: Element[]): void; // @internal setStates(): void; + // (undocumented) + setValidationStates(): void; // @internal slottedInputs: SlottableInput[]; // @internal @@ -2535,22 +2616,25 @@ export const ProgressBarValidationState: { // @public export type ProgressBarValidationState = ValuesOf; -// Warning: (ae-forgotten-export) The symbol "FormAssociatedRadio" needs to be exported by the entry point index.d.ts -// // @public -export class Radio extends FormAssociatedRadio implements RadioControl { +export class Radio extends BaseCheckbox { constructor(); - // @internal (undocumented) + get ariaPosInSet(): string | null; + set ariaPosInSet(value: string | null); + get ariaSetSize(): string | null; + set ariaSetSize(value: string | null); + // (undocumented) connectedCallback(): void; - // @internal (undocumented) - defaultCheckedChanged(): void; - // @internal (undocumented) - defaultSlottedNodes: Node[]; - // @internal - initialValue: string; - // @beta - keypressHandler(e: KeyboardEvent): boolean | void; - name: string; + // (undocumented) + protected disabledChanged(prev: boolean | undefined, next: boolean | undefined): void; + // (undocumented) + requiredChanged(): void; + // (undocumented) + setFormValue(): void; + // (undocumented) + setValidity(): void; + // (undocumented) + toggleChecked(state?: boolean): void; } // @public (undocumented) @@ -2561,34 +2645,62 @@ export const RadioDefinition: FASTElementDefinition; // @public export class RadioGroup extends FASTElement { + constructor(); + changeHandler(e: Event): boolean | void; + // @internal + protected checkedIndex: number; + // @internal + protected checkedIndexChanged(prev: number | undefined, next: number): void; + // @internal + checkRadio(index?: number): void; + checkValidity(): boolean; + // @internal + clickHandler(e: MouseEvent): boolean | void; // (undocumented) - childItems: HTMLElement[]; - // @internal (undocumented) - clickHandler: (e: MouseEvent) => void | boolean; - // @internal (undocumented) connectedCallback(): void; disabled: boolean; - // (undocumented) - disconnectedCallback(): void; - // @internal (undocumented) - focusOutHandler: (e: FocusEvent) => boolean | void; - // @internal (undocumented) - handleDisabledClick: (e: MouseEvent) => void | boolean; // @internal - keydownHandler: (e: KeyboardEvent) => boolean | void; - name: string; + protected disabledChanged(prev?: boolean, next?: boolean): void; // (undocumented) - protected nameChanged(): void; - orientation: RadioGroupOrientation; - readOnly: boolean; - // @internal (undocumented) - slottedRadioButtons: HTMLElement[]; + disabledRadioHandler(e: CustomEvent): void; + // @internal + elementInternals: ElementInternals; + // @internal + get enabledRadios(): Radio[]; + // @internal + focus(): void; + // @internal + focusinHandler(e: FocusEvent): boolean | void; + // @internal + focusoutHandler(e: FocusEvent): boolean | void; + static formAssociated: boolean; // (undocumented) - protected slottedRadioButtonsChanged(oldValue: unknown, newValue: HTMLElement[]): void; - stacked: boolean; - value: string; + formResetCallback(): void; + initialValue?: string; + initialValueChanged(prev: string | undefined, next: string | undefined): void; + // @internal + keydownHandler(e: KeyboardEvent): boolean | void; + name: string; + // @internal + protected nameChanged(prev: string | undefined, next: string | undefined): void; + orientation?: RadioGroupOrientation; + // @internal + orientationChanged(prev: RadioGroupOrientation | undefined, next: RadioGroupOrientation | undefined): void; + radios?: Radio[]; + radiosChanged(prev: Radio[] | undefined, next: Radio[] | undefined): void; + reportValidity(): boolean; + required: boolean; // (undocumented) - protected valueChanged(): void; + requiredChanged(prev: boolean, next: boolean): void; + // @internal + setFormValue(value: File | string | FormData | null, state?: File | string | FormData | null): void; + // @internal + setValidity(flags?: Partial, message?: string, anchor?: HTMLElement): void; + // @internal + get validationMessage(): string; + get validity(): ValidityState; + get value(): string | null; + set value(next: string | null); } // @public @@ -2622,15 +2734,14 @@ export type RadioOptions = { // @public export const RadioStyles: ElementStyles; -// Warning: (ae-missing-release-tag) "template" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const RadioTemplate: ElementViewTemplate; // @public export class RatingDisplay extends FASTElement { constructor(); color?: RatingDisplayColor; + colorChanged(prev: RatingDisplayColor | undefined, next: RatingDisplayColor | undefined): void; compact: boolean; count?: number; // @internal @@ -2641,6 +2752,7 @@ export class RatingDisplay extends FASTElement { generateIcons(): string; max?: number; size?: RatingDisplaySize; + sizeChanged(prev: RatingDisplaySize | undefined, next: RatingDisplaySize | undefined): void; value?: number; } @@ -2855,6 +2967,8 @@ export type SlottableInput = HTMLElement & ElementInternals & { required: boolean; disabled: boolean; readOnly: boolean; + checked?: boolean; + value?: string; }; // @public @@ -2991,6 +3105,7 @@ export { styles as MenuButtonStyles } // // @public (undocumented) export class Switch extends BaseCheckbox { + constructor(); } // @public From fdd92a310c5c2323d8cf91dfbd16951fb27464d0 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 27 Jun 2024 12:25:44 -0700 Subject: [PATCH 08/14] change files --- ...eb-components-0404c908-9458-47f2-9bf9-fe362e2701df.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@fluentui-web-components-0404c908-9458-47f2-9bf9-fe362e2701df.json diff --git a/change/@fluentui-web-components-0404c908-9458-47f2-9bf9-fe362e2701df.json b/change/@fluentui-web-components-0404c908-9458-47f2-9bf9-fe362e2701df.json new file mode 100644 index 00000000000000..088a208ba19d25 --- /dev/null +++ b/change/@fluentui-web-components-0404c908-9458-47f2-9bf9-fe362e2701df.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat: update radio and radio-group to use ElementInternals for form association", + "packageName": "@fluentui/web-components", + "email": "863023+radium-v@users.noreply.github.com", + "dependentChangeType": "patch" +} From f1716b772f59122797891248af8e3192a46580b9 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Thu, 27 Jun 2024 17:52:23 -0700 Subject: [PATCH 09/14] add more tests --- .../src/checkbox/checkbox.spec.ts | 16 ++ .../src/radio-group/radio-group.spec.ts | 218 +++++++++++++++++- .../src/radio-group/radio-group.ts | 22 +- .../web-components/src/radio/radio.spec.ts | 100 ++++++++ 4 files changed, 337 insertions(+), 19 deletions(-) diff --git a/packages/web-components/src/checkbox/checkbox.spec.ts b/packages/web-components/src/checkbox/checkbox.spec.ts index aeeb7463e5b9bb..a9080afd98b621 100644 --- a/packages/web-components/src/checkbox/checkbox.spec.ts +++ b/packages/web-components/src/checkbox/checkbox.spec.ts @@ -29,6 +29,14 @@ test.describe('Checkbox', () => { await expect(element).toHaveAttribute('shape', 'square'); }); + + await test.step('should unset the `shape` property when the attribute is removed', async () => { + await element.evaluate((node: Checkbox) => { + node.removeAttribute('shape'); + }); + + await expect(element).toHaveJSProperty('shape', null); + }); }); test('should add a custom state matching the `shape` attribute when provided', async ({ page }) => { @@ -81,6 +89,14 @@ test.describe('Checkbox', () => { await expect(element).toHaveJSProperty('size', 'large'); }); + + await test.step('should unset the `size` property when the attribute is removed', async () => { + await element.evaluate((node: Checkbox) => { + node.removeAttribute('size'); + }); + + await expect(element).toHaveJSProperty('size', null); + }); }); test('should add a custom state matching the `size` attribute when provided', async ({ page }) => { diff --git a/packages/web-components/src/radio-group/radio-group.spec.ts b/packages/web-components/src/radio-group/radio-group.spec.ts index 3980f9bfb742bf..ff19e0708b31f9 100644 --- a/packages/web-components/src/radio-group/radio-group.spec.ts +++ b/packages/web-components/src/radio-group/radio-group.spec.ts @@ -73,26 +73,29 @@ test.describe('RadioGroup', () => { const secondRadio = radios.nth(1); const thirdRadio = radios.nth(2); - const expectedFirst = await firstRadio.evaluate(node => node.hasAttribute('disabled')); - const expectedSecond = await secondRadio.evaluate(node => node.hasAttribute('disabled')); - const expectedThird = await thirdRadio.evaluate(node => node.hasAttribute('disabled')); + const expectedFirst = await firstRadio.evaluate((node: Radio) => node.hasAttribute('disabled')); + const expectedSecond = await secondRadio.evaluate((node: Radio) => node.hasAttribute('disabled')); + const expectedThird = await thirdRadio.evaluate((node: Radio) => node.hasAttribute('disabled')); - expect(await firstRadio.evaluate(radio => radio.hasAttribute('disabled'))).toEqual(expectedFirst); + expect(await firstRadio.evaluate((radio: Radio) => radio.hasAttribute('disabled'))).toEqual(expectedFirst); - expect(await secondRadio.evaluate(radio => radio.hasAttribute('disabled'))).toEqual(expectedSecond); + expect(await secondRadio.evaluate((radio: Radio) => radio.hasAttribute('disabled'))).toEqual(expectedSecond); - expect(await thirdRadio.evaluate(radio => radio.hasAttribute('disabled'))).toEqual(expectedThird); + expect(await thirdRadio.evaluate((radio: Radio) => radio.hasAttribute('disabled'))).toEqual(expectedThird); - element.evaluate(node => node.setAttribute('disabled', '')); + element.evaluate((node: RadioGroup) => { + node.toggleAttribute('disabled'); + }); const hasDisabledAttributeAfter = await element.evaluate((node: Element) => node.hasAttribute('disabled')); + expect(hasDisabledAttributeAfter).toBe(true); - expect(await firstRadio.evaluate(radio => radio.hasAttribute('disabled'))).toEqual(expectedFirst); + expect(await firstRadio.evaluate((radio: Radio) => radio.hasAttribute('disabled'))).toEqual(expectedFirst); - expect(await secondRadio.evaluate(radio => radio.hasAttribute('disabled'))).toEqual(expectedSecond); + expect(await secondRadio.evaluate((radio: Radio) => radio.hasAttribute('disabled'))).toEqual(expectedSecond); - expect(await thirdRadio.evaluate(radio => radio.hasAttribute('disabled'))).toEqual(expectedThird); + expect(await thirdRadio.evaluate((radio: Radio) => radio.hasAttribute('disabled'))).toEqual(expectedThird); }); test('should NOT be focusable when disabled', async ({ page }) => { @@ -150,7 +153,8 @@ test.describe('RadioGroup', () => { await element.evaluate(node => node.setAttribute('disabled', '')); const isDisabled = await element.evaluate((node: Element) => node.hasAttribute('disabled')); - await expect(isDisabled).toBe(true); + + expect(isDisabled).toBe(true); for (let i = 0; i < radioItemsCount; i++) { const item = radios.nth(i); @@ -419,4 +423,196 @@ test.describe('RadioGroup', () => { await expect(radios.nth(0)).toBeFocused(); }); }); + + test('should adopt the `name` of the radios when every radio has the same `name` and the radio group has no `name` attribute', async ({ + page, + }) => { + const element = page.locator('fluent-radio-group'); + + await page.setContent(/* html */ ` + + + + + + `); + + await expect(element).toHaveJSProperty('name', 'foo'); + }); + + test('should NOT adopt the `name` of the radios when the radios have different `name` attributes', async ({ + page, + }) => { + const element = page.locator('fluent-radio-group'); + + await page.setContent(/* html */ ` + + + + + + `); + + await expect(element).not.toHaveAttribute('name'); + }); + + test('should set the `name` attribute of the radios to the `name` attribute of the radio group', async ({ page }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); + + await page.setContent(/* html */ ` + + + + + + `); + + expect(await radios.evaluateAll((radios: Radio[]) => radios.every(radio => radio.name === 'foo'))).toBe(true); + }); + + test('should override the `name` attribute of the radios with the `name` attribute of the radio group', async ({ + page, + }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); + + await page.setContent(/* html */ ` + + + + + + `); + + await expect(element).toHaveAttribute('name', 'foo'); + + expect( + await radios.evaluateAll((radios: Radio[]) => radios.every(radio => radio.getAttribute('name') === 'foo')), + ).toBe(true); + + expect(await radios.evaluateAll((radios: Radio[]) => radios.every(radio => radio.name === 'foo'))).toBe(true); + }); + + test('should submit the value of the checked radio when the radio group is in a form and the form is submitted', async ({ + page, + }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); + + await page.setContent(/* html */ ` +
+ + + + + + +
+ `); + + const button = page.locator('button'); + + await radios.nth(1).click(); + + await button.click(); + + await expect(page).toHaveURL(/radio=bar/); + }); + + test('should NOT submit the value of the checked radio when the radio group is in a form and the form is submitted and the radio group is disabled', async ({ + page, + }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); + + await page.setContent(/* html */ ` +
+ + + + + + +
+ `); + + const button = page.locator('button'); + + await radios.nth(1).click(); + + await button.click(); + + await expect(page).not.toHaveURL(/radio=/); + }); + + test('should NOT submit the value of the checked radio when the radio group is in a form and the form is submitted and the radio group has no name', async ({ + page, + }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); + + await page.setContent(/* html */ ` +
+ + + + + + +
+ `); + + const button = page.locator('button'); + + await radios.nth(1).click(); + + await button.click(); + + await expect(page).not.toHaveURL(/radio=/); + }); + + test('should NOT submit the value of the checked radio when the radio group is in a form and the form is submitted and the radio group has no radios', async ({ + page, + }) => { + await page.setContent(/* html */ ` +
+ + +
+ `); + + const button = page.locator('button'); + + await button.click(); + + await expect(page).not.toHaveURL(/radio=/); + }); + + test('should NOT submit the value of the checked radio when the radio group is in a form and the form is submitted and the radio group has no enabled radios', async ({ + page, + }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); + const button = page.locator('button'); + + await page.setContent(/* html */ ` +
+ + + + + + +
+ `); + + await radios.nth(1).click(); + + await expect(radios.nth(1)).toHaveJSProperty('checked', false); + + await button.click(); + + await expect(page).not.toHaveURL(/radio=/); + }); }); diff --git a/packages/web-components/src/radio-group/radio-group.ts b/packages/web-components/src/radio-group/radio-group.ts index 109065a474ca8e..a2b12392815d47 100644 --- a/packages/web-components/src/radio-group/radio-group.ts +++ b/packages/web-components/src/radio-group/radio-group.ts @@ -147,26 +147,32 @@ export class RadioGroup extends FASTElement { return; } - // TODO: Switch to standard `Array.findLastIndex` when TypeScript 5 is available - const lastCheckedIndex = findLastIndex(this.enabledRadios, x => x.checked); - - if (!this.dirtyState) { - this.value = this.initialValue ?? next[lastCheckedIndex]?.value ?? ''; + if (!this.name && next.every(x => x.name === next[0].name)) { + this.name = next[0].name; } next.forEach((radio, index, radios) => { radio.ariaPosInSet = (index + 1).toString(); radio.ariaSetSize = radios.length.toString(); - radio.checked = !this.disabled && index === lastCheckedIndex; + radio.checked = false; radio.disabled = this.disabled || radio.disabledAttribute; radio.name = this.name; }); - this.checkedIndex = lastCheckedIndex; + if (!this.dirtyState && this.initialValue) { + this.value = this.initialValue; + } + + if (!this.value) { + // TODO: Switch to standard `Array.findLastIndex` when TypeScript 5 is available + this.checkedIndex = findLastIndex(this.enabledRadios, x => x.initialChecked); + } this.setAttribute('aria-owns', next.map(radio => radio.id).join(' ')); - Updates.enqueue(() => this.restrictFocus()); + Updates.enqueue(() => { + this.restrictFocus(); + }); } /** diff --git a/packages/web-components/src/radio/radio.spec.ts b/packages/web-components/src/radio/radio.spec.ts index 1433540352d7fc..897d7c760d38ab 100644 --- a/packages/web-components/src/radio/radio.spec.ts +++ b/packages/web-components/src/radio/radio.spec.ts @@ -248,4 +248,104 @@ test.describe('Radio', () => { await expect(element).toHaveJSProperty('checked', true); }); }); + + test('should set the `checked` property to false if the `checked` attribute is unset', async ({ page }) => { + const element = page.locator('fluent-radio'); + const form = page.locator('form'); + + await page.setContent(/* html */ ` +
+ +
+ `); + + await expect(element).toHaveJSProperty('checked', false); + + await element.evaluate((node: Radio) => { + node.checked = true; + }); + + await expect(element).toHaveJSProperty('checked', true); + + await form.evaluate((node: HTMLFormElement) => { + node.reset(); + }); + + await expect(element).toHaveJSProperty('checked', false); + }); + + test('should set its checked property to true if the checked attribute is set', async ({ page }) => { + const element = page.locator('fluent-radio'); + const form = page.locator('form'); + + await page.setContent(/* html */ ` +
+ +
+ `); + + await expect(element).toHaveJSProperty('checked', false); + + await element.evaluate((node: Radio) => { + node.setAttribute('checked', ''); + }); + + await expect(element).toHaveJSProperty('checked', true); + + await form.evaluate((node: HTMLFormElement) => { + node.reset(); + }); + + await expect(element).toHaveJSProperty('checked', true); + }); + + test('should put the control into a clean state, where `checked` attribute modifications change the `checked` property prior to user or programmatic interaction', async ({ + page, + }) => { + const element = page.locator('fluent-radio'); + const form = page.locator('form'); + + await page.setContent(/* html */ ` +
+ +
+ `); + + await element.evaluate((node: Radio) => { + node.checked = true; + node.removeAttribute('checked'); + }); + + await expect(element).toHaveJSProperty('checked', true); + + await form.evaluate((node: HTMLFormElement) => { + node.reset(); + }); + + await expect(element).toHaveJSProperty('checked', false); + + await element.evaluate((node: Radio) => { + node.setAttribute('checked', ''); + }); + + expect(await element.evaluate((node: Radio) => node.value)).toBeTruthy(); + }); + + test('should NOT submit the value of the radio when checked', async ({ page }) => { + const element = page.locator('fluent-radio'); + const submitButton = page.locator('button'); + + await page.setContent(/* html */ ` +
+ + +
+ `); + + await element.click(); + + await submitButton.click(); + + expect(page.url()).not.toContain('?radio=foo'); + }); }); From ad84c9a7d2e279884375d4b9b4bdd5f969830c4e Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Mon, 1 Jul 2024 17:02:51 -0700 Subject: [PATCH 10/14] remove ARIA getters and setters from radio --- .../src/radio-group/radio-group.spec.ts | 23 ++++++++++++++ .../src/radio-group/radio-group.ts | 5 ++-- packages/web-components/src/radio/radio.ts | 30 ------------------- 3 files changed, 26 insertions(+), 32 deletions(-) diff --git a/packages/web-components/src/radio-group/radio-group.spec.ts b/packages/web-components/src/radio-group/radio-group.spec.ts index ff19e0708b31f9..077318b96d69da 100644 --- a/packages/web-components/src/radio-group/radio-group.spec.ts +++ b/packages/web-components/src/radio-group/radio-group.spec.ts @@ -52,6 +52,29 @@ test.describe('RadioGroup', () => { await expect(element).toHaveJSProperty('elementInternals.ariaOrientation', 'vertical'); }); + test('should set the `aria-setsize` and `aria-posinset` attributes on the radios', async ({ page }) => { + const element = page.locator('fluent-radio-group'); + const radios = element.locator('fluent-radio'); + + await page.setContent(/* html */ ` + + + + + + `); + + expect( + await radios.evaluateAll((radios: Radio[]) => radios.every(radio => radio.hasAttribute('aria-posinset'))), + ).toBe(true); + + await expect(radios.nth(0)).toHaveAttribute('aria-posinset', '1'); + + await expect(radios.nth(1)).toHaveAttribute('aria-posinset', '2'); + + await expect(radios.nth(2)).toHaveAttribute('aria-posinset', '3'); + }); + test('should NOT modify child radio elements disabled state when the `disabled` attribute is present', async ({ page, }) => { diff --git a/packages/web-components/src/radio-group/radio-group.ts b/packages/web-components/src/radio-group/radio-group.ts index a2b12392815d47..e436fb99554b00 100644 --- a/packages/web-components/src/radio-group/radio-group.ts +++ b/packages/web-components/src/radio-group/radio-group.ts @@ -151,9 +151,10 @@ export class RadioGroup extends FASTElement { this.name = next[0].name; } - next.forEach((radio, index, radios) => { + const setSize = next.length.toString(); + next.forEach((radio, index) => { radio.ariaPosInSet = (index + 1).toString(); - radio.ariaSetSize = radios.length.toString(); + radio.ariaSetSize = setSize; radio.checked = false; radio.disabled = this.disabled || radio.disabledAttribute; radio.name = this.name; diff --git a/packages/web-components/src/radio/radio.ts b/packages/web-components/src/radio/radio.ts index 796fc96a811da2..110190fe4ce102 100644 --- a/packages/web-components/src/radio/radio.ts +++ b/packages/web-components/src/radio/radio.ts @@ -11,36 +11,6 @@ import { BaseCheckbox } from '../checkbox/checkbox.js'; * @public */ export class Radio extends BaseCheckbox { - /** - * Indicates the position of the radio in a radio group. - * - * @public - * @remarks - * Reflects the {@link https://developer.mozilla.org/docs/Web/API/ElementInternals/ariaPosInSet | `ElementInternals.ariaPosInSet`} property. - */ - public get ariaPosInSet(): string | null { - return this.elementInternals.ariaPosInSet; - } - - public set ariaPosInSet(value: string | null) { - this.elementInternals.ariaPosInSet = value; - } - - /** - * Indicates the number of radio buttons in the associated radio group. - * - * @public - * @remarks - * Reflects the {@link https://developer.mozilla.org/docs/Web/API/ElementInternals/ariaSetSize | `ElementInternals.ariaSetSize`} property. - */ - public get ariaSetSize(): string | null { - return this.elementInternals.ariaSetSize; - } - - public set ariaSetSize(value: string | null) { - this.elementInternals.ariaSetSize = value; - } - connectedCallback() { super.connectedCallback(); From 66a29aaf33f5c770f75e2371e23cb85986f5d6a2 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Tue, 2 Jul 2024 10:12:36 -0700 Subject: [PATCH 11/14] remove formResetCallback override for checkbox indeterminate state --- .../src/checkbox/checkbox.spec.ts | 25 ++++++++++++++++--- .../web-components/src/checkbox/checkbox.ts | 10 -------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/web-components/src/checkbox/checkbox.spec.ts b/packages/web-components/src/checkbox/checkbox.spec.ts index a9080afd98b621..64fae184f17a6e 100644 --- a/packages/web-components/src/checkbox/checkbox.spec.ts +++ b/packages/web-components/src/checkbox/checkbox.spec.ts @@ -239,11 +239,14 @@ test.describe('Checkbox', () => { await expect(element).toHaveJSProperty('indeterminate', false); }); - test('should clear the `indeterminate` state when the `checked` property is true', async ({ page }) => { + test('should NOT change the `indeterminate` property when the owning form is reset', async ({ page }) => { const element = page.locator('fluent-checkbox'); + const form = page.locator('form'); await page.setContent(/* html */ ` - +
+ +
`); await element.evaluate((node: Checkbox) => { @@ -252,9 +255,23 @@ test.describe('Checkbox', () => { await expect(element).toHaveJSProperty('indeterminate', true); - await element.press(' '); + await form.evaluate((node: HTMLFormElement) => { + node.reset(); + }); + + await expect(element).toHaveJSProperty('indeterminate', true); + + await test.step('should retain the `indeterminate` property after being set to `false` via user interaction', async () => { + await element.click(); - await expect(element).toHaveJSProperty('indeterminate', false); + await expect(element).toHaveJSProperty('indeterminate', false); + + await form.evaluate((node: HTMLFormElement) => { + node.reset(); + }); + + await expect(element).toHaveJSProperty('indeterminate', false); + }); }); test('should initialize to the initial value if no value property is set', async ({ page }) => { diff --git a/packages/web-components/src/checkbox/checkbox.ts b/packages/web-components/src/checkbox/checkbox.ts index fdf2ab2fb6123d..61bfaa1791823c 100644 --- a/packages/web-components/src/checkbox/checkbox.ts +++ b/packages/web-components/src/checkbox/checkbox.ts @@ -554,16 +554,6 @@ export class Checkbox extends BaseCheckbox { this.elementInternals.role = 'checkbox'; } - /** - * Resets the form value to its initial value when the form is reset. - * - * @internal - */ - formResetCallback(): void { - this.indeterminate = false; - super.formResetCallback(); - } - /** * Toggles the checked state of the control. * From 6d0bb905f9b3cdf0964e529c7acb903997a1a83a Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Wed, 3 Jul 2024 08:23:54 -0700 Subject: [PATCH 12/14] fix test failures in Webkit browsers --- .../src/radio-group/radio-group.spec.ts | 8 +-- .../src/radio-group/radio-group.styles.ts | 1 + .../src/radio-group/radio-group.template.ts | 12 +--- .../src/radio-group/radio-group.ts | 61 +++++++++++++------ 4 files changed, 49 insertions(+), 33 deletions(-) diff --git a/packages/web-components/src/radio-group/radio-group.spec.ts b/packages/web-components/src/radio-group/radio-group.spec.ts index 077318b96d69da..40f517ed4f9911 100644 --- a/packages/web-components/src/radio-group/radio-group.spec.ts +++ b/packages/web-components/src/radio-group/radio-group.spec.ts @@ -64,15 +64,15 @@ test.describe('RadioGroup', () => {
`); - expect( - await radios.evaluateAll((radios: Radio[]) => radios.every(radio => radio.hasAttribute('aria-posinset'))), - ).toBe(true); - await expect(radios.nth(0)).toHaveAttribute('aria-posinset', '1'); + await expect(radios.nth(0)).toHaveAttribute('aria-setsize', '3'); + await expect(radios.nth(1)).toHaveAttribute('aria-posinset', '2'); + await expect(radios.nth(1)).toHaveAttribute('aria-setsize', '3'); await expect(radios.nth(2)).toHaveAttribute('aria-posinset', '3'); + await expect(radios.nth(2)).toHaveAttribute('aria-setsize', '3'); }); test('should NOT modify child radio elements disabled state when the `disabled` attribute is present', async ({ diff --git a/packages/web-components/src/radio-group/radio-group.styles.ts b/packages/web-components/src/radio-group/radio-group.styles.ts index 048cbdee2e6821..dbbb16193dd840 100644 --- a/packages/web-components/src/radio-group/radio-group.styles.ts +++ b/packages/web-components/src/radio-group/radio-group.styles.ts @@ -15,6 +15,7 @@ export const styles = css` ${display('flex')} :host { + -webkit-tap-highlight-color: transparent; cursor: pointer; } diff --git a/packages/web-components/src/radio-group/radio-group.template.ts b/packages/web-components/src/radio-group/radio-group.template.ts index 68b7029751d028..fb446e67de2cd7 100644 --- a/packages/web-components/src/radio-group/radio-group.template.ts +++ b/packages/web-components/src/radio-group/radio-group.template.ts @@ -1,6 +1,4 @@ -import type { ElementViewTemplate } from '@microsoft/fast-element'; -import { children, html } from '@microsoft/fast-element'; -import { Radio } from '../radio/radio.js'; +import { ElementViewTemplate, html } from '@microsoft/fast-element'; import type { RadioGroup } from './radio-group.js'; export function radioGroupTemplate(): ElementViewTemplate { @@ -12,14 +10,8 @@ export function radioGroupTemplate(): ElementViewTemplate< @focusin="${(x, c) => x.focusinHandler(c.event as FocusEvent)}" @focusout="${(x, c) => x.focusoutHandler(c.event as FocusEvent)}" @keydown="${(x, c) => x.keydownHandler(c.event as KeyboardEvent)}" - ${children({ - property: 'radios', - filter: x => x instanceof Radio, - selector: '*', - subtree: true, - })} > - + `; } diff --git a/packages/web-components/src/radio-group/radio-group.ts b/packages/web-components/src/radio-group/radio-group.ts index e436fb99554b00..db024bbca36635 100644 --- a/packages/web-components/src/radio-group/radio-group.ts +++ b/packages/web-components/src/radio-group/radio-group.ts @@ -37,6 +37,9 @@ export class RadioGroup extends FASTElement { this.checkRadio(next); } + /** + * Indicates that the value has been changed by the user. + */ private dirtyState: boolean = false; /** @@ -102,9 +105,11 @@ export class RadioGroup extends FASTElement { * @internal */ protected nameChanged(prev: string | undefined, next: string | undefined): void { - this.radios?.forEach(radio => { - radio.name = this.name; - }); + if (this.isConnected && next) { + this.radios?.forEach(radio => { + radio.name = this.name; + }); + } } /** @@ -134,7 +139,7 @@ export class RadioGroup extends FASTElement { * @public */ @observable - public radios?: Radio[]; + public radios!: Radio[]; /** * Updates the enabled radios collection when properties on the child radios change. @@ -143,7 +148,8 @@ export class RadioGroup extends FASTElement { * @param next - the current radios */ public radiosChanged(prev: Radio[] | undefined, next: Radio[] | undefined): void { - if (!next?.length) { + const setSize = next?.length; + if (!setSize) { return; } @@ -151,13 +157,20 @@ export class RadioGroup extends FASTElement { this.name = next[0].name; } - const setSize = next.length.toString(); + const checkedIndex = findLastIndex(this.enabledRadios, x => x.initialChecked); + next.forEach((radio, index) => { - radio.ariaPosInSet = (index + 1).toString(); - radio.ariaSetSize = setSize; - radio.checked = false; + radio.ariaPosInSet = `${index + 1}`; + radio.ariaSetSize = `${setSize}`; + + if (this.initialValue && !this.dirtyState) { + radio.checked = radio.value === this.initialValue; + } else { + radio.checked = index === checkedIndex; + } + + radio.name = this.name ?? radio.name; radio.disabled = this.disabled || radio.disabledAttribute; - radio.name = this.name; }); if (!this.dirtyState && this.initialValue) { @@ -166,10 +179,14 @@ export class RadioGroup extends FASTElement { if (!this.value) { // TODO: Switch to standard `Array.findLastIndex` when TypeScript 5 is available - this.checkedIndex = findLastIndex(this.enabledRadios, x => x.initialChecked); + this.checkedIndex = checkedIndex; } - this.setAttribute('aria-owns', next.map(radio => radio.id).join(' ')); + // prettier-ignore + const radioIds = next.map(radio => radio.id).join(' ').trim(); + if (radioIds) { + this.setAttribute('aria-owns', radioIds); + } Updates.enqueue(() => { this.restrictFocus(); @@ -302,7 +319,7 @@ export class RadioGroup extends FASTElement { } this.dirtyState = true; - const radioIndex = this.enabledRadios.indexOf(e.target as Radio) ?? this.checkedIndex; + const radioIndex = this.enabledRadios.indexOf(e.target as Radio); this.checkRadio(radioIndex); return true; } @@ -361,12 +378,6 @@ export class RadioGroup extends FASTElement { this.elementInternals.ariaOrientation = this.orientation ?? RadioGroupOrientation.horizontal; } - connectedCallback(): void { - super.connectedCallback(); - this.setFormValue(this.value); - this.setValidity(); - } - /** * Focuses the checked radio or the first enabled radio. * @@ -546,4 +557,16 @@ export class RadioGroup extends FASTElement { ); } } + + /** + * Updates the collection of child radios when the slot changes. + * + * @param e - the slot change event + * @internal + */ + public slotchangeHandler(e: Event): void { + Updates.enqueue(() => { + this.radios = [...this.querySelectorAll('*')].filter(x => x instanceof Radio) as Radio[]; + }); + } } From f989c6f7d4ad5dfb7c195638a16552f37110c75e Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Wed, 3 Jul 2024 13:10:52 -0700 Subject: [PATCH 13/14] update method access and add documentation --- packages/web-components/docs/api-report.md | 36 ++++------ .../src/checkbox/checkbox.template.ts | 2 +- .../web-components/src/checkbox/checkbox.ts | 70 ++++++++++++------- .../src/radio-group/radio-group.ts | 4 +- packages/web-components/src/radio/radio.ts | 47 ++++++++++++- .../src/switch/switch.template.ts | 2 +- 6 files changed, 106 insertions(+), 55 deletions(-) diff --git a/packages/web-components/docs/api-report.md b/packages/web-components/docs/api-report.md index 5078267974c21c..bbe424b8d6d572 100644 --- a/packages/web-components/docs/api-report.md +++ b/packages/web-components/docs/api-report.md @@ -592,17 +592,15 @@ export type ButtonType = ValuesOf; // @public export class Checkbox extends BaseCheckbox { constructor(); - // @internal - formResetCallback(): void; indeterminate?: boolean; // @internal - indeterminateChanged(prev: boolean | undefined, next: boolean | undefined): void; - // (undocumented) - setAriaChecked(value?: boolean): void; + protected indeterminateChanged(prev: boolean | undefined, next: boolean | undefined): void; + // @override + protected setAriaChecked(value?: boolean): void; shape?: CheckboxShape; - shapeChanged(prev: CheckboxShape | undefined, next: CheckboxShape | undefined): void; + protected shapeChanged(prev: CheckboxShape | undefined, next: CheckboxShape | undefined): void; size?: CheckboxSize; - sizeChanged(prev: CheckboxSize | undefined, next: CheckboxSize | undefined): void; + protected sizeChanged(prev: CheckboxSize | undefined, next: CheckboxSize | undefined): void; toggleChecked(force?: boolean): void; } @@ -2619,22 +2617,18 @@ export type ProgressBarValidationState = ValuesOf, message?: string, anchor?: HTMLElement): void; // @internal + slotchangeHandler(e: Event): void; + // @internal get validationMessage(): string; get validity(): ValidityState; get value(): string | null; diff --git a/packages/web-components/src/checkbox/checkbox.template.ts b/packages/web-components/src/checkbox/checkbox.template.ts index aa26500db5a854..d6105ac3d8adcd 100644 --- a/packages/web-components/src/checkbox/checkbox.template.ts +++ b/packages/web-components/src/checkbox/checkbox.template.ts @@ -30,7 +30,7 @@ export function checkboxTemplate(options: CheckboxOptions =