From 6450c0bee6a0229eb982b7f1b22c0f4b28f20825 Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Tue, 15 Mar 2022 17:42:59 -0400 Subject: [PATCH] add radio button; refactor radio group --- docs/_sidebar.md | 1 + docs/components/radio-button.md | 381 ++++++++++++++++++ docs/components/radio-group.md | 36 +- docs/components/radio.md | 40 +- docs/resources/changelog.md | 4 +- src/components/button-group/button-group.ts | 4 +- src/components/button/button.styles.ts | 22 +- src/components/button/button.test.ts | 2 - src/components/button/button.ts | 4 +- .../radio-button/radio-button.styles.ts | 10 + .../radio-button/radio-button.test.ts | 99 +++++ src/components/radio-button/radio-button.ts | 100 +++++ src/components/radio-group/radio-group.ts | 38 +- src/components/radio/radio.test.ts | 31 +- src/components/radio/radio.ts | 113 +----- src/internal/radio.ts | 115 ++++++ src/shoelace.ts | 1 + 17 files changed, 846 insertions(+), 155 deletions(-) create mode 100644 docs/components/radio-button.md create mode 100644 src/components/radio-button/radio-button.styles.ts create mode 100644 src/components/radio-button/radio-button.test.ts create mode 100644 src/components/radio-button/radio-button.ts create mode 100644 src/internal/radio.ts diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 4cf6a8351..9e10d813e 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -49,6 +49,7 @@ - [Progress Ring](/components/progress-ring) - [QR Code](/components/qr-code) - [Radio](/components/radio) + - [Radio Button](/components/radio-button) - [Radio Group](/components/radio-group) - [Range](/components/range) - [Rating](/components/rating) diff --git a/docs/components/radio-button.md b/docs/components/radio-button.md new file mode 100644 index 000000000..4c8f43847 --- /dev/null +++ b/docs/components/radio-button.md @@ -0,0 +1,381 @@ +# Radio Button + +[component-header:sl-radio-button] + +Radios buttons allow the user to select a single option from a group using a button-like control. + +Radio buttons are designed to be used with [radio groups](/components/radio-group). When a radio button has focus, the arrow keys can be used to change the selected option just like standard radio controls. + +```html preview + + Option 1 + Option 2 + Option 3 + +``` + +```jsx react +import { SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + + + Option 1 + + + Option 2 + + + Option 3 + + +); +``` + +## Examples + +### Checked + +To set the initial checked state, use the `checked` attribute. + +```html preview + + Option 1 + Option 2 + Option 3 + +``` + +```jsx react +import { SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + + + Option 1 + + + Option 2 + + + Option 3 + + +); +``` + +### Disabled + +Use the `disabled` attribute to disable a radio button. + +```html preview + + Option 1 + Option 2 + Option 3 + +``` + +```jsx react +import { SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + + + Option 1 + + + Option 2 + + + Option 3 + + +); +``` + +### Variants + +Use the `variant` attribute to set the button's variant. + +```html preview + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + +``` + +```jsx react +import { SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + +); +``` + +### Sizes + +Use the `size` attribute to change a radio button's size. + +```html preview + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + +``` + +```jsx react +import { SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + +); +``` + +### Pill Buttons + +Use the `pill` attribute to give radio buttons rounded edges. + +```html preview + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + +``` + +```jsx react +import { SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + + +
+ + + Option 1 + Option 2 + Option 3 + +); +``` + +### Prefix and Suffix Icons + +Use the `prefix` and `suffix` slots to add icons. + +```html preview + + + + Option 1 + + + + + Option 2 + + + + + + Option 3 + + +``` + +```jsx react +import { SlIcon, SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + + + + Option 1 + + + + + Option 2 + + + + + + Option 3 + + +); +``` + +### Buttons with Icons + +You can omit button labels and use icons instead. Make sure to set a `label` attribute on each icon so screen readers will announce each option correctly. + +```html preview + + + + + + + + + + + + + +``` + +[component-metadata:sl-radio-button] diff --git a/docs/components/radio-group.md b/docs/components/radio-group.md index c67cb9db6..d5a28963d 100644 --- a/docs/components/radio-group.md +++ b/docs/components/radio-group.md @@ -2,7 +2,7 @@ [component-header:sl-radio-group] -Radio Groups are used to group multiple radios so they function as a single control. +Radio groups are used to group multiple [radios](/components/radio) or [radio buttons](/components/radio-button) so they function as a single form control. ```html preview @@ -32,9 +32,9 @@ const App = () => ( ## Examples -### Showing the Fieldset +### Showing the Label -You can show a fieldset and legend that wraps the radio group using the `fieldset` attribute. +You can show the fieldset and legend that wraps the radio group using the `fieldset` attribute. If you don't use this option, you should still provide a label so screen readers announce the control correctly. ```html preview @@ -62,4 +62,34 @@ const App = () => ( ); ``` +### Radio Buttons + +[Radio buttons](/components/radio-button) offer an alternate way to display radio controls. In this case, an internal [button group](/components/button-group) is used to group the buttons into a single, cohesive control. + +```html preview + + Option 1 + Option 2 + Option 3 + +``` + +```jsx react +import { SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + + + Option 1 + + + Option 2 + + + Option 3 + + +); +``` + [component-metadata:sl-radio-group] diff --git a/docs/components/radio.md b/docs/components/radio.md index 025b467b7..4c271e972 100644 --- a/docs/components/radio.md +++ b/docs/components/radio.md @@ -2,9 +2,9 @@ [component-header:sl-radio] -Radios allow the user to select one option from a group of many. +Radios allow the user to select a single option from a group. -Radios are designed to be used with [radio groups](/components/radio-group). As such, all of the examples on this page utilize them to demonstrate their correct usage. +Radios are designed to be used with [radio groups](/components/radio-group). ```html preview @@ -36,16 +36,15 @@ const App = () => ( ## Examples -### Disabled +### Checked -Use the `disabled` attribute to disable a radio. +To set the initial checked state, use the `checked` attribute. ```html preview Option 1 Option 2 Option 3 - Disabled ``` @@ -63,8 +62,35 @@ const App = () => ( Option 3 - - Disabled + +); +``` + +### Disabled + +Use the `disabled` attribute to disable a radio. + +```html preview + + Option 1 + Option 2 + Option 3 + +``` + +```jsx react +import { SlRadio, SlRadioGroup } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + + + Option 1 + + + Option 2 + + + Option 3 ); diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index 88e1a722a..ee4558bfa 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -8,6 +8,8 @@ _During the beta period, these restrictions may be relaxed in the event of a mis ## Next +- Added the experimental `` component +- Added `button-group` and `button-group__base` parts to `` - Fixed a bug that prevented form submission from working as expected in some cases - Fixed a bug that prevented `` from toggling `vertical` properly [#703](https://github.com/shoelace-style/shoelace/issues/703) - Fixed a bug that prevented `` from rendering a color initially [#704](https://github.com/shoelace-style/shoelace/issues/704) @@ -125,7 +127,7 @@ _During the beta period, these restrictions may be relaxed in the event of a mis - 🚨 BREAKING: changed the `type` attribute to `variant` in ``, ``, ``, and `` since it's more appropriate and to disambiguate from other `type` attributes - 🚨 BREAKING: removed `base` part from `` to simplify the styling API -- Added experimental `` component +- Added the experimental `` component - Added `focus()` and `blur()` methods to `` [#625](https://github.com/shoelace-style/shoelace/pull/625) - Fixed a bug where setting `tooltipFormatter` on `` in JSX causes React@experimental to error out - Fixed a bug where clicking on a slotted icon in `` wouldn't submit forms [#626](https://github.com/shoelace-style/shoelace/issues/626) diff --git a/src/components/button-group/button-group.ts b/src/components/button-group/button-group.ts index 610557657..cba1347c2 100644 --- a/src/components/button-group/button-group.ts +++ b/src/components/button-group/button-group.ts @@ -2,6 +2,8 @@ import { LitElement, html } from 'lit'; import { customElement, property, query } from 'lit/decorators.js'; import styles from './button-group.styles'; +const BUTTON_CHILDREN = ['sl-button', 'sl-radio-button']; + /** * @since 2.0 * @status stable @@ -75,7 +77,7 @@ export default class SlButtonGroup extends LitElement { } function findButton(el: HTMLElement) { - return el.tagName.toLowerCase() === 'sl-button' ? el : el.querySelector('sl-button'); + return BUTTON_CHILDREN.includes(el.tagName.toLowerCase()) ? el : el.querySelector(BUTTON_CHILDREN.join(',')); } declare global { diff --git a/src/components/button/button.styles.ts b/src/components/button/button.styles.ts index 283de7842..aa7c46875 100644 --- a/src/components/button/button.styles.ts +++ b/src/components/button/button.styles.ts @@ -26,8 +26,8 @@ export default css` white-space: nowrap; vertical-align: middle; padding: 0; - transition: var(--sl-transition-fast) background-color, var(--sl-transition-fast) color, - var(--sl-transition-fast) border, var(--sl-transition-fast) box-shadow; + transition: var(--sl-transition-x-fast) background-color, var(--sl-transition-x-fast) color, + var(--sl-transition-x-fast) border, var(--sl-transition-x-fast) box-shadow; cursor: inherit; } @@ -235,7 +235,8 @@ export default css` color: var(--sl-color-neutral-700); } - .button--outline.button--default:hover:not(.button--disabled) { + .button--outline.button--default:hover:not(.button--disabled), + .button--outline.button--default.button--checked:not(.button--disabled) { border-color: var(--sl-color-primary-600); background-color: var(--sl-color-primary-600); color: var(--sl-color-neutral-0); @@ -258,7 +259,8 @@ export default css` color: var(--sl-color-primary-600); } - .button--outline.button--primary:hover:not(.button--disabled) { + .button--outline.button--primary:hover:not(.button--disabled), + .button--outline.button--primary.button--checked:not(.button--disabled) { background-color: var(--sl-color-primary-600); color: var(--sl-color-neutral-0); } @@ -280,7 +282,8 @@ export default css` color: var(--sl-color-success-600); } - .button--outline.button--success:hover:not(.button--disabled) { + .button--outline.button--success:hover:not(.button--disabled), + .button--outline.button--success.button--checked:not(.button--disabled) { background-color: var(--sl-color-success-600); color: var(--sl-color-neutral-0); } @@ -302,7 +305,8 @@ export default css` color: var(--sl-color-neutral-600); } - .button--outline.button--neutral:hover:not(.button--disabled) { + .button--outline.button--neutral:hover:not(.button--disabled), + .button--outline.button--neutral.button--checked:not(.button--disabled) { background-color: var(--sl-color-neutral-600); color: var(--sl-color-neutral-0); } @@ -324,7 +328,8 @@ export default css` color: var(--sl-color-warning-600); } - .button--outline.button--warning:hover:not(.button--disabled) { + .button--outline.button--warning:hover:not(.button--disabled), + .button--outline.button--warning.button--checked:not(.button--disabled) { background-color: var(--sl-color-warning-600); color: var(--sl-color-neutral-0); } @@ -346,7 +351,8 @@ export default css` color: var(--sl-color-danger-600); } - .button--outline.button--danger:hover:not(.button--disabled) { + .button--outline.button--danger:hover:not(.button--disabled), + .button--outline.button--danger.button--checked:not(.button--disabled) { background-color: var(--sl-color-danger-600); color: var(--sl-color-neutral-0); } diff --git a/src/components/button/button.test.ts b/src/components/button/button.test.ts index 100d74f95..1846f5420 100644 --- a/src/components/button/button.test.ts +++ b/src/components/button/button.test.ts @@ -126,8 +126,6 @@ describe('', () => { const button = el.querySelector('sl-button')!; const handleSubmit = sinon.spy((event: SubmitEvent) => event.preventDefault()); - console.log(form, button); - form.addEventListener('submit', handleSubmit); button.click(); diff --git a/src/components/button/button.ts b/src/components/button/button.ts index 52e3f73ea..d98ce3dcd 100644 --- a/src/components/button/button.ts +++ b/src/components/button/button.ts @@ -23,9 +23,9 @@ import styles from './button.styles'; * @slot suffix - Used to append an icon or similar element to the button. * * @csspart base - The component's internal wrapper. - * @csspart prefix - The prefix container. + * @csspart prefix - The prefix slot's container. * @csspart label - The button's label. - * @csspart suffix - The suffix container. + * @csspart suffix - The suffix slot's container. * @csspart caret - The button's caret. */ @customElement('sl-button') diff --git a/src/components/radio-button/radio-button.styles.ts b/src/components/radio-button/radio-button.styles.ts new file mode 100644 index 000000000..fd001391b --- /dev/null +++ b/src/components/radio-button/radio-button.styles.ts @@ -0,0 +1,10 @@ +import { css } from 'lit'; +import componentStyles from '~/styles/component.styles'; + +export default css` + ${componentStyles} + + :host { + display: block; + } +`; diff --git a/src/components/radio-button/radio-button.test.ts b/src/components/radio-button/radio-button.test.ts new file mode 100644 index 000000000..5c580aeb3 --- /dev/null +++ b/src/components/radio-button/radio-button.test.ts @@ -0,0 +1,99 @@ +import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; +import sinon from 'sinon'; +import type SlRadioGroup from '~/components/radio-group/radio-group'; +import type SlRadioButton from './radio-button'; + +describe('', () => { + it('should be disabled with the disabled attribute', async () => { + const el = await fixture(html` `); + + expect(el.input.disabled).to.be.true; + }); + + it('should be valid by default', async () => { + const el = await fixture(html` `); + + expect(el.invalid).to.be.false; + }); + + it('should fire sl-change when clicked', async () => { + const el = await fixture(html` `); + setTimeout(() => el.input.click()); + const event = await oneEvent(el, 'sl-change'); + expect(event.target).to.equal(el); + expect(el.checked).to.be.true; + }); + + it('should fire sl-change when toggled via keyboard - space', async () => { + const el = await fixture(html` `); + el.input.focus(); + setTimeout(() => sendKeys({ press: ' ' })); + const event = await oneEvent(el, 'sl-change'); + expect(event.target).to.equal(el); + expect(el.checked).to.be.true; + }); + + it('should fire sl-change when toggled via keyboard - arrow key', async () => { + const radioGroup = await fixture(html` + + + + + `); + const radio1 = radioGroup.querySelector('#radio-1')!; + const radio2 = radioGroup.querySelector('#radio-2')!; + radio1.input.focus(); + setTimeout(() => sendKeys({ press: 'ArrowRight' })); + const event = await oneEvent(radio2, 'sl-change'); + expect(event.target).to.equal(radio2); + expect(radio2.checked).to.be.true; + }); + + it('should not get checked when disabled', async () => { + const radioGroup = await fixture(html` + + + + + `); + const radio1 = radioGroup.querySelector('sl-radio-button[checked]')!; + const radio2 = radioGroup.querySelector('sl-radio-button[disabled]')!; + + radio2.click(); + await Promise.all([radio1.updateComplete, radio2.updateComplete]); + + expect(radio1.checked).to.be.true; + expect(radio2.checked).to.be.false; + }); + + describe('when submitting a form', () => { + it('should submit the correct value', async () => { + const form = await fixture(html` +
+ + + + + + Submit +
+ `); + const button = form.querySelector('sl-button')!; + const radio = form.querySelectorAll('sl-radio-button')[1]!; + const submitHandler = sinon.spy((event: SubmitEvent) => { + formData = new FormData(form); + event.preventDefault(); + }); + let formData: FormData; + + form.addEventListener('submit', submitHandler); + radio.click(); + button.click(); + + await waitUntil(() => submitHandler.calledOnce); + + expect(formData!.get('a')).to.equal('2'); + }); + }); +}); diff --git a/src/components/radio-button/radio-button.ts b/src/components/radio-button/radio-button.ts new file mode 100644 index 000000000..9dcf33456 --- /dev/null +++ b/src/components/radio-button/radio-button.ts @@ -0,0 +1,100 @@ +import { customElement, property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { html } from 'lit/static-html.js'; +import styles from '~/components/button/button.styles'; +import RadioBase from '~/internal/radio'; +import { HasSlotController } from '~/internal/slot'; + +/** + * @since 2.0 + * @status stable + * + * @event sl-blur - Emitted when the button loses focus. + * @event sl-focus - Emitted when the button gains focus. + * + * @slot - The button's label. + * @slot prefix - Used to prepend an icon or similar element to the button. + * @slot suffix - Used to append an icon or similar element to the button. + * + * @csspart base - The component's internal wrapper. + * @csspart prefix - The prefix slot's container. + * @csspart label - The button's label. + * @csspart suffix - The suffix slot's container. + */ +@customElement('sl-radio-button') +export default class SlRadioButton extends RadioBase { + static styles = styles; + + private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix'); + + /** The button's variant. */ + @property({ reflect: true }) variant: 'default' | 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = + 'default'; + + /** The button's size. */ + @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; + + /** + * This will be true when the control is in an invalid state. Validity in range inputs is determined by the message + * provided by the `setCustomValidity` method. + */ + @property({ type: Boolean, reflect: true }) invalid = false; + + /** Draws a pill-style button with rounded edges. */ + @property({ type: Boolean, reflect: true }) pill = false; + + render() { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-radio-button': SlRadioButton; + } +} diff --git a/src/components/radio-group/radio-group.ts b/src/components/radio-group/radio-group.ts index 021be275a..7431f24dc 100644 --- a/src/components/radio-group/radio-group.ts +++ b/src/components/radio-group/radio-group.ts @@ -1,19 +1,25 @@ import { html, LitElement } from 'lit'; -import { customElement, property, query } from 'lit/decorators.js'; +import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; +import '~/components/button-group/button-group'; import type SlRadio from '~/components/radio/radio'; -import { emit } from '~/internal/event'; import styles from './radio-group.styles'; +const RADIO_CHILDREN = ['sl-radio', 'sl-radio-button']; + /** * @since 2.0 * @status stable * + * @dependency sl-button-group + * * @slot - The default slot where radio controls are placed. * @slot label - The radio group label. Required for proper accessibility. Alternatively, you can use the label prop. * * @csspart base - The component's internal wrapper. - * @csspart label - The radio group label. + * @csspart label - The radio group's label. + * @csspart button-group - The button group that wraps radio buttons. + * @csspart button-group__base - The button group's `base` part. */ @customElement('sl-radio-group') export default class SlRadioGroup extends LitElement { @@ -21,6 +27,8 @@ export default class SlRadioGroup extends LitElement { @query('slot:not([name])') defaultSlot: HTMLSlotElement; + @state() hasButtonGroup = false; + /** The radio group label. Required for proper accessibility. Alternatively, you can use the label slot. */ @property() label = ''; @@ -33,14 +41,14 @@ export default class SlRadioGroup extends LitElement { } getAllRadios() { - return this.defaultSlot - .assignedElements({ flatten: true }) - .filter(el => el.tagName.toLowerCase() === 'sl-radio') as SlRadio[]; + return [...this.querySelectorAll(RADIO_CHILDREN.join(','))].filter(el => + RADIO_CHILDREN.includes(el.tagName.toLowerCase()) + ) as SlRadio[]; } handleRadioClick(event: MouseEvent) { const target = event.target as HTMLElement; - const checkedRadio = target.closest('sl-radio'); + const checkedRadio = target.closest(RADIO_CHILDREN.map(selector => `${selector}:not([disabled])`).join(',')); if (checkedRadio) { const radios = this.getAllRadios(); @@ -73,8 +81,6 @@ export default class SlRadioGroup extends LitElement { radios[index].checked = true; radios[index].input.tabIndex = 0; - emit(radios[index], 'sl-change'); - event.preventDefault(); } } @@ -83,6 +89,8 @@ export default class SlRadioGroup extends LitElement { const radios = this.getAllRadios(); const checkedRadio = radios.find(radio => radio.checked); + this.hasButtonGroup = !!radios.find(radio => radio.tagName.toLowerCase() === 'sl-radio-button'); + radios.forEach(radio => { radio.setAttribute('role', 'radio'); radio.input.tabIndex = -1; @@ -96,6 +104,10 @@ export default class SlRadioGroup extends LitElement { } render() { + const defaultSlot = html` + + `; + return html`
${this.label} - + ${this.hasButtonGroup + ? html`${defaultSlot}` + : defaultSlot}
`; } diff --git a/src/components/radio/radio.test.ts b/src/components/radio/radio.test.ts index e05f4a364..12c86e634 100644 --- a/src/components/radio/radio.test.ts +++ b/src/components/radio/radio.test.ts @@ -7,7 +7,7 @@ import type SlRadio from './radio'; describe('', () => { it('should be disabled with the disabled attribute', async () => { const el = await fixture(html` `); - const radio = el.shadowRoot!.querySelector('input')!; + const radio = el.input; expect(radio.disabled).to.be.true; }); @@ -20,7 +20,7 @@ describe('', () => { it('should fire sl-change when clicked', async () => { const el = await fixture(html` `); - setTimeout(() => el.shadowRoot!.querySelector('input')!.click()); + setTimeout(() => el.input.click()); const event = await oneEvent(el, 'sl-change'); expect(event.target).to.equal(el); expect(el.checked).to.be.true; @@ -28,8 +28,7 @@ describe('', () => { it('should fire sl-change when toggled via keyboard - space', async () => { const el = await fixture(html` `); - const input = el.shadowRoot!.querySelector('input')!; - input.focus(); + el.input.focus(); setTimeout(() => sendKeys({ press: ' ' })); const event = await oneEvent(el, 'sl-change'); expect(event.target).to.equal(el); @@ -43,16 +42,32 @@ describe('', () => {
`); - const radio1 = radioGroup.querySelector('sl-radio#radio-1')!; - const radio2 = radioGroup.querySelector('sl-radio#radio-2')!; - const input1 = radio1.shadowRoot!.querySelector('input')!; - input1.focus(); + const radio1 = radioGroup.querySelector('#radio-1')!; + const radio2 = radioGroup.querySelector('#radio-2')!; + radio1.input.focus(); setTimeout(() => sendKeys({ press: 'ArrowRight' })); const event = await oneEvent(radio2, 'sl-change'); expect(event.target).to.equal(radio2); expect(radio2.checked).to.be.true; }); + it('should not get checked when disabled', async () => { + const radioGroup = await fixture(html` + + + + + `); + const radio1 = radioGroup.querySelector('sl-radio[checked]')!; + const radio2 = radioGroup.querySelector('sl-radio[disabled]')!; + + radio2.click(); + await Promise.all([radio1.updateComplete, radio2.updateComplete]); + + expect(radio1.checked).to.be.true; + expect(radio2.checked).to.be.false; + }); + describe('when submitting a form', () => { it('should submit the correct value', async () => { const form = await fixture(html` diff --git a/src/components/radio/radio.ts b/src/components/radio/radio.ts index ac0d952bb..6387da42e 100644 --- a/src/components/radio/radio.ts +++ b/src/components/radio/radio.ts @@ -1,11 +1,9 @@ -import { html, LitElement } from 'lit'; -import { customElement, property, query, state } from 'lit/decorators.js'; +import { html } from 'lit'; +import { customElement } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; -import { emit } from '~/internal/event'; -import { FormSubmitController } from '~/internal/form-control'; -import { watch } from '~/internal/watch'; +import RadioBase from '~/internal/radio'; import styles from './radio.styles'; /** @@ -24,112 +22,9 @@ import styles from './radio.styles'; * @csspart label - The radio label. */ @customElement('sl-radio') -export default class SlRadio extends LitElement { +export default class SlRadio extends RadioBase { static styles = styles; - @query('input[type="radio"]') input: HTMLInputElement; - - // @ts-expect-error -- Controller is currently unused - private readonly formSubmitController = new FormSubmitController(this, { - value: (control: SlRadio) => (control.checked ? control.value : undefined) - }); - - @state() private hasFocus = false; - - /** The radio's name attribute. */ - @property() name: string; - - /** The radio's value attribute. */ - @property() value: string; - - /** Disables the radio. */ - @property({ type: Boolean, reflect: true }) disabled = false; - - /** Draws the radio in a checked state. */ - @property({ type: Boolean, reflect: true }) checked = false; - - /** - * This will be true when the control is in an invalid state. Validity in range inputs is determined by the message - * provided by the `setCustomValidity` method. - */ - @property({ type: Boolean, reflect: true }) invalid = false; - - connectedCallback(): void { - super.connectedCallback(); - this.setAttribute('role', 'radio'); - } - - /** Simulates a click on the radio. */ - click() { - this.input.click(); - } - - /** Sets focus on the radio. */ - focus(options?: FocusOptions) { - this.input.focus(options); - } - - /** Removes focus from the radio. */ - blur() { - this.input.blur(); - } - - /** Checks for validity and shows the browser's validation message if the control is invalid. */ - reportValidity() { - return this.input.reportValidity(); - } - - /** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */ - setCustomValidity(message: string) { - this.input.setCustomValidity(message); - this.invalid = !this.input.checkValidity(); - } - - getAllRadios() { - const radioGroup = this.closest('sl-radio-group'); - - // Radios must be part of a radio group - if (radioGroup === null) { - return [this]; - } - - return [...radioGroup.querySelectorAll('sl-radio')].filter((radio: this) => radio.name === this.name); - } - - handleBlur() { - this.hasFocus = false; - emit(this, 'sl-blur'); - } - - @watch('checked') - handleCheckedChange() { - this.setAttribute('aria-checked', this.checked ? 'true' : 'false'); - - if (this.hasUpdated) { - emit(this, 'sl-change'); - } - } - - handleClick() { - this.checked = true; - } - - @watch('disabled', { waitUntilFirstUpdate: true }) - handleDisabledChange() { - this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false'); - - // Disabled form controls are always valid, so we need to recheck validity when the state changes - if (this.hasUpdated) { - this.input.disabled = this.disabled; - this.invalid = !this.input.checkValidity(); - } - } - - handleFocus() { - this.hasFocus = true; - emit(this, 'sl-focus'); - } - render() { return html`