add radio button; refactor radio group

This commit is contained in:
Cory LaViska
2022-03-15 17:42:59 -04:00
parent 87d1db760f
commit 6450c0bee6
17 changed files with 846 additions and 155 deletions

View File

@@ -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)

View File

@@ -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
<sl-radio-group label="Select an option">
<sl-radio-button name="a" value="1" checked>Option 1</sl-radio-button>
<sl-radio-button name="a" value="2">Option 2</sl-radio-button>
<sl-radio-button name="a" value="3">Option 3</sl-radio-button>
</sl-radio-group>
```
```jsx react
import { SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlRadioGroup label="Select an option">
<SlRadioButton name="option" value="1" checked>
Option 1
</SlRadioButton>
<SlRadioButton name="option" value="2">
Option 2
</SlRadioButton>
<SlRadioButton name="option" value="3">
Option 3
</SlRadioButton>
</SlRadioGroup>
);
```
## Examples
### Checked
To set the initial checked state, use the `checked` attribute.
```html preview
<sl-radio-group label="Select an option">
<sl-radio-button name="option" value="1" checked>Option 1</sl-radio-button>
<sl-radio-button name="option" value="2">Option 2</sl-radio-button>
<sl-radio-button name="option" value="3">Option 3</sl-radio-button>
</sl-radio-group>
```
```jsx react
import { SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlRadioGroup label="Select an option">
<SlRadioButton name="option" value="1" checked>
Option 1
</SlRadioButton>
<SlRadioButton name="option" value="2">
Option 2
</SlRadioButton>
<SlRadioButton name="option" value="3">
Option 3
</SlRadioButton>
</SlRadioGroup>
);
```
### Disabled
Use the `disabled` attribute to disable a radio button.
```html preview
<sl-radio-group label="Select an option">
<sl-radio-button name="option" value="1" checked>Option 1</sl-radio-button>
<sl-radio-button name="option" value="2">Option 2</sl-radio-button>
<sl-radio-button name="option" value="3" disabled>Option 3</sl-radio-button>
</sl-radio-group>
```
```jsx react
import { SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlRadioGroup label="Select an option">
<SlRadioButton name="option" value="1" checked>
Option 1
</SlRadioButton>
<SlRadioButton name="option" value="2">
Option 2
</SlRadioButton>
<SlRadioButton name="option" value="3" disabled>
Option 3
</SlRadioButton>
</SlRadioGroup>
);
```
### Variants
Use the `variant` attribute to set the button's variant.
```html preview
<sl-radio-group label="Select an option">
<sl-radio-button variant="default" name="option" value="1" checked>Option 1</sl-radio-button>
<sl-radio-button variant="default" name="option" value="2">Option 2</sl-radio-button>
<sl-radio-button variant="default" name="option" value="3">Option 3</sl-radio-button>
</sl-radio-group>
<br />
<sl-radio-group label="Select an option">
<sl-radio-button variant="primary" name="option" value="1" checked>Option 1</sl-radio-button>
<sl-radio-button variant="primary" name="option" value="2">Option 2</sl-radio-button>
<sl-radio-button variant="primary" name="option" value="3">Option 3</sl-radio-button>
</sl-radio-group>
<br />
<sl-radio-group label="Select an option">
<sl-radio-button variant="success" name="option" value="1" checked>Option 1</sl-radio-button>
<sl-radio-button variant="success" name="option" value="2">Option 2</sl-radio-button>
<sl-radio-button variant="success" name="option" value="3">Option 3</sl-radio-button>
</sl-radio-group>
<br />
<sl-radio-group label="Select an option">
<sl-radio-button variant="neutral" name="option" value="1" checked>Option 1</sl-radio-button>
<sl-radio-button variant="neutral" name="option" value="2">Option 2</sl-radio-button>
<sl-radio-button variant="neutral" name="option" value="3">Option 3</sl-radio-button>
</sl-radio-group>
<br />
<sl-radio-group label="Select an option">
<sl-radio-button variant="warning" name="option" value="1" checked>Option 1</sl-radio-button>
<sl-radio-button variant="warning" name="option" value="2">Option 2</sl-radio-button>
<sl-radio-button variant="warning" name="option" value="3">Option 3</sl-radio-button>
</sl-radio-group>
<br />
<sl-radio-group label="Select an option">
<sl-radio-button variant="success" name="option" value="1" checked>Option 1</sl-radio-button>
<sl-radio-button variant="success" name="option" value="2">Option 2</sl-radio-button>
<sl-radio-button variant="success" name="option" value="3">Option 3</sl-radio-button>
</sl-radio-group>
```
```jsx react
import { SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlRadioGroup label="Select an option">
<SlRadioButton variant="default" name="option" value="1" checked>Option 1</SlRadioButton>
<SlRadioButton variant="default" name="option" value="2">Option 2</SlRadioButton>
<SlRadioButton variant="default" name="option" value="3">Option 3</SlRadioButton>
</SlRadioGroup>
<br />
<SlRadioGroup label="Select an option">
<SlRadioButton variant="primary" name="option" value="1" checked>Option 1</SlRadioButton>
<SlRadioButton variant="primary" name="option" value="2">Option 2</SlRadioButton>
<SlRadioButton variant="primary" name="option" value="3">Option 3</SlRadioButton>
</SlRadioGroup>
<br />
<SlRadioGroup label="Select an option">
<SlRadioButton variant="success" name="option" value="1" checked>Option 1</SlRadioButton>
<SlRadioButton variant="success" name="option" value="2">Option 2</SlRadioButton>
<SlRadioButton variant="success" name="option" value="3">Option 3</SlRadioButton>
</SlRadioGroup>
<br />
<SlRadioGroup label="Select an option">
<SlRadioButton variant="neutral" name="option" value="1" checked>Option 1</SlRadioButton>
<SlRadioButton variant="neutral" name="option" value="2">Option 2</SlRadioButton>
<SlRadioButton variant="neutral" name="option" value="3">Option 3</SlRadioButton>
</SlRadioGroup>
<br />
<SlRadioGroup label="Select an option">
<SlRadioButton variant="warning" name="option" value="1" checked>Option 1</SlRadioButton>
<SlRadioButton variant="warning" name="option" value="2">Option 2</SlRadioButton>
<SlRadioButton variant="warning" name="option" value="3">Option 3</SlRadioButton>
</SlRadioGroup>
<br />
<SlRadioGroup label="Select an option">
<SlRadioButton variant="success" name="option" value="1" checked>Option 1</SlRadioButton>
<SlRadioButton variant="success" name="option" value="2">Option 2</SlRadioButton>
<SlRadioButton variant="success" name="option" value="3">Option 3</SlRadioButton>
</SlRadioGroup>
);
```
### Sizes
Use the `size` attribute to change a radio button's size.
```html preview
<sl-radio-group label="Select an option">
<sl-radio-button size="small" name="option" value="1" checked>Option 1</sl-radio-button>
<sl-radio-button size="small" name="option" value="2">Option 2</sl-radio-button>
<sl-radio-button size="small" name="option" value="3">Option 3</sl-radio-button>
</sl-radio-group>
<br />
<sl-radio-group label="Select an option">
<sl-radio-button size="medium" name="option" value="1" checked>Option 1</sl-radio-button>
<sl-radio-button size="medium" name="option" value="2">Option 2</sl-radio-button>
<sl-radio-button size="medium" name="option" value="3">Option 3</sl-radio-button>
</sl-radio-group>
<br />
<sl-radio-group label="Select an option">
<sl-radio-button size="large" name="option" value="1" checked>Option 1</sl-radio-button>
<sl-radio-button size="large" name="option" value="2">Option 2</sl-radio-button>
<sl-radio-button size="large" name="option" value="3">Option 3</sl-radio-button>
</sl-radio-group>
```
```jsx react
import { SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlRadioGroup label="Select an option">
<SlRadioButton size="small" name="option" value="1" checked>Option 1</SlRadioButton>
<SlRadioButton size="small" name="option" value="2">Option 2</SlRadioButton>
<SlRadioButton size="small" name="option" value="3">Option 3</SlRadioButton>
</SlRadioGroup>
<br />
<SlRadioGroup label="Select an option">
<SlRadioButton size="medium" name="option" value="1" checked>Option 1</SlRadioButton>
<SlRadioButton size="medium" name="option" value="2">Option 2</SlRadioButton>
<SlRadioButton size="medium" name="option" value="3">Option 3</SlRadioButton>
</SlRadioGroup>
<br />
<SlRadioGroup label="Select an option">
<SlRadioButton size="large" name="option" value="1" checked>Option 1</SlRadioButton>
<SlRadioButton size="large" name="option" value="2">Option 2</SlRadioButton>
<SlRadioButton size="large" name="option" value="3">Option 3</SlRadioButton>
</SlRadioGroup>
);
```
### Pill Buttons
Use the `pill` attribute to give radio buttons rounded edges.
```html preview
<sl-radio-group label="Select an option">
<sl-radio-button pill size="small" name="option" value="1" checked>Option 1</sl-radio-button>
<sl-radio-button pill size="small" name="option" value="2">Option 2</sl-radio-button>
<sl-radio-button pill size="small" name="option" value="3">Option 3</sl-radio-button>
</sl-radio-group>
<br />
<sl-radio-group label="Select an option">
<sl-radio-button pill size="medium" name="option" value="1" checked>Option 1</sl-radio-button>
<sl-radio-button pill size="medium" name="option" value="2">Option 2</sl-radio-button>
<sl-radio-button pill size="medium" name="option" value="3">Option 3</sl-radio-button>
</sl-radio-group>
<br />
<sl-radio-group label="Select an option">
<sl-radio-button pill size="large" name="option" value="1" checked>Option 1</sl-radio-button>
<sl-radio-button pill size="large" name="option" value="2">Option 2</sl-radio-button>
<sl-radio-button pill size="large" name="option" value="3">Option 3</sl-radio-button>
</sl-radio-group>
```
```jsx react
import { SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlRadioGroup label="Select an option">
<SlRadioButton pill size="small" name="option" value="1" checked>Option 1</SlRadioButton>
<SlRadioButton pill size="small" name="option" value="2">Option 2</SlRadioButton>
<SlRadioButton pill size="small" name="option" value="3">Option 3</SlRadioButton>
</SlRadioGroup>
<br />
<SlRadioGroup label="Select an option">
<SlRadioButton pill size="medium" name="option" value="1" checked>Option 1</SlRadioButton>
<SlRadioButton pill size="medium" name="option" value="2">Option 2</SlRadioButton>
<SlRadioButton pill size="medium" name="option" value="3">Option 3</SlRadioButton>
</SlRadioGroup>
<br />
<SlRadioGroup label="Select an option">
<SlRadioButton pill size="large" name="option" value="1" checked>Option 1</SlRadioButton>
<SlRadioButton pill size="large" name="option" value="2">Option 2</SlRadioButton>
<SlRadioButton pill size="large" name="option" value="3">Option 3</SlRadioButton>
</SlRadioGroup>
);
```
### Prefix and Suffix Icons
Use the `prefix` and `suffix` slots to add icons.
```html preview
<sl-radio-group label="Select an option">
<sl-radio-button name="a" value="1" checked>
<sl-icon slot="prefix" name="archive"></sl-icon>
Option 1
</sl-radio-button>
<sl-radio-button name="a" value="2">
<sl-icon slot="suffix" name="bag"></sl-icon>
Option 2
</sl-radio-button>
<sl-radio-button name="a" value="3">
<sl-icon slot="prefix" name="gift"></sl-icon>
<sl-icon slot="suffix" name="cart"></sl-icon>
Option 3
</sl-radio-button>
</sl-radio-group>
```
```jsx react
import { SlIcon, SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlRadioGroup label="Select an option">
<SlRadioButton name="a" value="1" checked>
<SlIcon slot="prefix" name="archive" />
Option 1
</SlRadioButton>
<SlRadioButton name="a" value="2">
<SlIcon slot="suffix" name="bag" />
Option 2
</SlRadioButton>
<SlRadioButton name="a" value="3">
<SlIcon slot="prefix" name="gift" />
<SlIcon slot="suffix" name="cart" />
Option 3
</SlRadioButton>
</SlRadioGroup>
);
```
### 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
<sl-radio-group label="Select an option">
<sl-radio-button name="a" value="1" checked>
<sl-icon name="emoji-frown" label="Sad"></sl-icon>
</sl-radio-button>
<sl-radio-button name="a" value="2">
<sl-icon name="emoji-neutral" label="Neutral"></sl-icon>
</sl-radio-button>
<sl-radio-button name="a" value="3">
<sl-icon name="emoji-smile" label="Happy"></sl-icon>
</sl-radio-button>
</sl-radio-group>
```
[component-metadata:sl-radio-button]

View File

@@ -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
<sl-radio-group label="Select an option">
@@ -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
<sl-radio-group label="Select an option" fieldset>
@@ -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
<sl-radio-group label="Select an option">
<sl-radio-button name="option" value="1" checked>Option 1</sl-radio-button>
<sl-radio-button name="option" value="2">Option 2</sl-radio-button>
<sl-radio-button name="option" value="3">Option 3</sl-radio-button>
</sl-radio-group>
```
```jsx react
import { SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlRadioGroup label="Select an option">
<SlRadioButton name="option" value="1" checked>
Option 1
</SlRadioButton>
<SlRadioButton name="option" value="2">
Option 2
</SlRadioButton>
<SlRadioButton name="option" value="3">
Option 3
</SlRadioButton>
</SlRadioGroup>
);
```
[component-metadata:sl-radio-group]

View File

@@ -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
<sl-radio-group label="Select an option">
@@ -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
<sl-radio-group label="Select an option">
<sl-radio name="option" value="1" checked>Option 1</sl-radio>
<sl-radio name="option" value="2">Option 2</sl-radio>
<sl-radio name="option" value="3">Option 3</sl-radio>
<sl-radio name="option" value="4" disabled>Disabled</sl-radio>
</sl-radio-group>
```
@@ -63,8 +62,35 @@ const App = () => (
<SlRadio name="option" value="3">
Option 3
</SlRadio>
<SlRadio name="option" value="4" disabled>
Disabled
</SlRadioGroup>
);
```
### Disabled
Use the `disabled` attribute to disable a radio.
```html preview
<sl-radio-group label="Select an option">
<sl-radio name="option" value="1" checked>Option 1</sl-radio>
<sl-radio name="option" value="2">Option 2</sl-radio>
<sl-radio name="option" value="3" disabled>Option 3</sl-radio>
</sl-radio-group>
```
```jsx react
import { SlRadio, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlRadioGroup label="Select an option">
<SlRadio name="option" value="1" checked>
Option 1
</SlRadio>
<SlRadio name="option" value="2">
Option 2
</SlRadio>
<SlRadio name="option" value="3" disabled>
Option 3
</SlRadio>
</SlRadioGroup>
);

View File

@@ -8,6 +8,8 @@ _During the beta period, these restrictions may be relaxed in the event of a mis
## Next
- Added the experimental `<sl-radio-button>` component
- Added `button-group` and `button-group__base` parts to `<sl-radio-group>`
- Fixed a bug that prevented form submission from working as expected in some cases
- Fixed a bug that prevented `<sl-split-panel>` from toggling `vertical` properly [#703](https://github.com/shoelace-style/shoelace/issues/703)
- Fixed a bug that prevented `<sl-color-picker>` 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 `<sl-alert>`, `<sl-badge>`, `<sl-button>`, and `<sl-tag>` since it's more appropriate and to disambiguate from other `type` attributes
- 🚨 BREAKING: removed `base` part from `<sl-divider>` to simplify the styling API
- Added experimental `<sl-split-panel>` component
- Added the experimental `<sl-split-panel>` component
- Added `focus()` and `blur()` methods to `<sl-select>` [#625](https://github.com/shoelace-style/shoelace/pull/625)
- Fixed a bug where setting `tooltipFormatter` on `<sl-range>` in JSX causes React@experimental to error out
- Fixed a bug where clicking on a slotted icon in `<sl-button>` wouldn't submit forms [#626](https://github.com/shoelace-style/shoelace/issues/626)

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -126,8 +126,6 @@ describe('<sl-button>', () => {
const button = el.querySelector<SlButton>('sl-button')!;
const handleSubmit = sinon.spy((event: SubmitEvent) => event.preventDefault());
console.log(form, button);
form.addEventListener('submit', handleSubmit);
button.click();

View File

@@ -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')

View File

@@ -0,0 +1,10 @@
import { css } from 'lit';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}
:host {
display: block;
}
`;

View File

@@ -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('<sl-radio-button>', () => {
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<SlRadioButton>(html` <sl-radio-button disabled></sl-radio-button> `);
expect(el.input.disabled).to.be.true;
});
it('should be valid by default', async () => {
const el = await fixture<SlRadioButton>(html` <sl-radio-button></sl-radio-button> `);
expect(el.invalid).to.be.false;
});
it('should fire sl-change when clicked', async () => {
const el = await fixture<SlRadioButton>(html` <sl-radio-button></sl-radio-button> `);
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<SlRadioButton>(html` <sl-radio-button></sl-radio-button> `);
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<SlRadioGroup>(html`
<sl-radio-group>
<sl-radio-button id="radio-1"></sl-radio-button>
<sl-radio-button id="radio-2"></sl-radio-button>
</sl-radio-group>
`);
const radio1 = radioGroup.querySelector<SlRadioButton>('#radio-1')!;
const radio2 = radioGroup.querySelector<SlRadioButton>('#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<SlRadioGroup>(html`
<sl-radio-group>
<sl-radio-button checked></sl-radio-button>
<sl-radio-button disabled></sl-radio-button>
</sl-radio-group>
`);
const radio1 = radioGroup.querySelector<SlRadioButton>('sl-radio-button[checked]')!;
const radio2 = radioGroup.querySelector<SlRadioButton>('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<HTMLFormElement>(html`
<form>
<sl-radio-group>
<sl-radio-button id="radio-1" name="a" value="1" checked></sl-radio-button>
<sl-radio-button id="radio-2" name="a" value="2"></sl-radio-button>
<sl-radio-button id="radio-2" name="a" value="3"></sl-radio-button>
</sl-radio-group>
<sl-button type="submit">Submit</sl-button>
</form>
`);
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');
});
});
});

View File

@@ -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`
<button
part="base"
class=${classMap({
button: true,
'button--default': this.variant === 'default',
'button--primary': this.variant === 'primary',
'button--success': this.variant === 'success',
'button--neutral': this.variant === 'neutral',
'button--warning': this.variant === 'warning',
'button--danger': this.variant === 'danger',
'button--small': this.size === 'small',
'button--medium': this.size === 'medium',
'button--large': this.size === 'large',
'button--checked': this.checked,
'button--disabled': this.disabled,
'button--focused': this.hasFocus,
'button--outline': true,
'button--pill': this.pill,
'button--has-label': this.hasSlotController.test('[default]'),
'button--has-prefix': this.hasSlotController.test('prefix'),
'button--has-suffix': this.hasSlotController.test('suffix')
})}
?disabled=${this.disabled}
type="button"
name=${ifDefined(this.name)}
value=${ifDefined(this.value)}
aria-selected=${this.checked ? 'true' : 'false'}
aria-disabled=${this.disabled ? 'true' : 'false'}
tabindex=${this.disabled ? '-1' : '0'}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@click=${this.handleClick}
>
<span part="prefix" class="button__prefix">
<slot name="prefix"></slot>
</span>
<span part="label" class="button__label">
<slot></slot>
</span>
<span part="suffix" class="button__suffix">
<slot name="suffix"></slot>
</span>
</button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-radio-button': SlRadioButton;
}
}

View File

@@ -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`
<slot @click=${this.handleRadioClick} @keydown=${this.handleKeyDown} @slotchange=${this.handleSlotChange}></slot>
`;
return html`
<fieldset
part="base"
@@ -107,11 +119,9 @@ export default class SlRadioGroup extends LitElement {
<legend part="label" class="radio-group__label">
<slot name="label">${this.label}</slot>
</legend>
<slot
@click=${this.handleRadioClick}
@keydown=${this.handleKeyDown}
@slotchange=${this.handleSlotChange}
></slot>
${this.hasButtonGroup
? html`<sl-button-group part="button-group">${defaultSlot}</sl-button-group>`
: defaultSlot}
</fieldset>
`;
}

View File

@@ -7,7 +7,7 @@ import type SlRadio from './radio';
describe('<sl-radio>', () => {
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<SlRadio>(html` <sl-radio disabled></sl-radio> `);
const radio = el.shadowRoot!.querySelector<HTMLInputElement>('input')!;
const radio = el.input;
expect(radio.disabled).to.be.true;
});
@@ -20,7 +20,7 @@ describe('<sl-radio>', () => {
it('should fire sl-change when clicked', async () => {
const el = await fixture<SlRadio>(html` <sl-radio></sl-radio> `);
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('<sl-radio>', () => {
it('should fire sl-change when toggled via keyboard - space', async () => {
const el = await fixture<SlRadio>(html` <sl-radio></sl-radio> `);
const input = el.shadowRoot!.querySelector<HTMLInputElement>('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('<sl-radio>', () => {
<sl-radio id="radio-2"></sl-radio>
</sl-radio-group>
`);
const radio1 = radioGroup.querySelector<SlRadio>('sl-radio#radio-1')!;
const radio2 = radioGroup.querySelector<SlRadio>('sl-radio#radio-2')!;
const input1 = radio1.shadowRoot!.querySelector<HTMLInputElement>('input')!;
input1.focus();
const radio1 = radioGroup.querySelector<SlRadio>('#radio-1')!;
const radio2 = radioGroup.querySelector<SlRadio>('#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<SlRadioGroup>(html`
<sl-radio-group>
<sl-radio checked></sl-radio>
<sl-radio disabled></sl-radio>
</sl-radio-group>
`);
const radio1 = radioGroup.querySelector<SlRadio>('sl-radio[checked]')!;
const radio2 = radioGroup.querySelector<SlRadio>('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<HTMLFormElement>(html`

View File

@@ -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<SlRadio>('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`
<label

115
src/internal/radio.ts Normal file
View File

@@ -0,0 +1,115 @@
import { LitElement } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { emit } from '~/internal/event';
import { FormSubmitController } from '~/internal/form-control';
import { watch } from '~/internal/watch';
/**
* @since 2.0
* @status stable
*
* @slot - The radio's label.
*
* @event sl-blur - Emitted when the control loses focus.
* @event sl-change - Emitted when the control's checked state changes.
* @event sl-focus - Emitted when the control gains focus.
*
* @csspart base - The component's internal wrapper.
* @csspart control - The radio control.
* @csspart checked-icon - The container the wraps the checked icon.
* @csspart label - The radio label.
*/
export default abstract class RadioBase extends LitElement {
@query('input[type="radio"], button') input: HTMLInputElement;
protected readonly formSubmitController = new FormSubmitController(this, {
value: (control: RadioBase) => (control.checked ? control.value : undefined)
});
@state() protected 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();
}
handleBlur() {
this.hasFocus = false;
emit(this, 'sl-blur');
}
handleClick() {
if (!this.disabled) {
this.checked = true;
}
}
handleFocus() {
this.hasFocus = true;
emit(this, 'sl-focus');
}
@watch('checked')
handleCheckedChange() {
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
if (this.hasUpdated) {
emit(this, 'sl-change');
}
}
@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();
}
}
}

View File

@@ -32,6 +32,7 @@ export { default as SlProgressBar } from './components/progress-bar/progress-bar
export { default as SlProgressRing } from './components/progress-ring/progress-ring';
export { default as SlQrCode } from './components/qr-code/qr-code';
export { default as SlRadio } from './components/radio/radio';
export { default as SlRadioButton } from './components/radio-button/radio-button';
export { default as SlRadioGroup } from './components/radio-group/radio-group';
export { default as SlRange } from './components/range/range';
export { default as SlRating } from './components/rating/rating';