kopia lustrzana https://github.com/shoelace-style/shoelace
Merge branch 'radio-group' of https://github.com/Trendy/shoelace into next
commit
e5cbee2770
docs/components
src/components
radio-group
|
@ -42,11 +42,62 @@ You can show a fieldset and legend that wraps the radio group using the `fieldse
|
|||
import { SlRadio, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
|
||||
|
||||
const App = () => (
|
||||
<SlRadioGroup label="Select an option" fieldset>
|
||||
<SlRadioGroup label="Select an option" fieldset value="1">
|
||||
<SlRadio value="1" checked>Option 1</SlRadio>
|
||||
<SlRadio value="2">Option 2</SlRadio>
|
||||
<SlRadio value="3">Option 3</SlRadio>
|
||||
</SlRadioGroup>
|
||||
);
|
||||
```
|
||||
|
||||
### Using the required attribute
|
||||
|
||||
Adding a `required` attribute to `sl-radio-group` will require at least one option to be selected.
|
||||
|
||||
```html preview
|
||||
<sl-radio-group class="required-radio-group" label="Select an option" fieldset required>
|
||||
<sl-radio value="1" name="foo">Option 1</sl-radio>
|
||||
<sl-radio value="2" name="foo">Option 2</sl-radio>
|
||||
<sl-radio value="3" name="foo">Option 3</sl-radio>
|
||||
</sl-radio-group>
|
||||
<br />
|
||||
<sl-button class="required-button">Validate Group</sl-button>
|
||||
<sl-button class="required-reset-button">Reset Group</sl-button>
|
||||
|
||||
<script>
|
||||
const button = document.querySelector('sl-button.required-button');
|
||||
const resetButton = document.querySelector('sl-button.required-reset-button');
|
||||
const group = document.querySelector('sl-radio-group.required-radio-group');
|
||||
|
||||
button.addEventListener('click', ()=> group.reportValidity());
|
||||
resetButton.addEventListener('click', () => { group.value = ''})
|
||||
</script>
|
||||
```
|
||||
|
||||
```jsx react
|
||||
import { SlRadio, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
|
||||
const validateGroup = () => {
|
||||
const group = document.querySelector('sl-radio-group.required-radio-group');
|
||||
group.reportValidity();
|
||||
}
|
||||
|
||||
const resetGroup = () => {
|
||||
const group = document.querySelector('sl-radio-group.required-radio-group');
|
||||
group.value = "";
|
||||
}
|
||||
|
||||
const App = () => (
|
||||
<>
|
||||
<SlRadioGroup label="Select an option" fieldset required>
|
||||
<SlRadio value="1" checked>Option 1</SlRadio>
|
||||
<SlRadio value="2">Option 2</SlRadio>
|
||||
<SlRadio value="3">Option 3</SlRadio>
|
||||
</SlRadioGroup>
|
||||
<br />
|
||||
<sl-button class="required-button" onClick={()=> validateGroup()}>Validate Group</sl-button>
|
||||
<sl-button class="required-button" onClick={()=> resetGroup()}>Reset Group</sl-button>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
[component-metadata:sl-radio-group]
|
||||
|
|
|
@ -0,0 +1,88 @@
|
|||
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
|
||||
import '../../../dist/shoelace.js';
|
||||
import type SlRadio from '../radio/radio';
|
||||
import type SlRadioGroup from './radio-group';
|
||||
|
||||
describe('<sl-radio-group>', () => {
|
||||
it('should toggle selected radio when toggled via keyboard - arrow right key', async () => {
|
||||
const radioGroup = await fixture<SlRadioGroup>(html`
|
||||
<sl-radio-group>
|
||||
<sl-radio id="radio-1" checked></sl-radio>
|
||||
<sl-radio id="radio-2"></sl-radio>
|
||||
</sl-radio-group>
|
||||
`);
|
||||
const radio1: SlRadio = radioGroup.querySelector('sl-radio#radio-1');
|
||||
const radio2: SlRadio = radioGroup.querySelector('sl-radio#radio-2');
|
||||
|
||||
expect(radio2.checked).to.be.false;
|
||||
expect(radio1.checked).to.be.true;
|
||||
|
||||
radio1.focus();
|
||||
await sendKeys({ press: 'ArrowRight' });
|
||||
|
||||
expect(radio2.checked).to.be.true;
|
||||
expect(radio1.checked).to.be.false;
|
||||
});
|
||||
|
||||
it('should toggle selected radio when toggled via keyboard - arrow down key', async () => {
|
||||
const radioGroup = await fixture<SlRadioGroup>(html`
|
||||
<sl-radio-group>
|
||||
<sl-radio id="radio-1" checked></sl-radio>
|
||||
<sl-radio id="radio-2"></sl-radio>
|
||||
</sl-radio-group>
|
||||
`);
|
||||
const radio1: SlRadio = radioGroup.querySelector('sl-radio#radio-1');
|
||||
const radio2: SlRadio = radioGroup.querySelector('sl-radio#radio-2');
|
||||
|
||||
expect(radio2.checked).to.be.false;
|
||||
expect(radio1.checked).to.be.true;
|
||||
|
||||
radio1.focus();
|
||||
await sendKeys({ press: 'ArrowDown' });
|
||||
|
||||
expect(radio2.checked).to.be.true;
|
||||
expect(radio1.checked).to.be.false;
|
||||
});
|
||||
|
||||
it('should toggle selected radio when toggled via keyboard - arrow left key', async () => {
|
||||
const radioGroup = await fixture<SlRadioGroup>(html`
|
||||
<sl-radio-group>
|
||||
<sl-radio id="radio-1"></sl-radio>
|
||||
<sl-radio id="radio-2" checked></sl-radio>
|
||||
</sl-radio-group>
|
||||
`);
|
||||
const radio1: SlRadio = radioGroup.querySelector('sl-radio#radio-1');
|
||||
const radio2: SlRadio = radioGroup.querySelector('sl-radio#radio-2');
|
||||
|
||||
expect(radio2.checked).to.be.true;
|
||||
expect(radio1.checked).to.be.false;
|
||||
|
||||
radio1.focus();
|
||||
await sendKeys({ press: 'ArrowLeft' });
|
||||
|
||||
expect(radio2.checked).to.be.false;
|
||||
expect(radio1.checked).to.be.true;
|
||||
});
|
||||
|
||||
it('should toggle selected radio when toggled via keyboard - arrow up key', async () => {
|
||||
const radioGroup = await fixture<SlRadioGroup>(html`
|
||||
<sl-radio-group>
|
||||
<sl-radio id="radio-1"></sl-radio>
|
||||
<sl-radio id="radio-2" checked></sl-radio>
|
||||
</sl-radio-group>
|
||||
`);
|
||||
const radio1: SlRadio = radioGroup.querySelector('sl-radio#radio-1');
|
||||
const radio2: SlRadio = radioGroup.querySelector('sl-radio#radio-2');
|
||||
|
||||
expect(radio2.checked).to.be.true;
|
||||
expect(radio1.checked).to.be.false;
|
||||
|
||||
radio1.focus();
|
||||
await sendKeys({ press: 'ArrowUp' });
|
||||
|
||||
expect(radio2.checked).to.be.false;
|
||||
expect(radio1.checked).to.be.true;
|
||||
});
|
||||
});
|
|
@ -17,15 +17,63 @@ import styles from './radio-group.styles';
|
|||
@customElement('sl-radio-group')
|
||||
export default class SlRadioGroup extends LitElement {
|
||||
static styles = styles;
|
||||
private _value: string = '';
|
||||
|
||||
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
|
||||
|
||||
/** The radio group label. Required for proper accessibility. Alternatively, you can use the label slot. */
|
||||
@property() label = '';
|
||||
|
||||
/** The current value of the radio group. */
|
||||
@property()
|
||||
get value() {
|
||||
if (!this._value) return this.getCurrentValue();
|
||||
|
||||
return this._value;
|
||||
}
|
||||
|
||||
set value(newValue) {
|
||||
const index = this.getAllRadios().findIndex(el => el.value === newValue);
|
||||
const oldValue = this._value;
|
||||
|
||||
if (index > -1) {
|
||||
this.checkRadioByIndex(index);
|
||||
this._value = newValue;
|
||||
this.requestUpdate('value', oldValue);
|
||||
} else {
|
||||
this._value = '';
|
||||
this.deselectAll();
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the fieldset and legend that surrounds the radio group. */
|
||||
@property({ type: Boolean, attribute: 'fieldset' }) fieldset = false;
|
||||
|
||||
/** Indicates that a selection is required. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
connectedCallback() {
|
||||
this.addEventListener('sl-change', this.syncRadioButtons);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener('sl-change', this.syncRadioButtons);
|
||||
}
|
||||
|
||||
syncRadioButtons(event: CustomEvent) {
|
||||
const currentRadio = event.target;
|
||||
const radios = this.getAllRadios().filter(el => !el.disabled && el !== currentRadio);
|
||||
radios.forEach(el => {
|
||||
el.checked = false;
|
||||
});
|
||||
}
|
||||
|
||||
getCurrentValue() {
|
||||
const valRadio = this.getAllRadios().filter(el => el.checked);
|
||||
this._value = valRadio.length === 1 ? valRadio[0].value : '';
|
||||
return this._value;
|
||||
}
|
||||
|
||||
handleFocusIn() {
|
||||
// When tabbing into the fieldset, make sure it lands on the checked radio
|
||||
requestAnimationFrame(() => {
|
||||
|
@ -39,6 +87,63 @@ export default class SlRadioGroup extends LitElement {
|
|||
});
|
||||
}
|
||||
|
||||
getAllRadios(): SlRadio[] {
|
||||
return [...this.querySelectorAll('sl-radio')];
|
||||
}
|
||||
|
||||
checkRadioByIndex(index: number): SlRadio[] {
|
||||
const radios = this.deselectAll();
|
||||
|
||||
radios[index].focus();
|
||||
radios[index].checked = true;
|
||||
this._value = radios[index].value;
|
||||
|
||||
return radios;
|
||||
}
|
||||
|
||||
deselectAll(): SlRadio[] {
|
||||
return this.getAllRadios().map(radio => {
|
||||
radio.checked = false;
|
||||
return radio;
|
||||
});
|
||||
}
|
||||
|
||||
handleKeyDown(event: KeyboardEvent) {
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
|
||||
const radios = this.getAllRadios().filter(radio => !radio.disabled);
|
||||
const currentIndex = radios.findIndex(el => el.checked);
|
||||
|
||||
const incr = ['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1;
|
||||
let index = currentIndex + incr;
|
||||
if (index < 0) index = radios.length - 1;
|
||||
if (index > radios.length - 1) index = 0;
|
||||
|
||||
this.checkRadioByIndex(index);
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
reportValidity() {
|
||||
const radios = [...(this.defaultSlot.assignedElements({ flatten: true }) as SlRadio[])];
|
||||
let isChecked = true;
|
||||
|
||||
if (this.required && radios.length > 0) {
|
||||
isChecked = radios.some(el => el.checked);
|
||||
|
||||
if (!isChecked) {
|
||||
// This is hacky...
|
||||
radios[0].required = true;
|
||||
|
||||
setTimeout(() => {
|
||||
radios[0].reportValidity();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return isChecked;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<fieldset
|
||||
|
@ -49,6 +154,7 @@ export default class SlRadioGroup extends LitElement {
|
|||
})}
|
||||
role="radiogroup"
|
||||
@focusin=${this.handleFocusIn}
|
||||
@keydown=${this.handleKeyDown}
|
||||
>
|
||||
<legend part="label" class="radio-group__label">
|
||||
<slot name="label">${this.label}</slot>
|
||||
|
|
|
@ -37,23 +37,6 @@ describe('<sl-radio>', () => {
|
|||
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 id="radio-1"></sl-radio>
|
||||
<sl-radio id="radio-2"></sl-radio>
|
||||
</sl-radio-group>
|
||||
`);
|
||||
const radio1: SlRadio = radioGroup.querySelector('sl-radio#radio-1');
|
||||
const radio2: SlRadio = radioGroup.querySelector('sl-radio#radio-2');
|
||||
const input1 = radio1.shadowRoot?.querySelector('input');
|
||||
input1.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 fire sl-change when checked is set by javascript', async () => {
|
||||
const el = await fixture<SlRadio>(html` <sl-radio></sl-radio> `);
|
||||
el.addEventListener('sl-change', () => expect.fail('event fired'));
|
||||
|
|
|
@ -47,6 +47,9 @@ export default class SlRadio extends LitElement {
|
|||
/** Draws the radio in a checked state. */
|
||||
@property({ type: Boolean, reflect: true }) checked = false;
|
||||
|
||||
/** Indicates that a selection is required. */
|
||||
@property({ type: Boolean, reflect: true }) required = 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.
|
||||
|
@ -79,33 +82,11 @@ export default class SlRadio extends LitElement {
|
|||
this.invalid = !this.input.checkValidity();
|
||||
}
|
||||
|
||||
getAllRadios() {
|
||||
const radioGroup = this.closest('sl-radio-group');
|
||||
|
||||
// Radios must be part of a radio group
|
||||
if (!radioGroup) {
|
||||
return [this];
|
||||
}
|
||||
|
||||
return [...radioGroup.querySelectorAll('sl-radio')].filter((radio: this) => radio.name === this.name) as this[];
|
||||
}
|
||||
|
||||
getSiblingRadios() {
|
||||
return this.getAllRadios().filter(radio => radio !== this) as this[];
|
||||
}
|
||||
|
||||
handleBlur() {
|
||||
this.hasFocus = false;
|
||||
emit(this, 'sl-blur');
|
||||
}
|
||||
|
||||
@watch('checked', { waitUntilFirstUpdate: true })
|
||||
handleCheckedChange() {
|
||||
if (this.checked) {
|
||||
this.getSiblingRadios().map(radio => (radio.checked = false));
|
||||
}
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
this.checked = true;
|
||||
emit(this, 'sl-change');
|
||||
|
@ -125,23 +106,6 @@ export default class SlRadio extends LitElement {
|
|||
emit(this, 'sl-focus');
|
||||
}
|
||||
|
||||
handleKeyDown(event: KeyboardEvent) {
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
|
||||
const radios = this.getAllRadios().filter(radio => !radio.disabled);
|
||||
const incr = ['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1;
|
||||
let index = radios.indexOf(this) + incr;
|
||||
if (index < 0) index = radios.length - 1;
|
||||
if (index > radios.length - 1) index = 0;
|
||||
|
||||
this.getAllRadios().map(radio => (radio.checked = false));
|
||||
radios[index].focus();
|
||||
radios[index].checked = true;
|
||||
emit(radios[index], 'sl-change');
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<label
|
||||
|
@ -153,7 +117,6 @@ export default class SlRadio extends LitElement {
|
|||
'radio--focused': this.hasFocus
|
||||
})}
|
||||
for=${this.inputId}
|
||||
@keydown=${this.handleKeyDown}
|
||||
>
|
||||
<input
|
||||
id=${this.inputId}
|
||||
|
@ -161,6 +124,7 @@ export default class SlRadio extends LitElement {
|
|||
type="radio"
|
||||
name=${ifDefined(this.name)}
|
||||
value=${ifDefined(this.value)}
|
||||
?required=${this.required}
|
||||
.checked=${live(this.checked)}
|
||||
.disabled=${this.disabled}
|
||||
aria-checked=${this.checked ? 'true' : 'false'}
|
||||
|
|
Ładowanie…
Reference in New Issue