improve form controls a11y; add tests

pull/706/head
Cory LaViska 2022-03-08 17:34:17 -05:00
rodzic 8ae987ea69
commit 3aa5fdba55
22 zmienionych plików z 606 dodań i 563 usunięć

Wyświetl plik

@ -13,13 +13,16 @@ _During the beta period, these restrictions may be relaxed in the event of a mis
- Added `form`, `formaction`, `formmethod`, `formnovalidate`, and `formtarget` attributes to `<sl-button>` [#699](https://github.com/shoelace-style/shoelace/issues/699)
- Added Prettier and ESLint to markdown files
- Added background color and border to `<sl-menu>`
- Added more tests for `<sl-input>`, `<sl-select>`, and `<sl-textarea>`
- Fixed a bug that prevented forms from submitting when pressing <kbd>Enter</kbd> inside of an `<sl-input>` [#700](https://github.com/shoelace-style/shoelace/issues/700)
- Fixed a bug in `<sl-input>` that prevented the `valueAsDate` and `valueAsNumber` properties from working when set before the component was initialized
- Improved `autofocus` behavior in Safari for `<sl-dialog>` and `<sl-drawer>` [#693](https://github.com/shoelace-style/shoelace/issues/693)
- Improved type to select logic in `<sl-menu>` so it supports <kbd>Backspace</kbd> and gives users more time before resetting
- Improved checkmark size and positioning in `<sl-menu-item>`
- Improved accessibility in form controls that have help text so they're announced correctly in various screen readers
- Removed feature detection for `focus({ preventScroll })` since it no longer works in Safari
- Removed the `--sl-tooltip-arrow-start-end-offset` design token
- Removed the `pattern` attribute from `<sl-textarea>` as it was documented incorrectly and never supported
- Replaced Popper positioning dependency with Floating UI in `<sl-dropdown>` and `<sl-tooltip>`
## 2.0.0-beta.70

Wyświetl plik

@ -9,7 +9,7 @@ describe('<sl-avatar>', () => {
el = await fixture<SlAvatar>(html` <sl-avatar label="Avatar"></sl-avatar> `);
});
it('passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -27,7 +27,7 @@ describe('<sl-avatar>', () => {
el = await fixture<SlAvatar>(html`<sl-avatar image="${image}" label="${label}"></sl-avatar>`);
});
it('passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
/**
* The image element itself is ancillary, because it's parent container contains the
* aria-label which dictates what "sl-avatar" is. This also implies that label text will
@ -57,7 +57,7 @@ describe('<sl-avatar>', () => {
el = await fixture<SlAvatar>(html`<sl-avatar initials="${initials}" label="Avatar"></sl-avatar>`);
});
it('passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -74,7 +74,7 @@ describe('<sl-avatar>', () => {
el = await fixture<SlAvatar>(html`<sl-avatar shape="${shape}" label="Shaped avatar"></sl-avatar>`);
});
it('passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -92,7 +92,7 @@ describe('<sl-avatar>', () => {
el = await fixture<SlAvatar>(html`<sl-avatar label="Avatar"><span slot="icon">random content</span></sl-avatar>`);
});
it('passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});

Wyświetl plik

@ -9,7 +9,7 @@ describe('<sl-badge>', () => {
el = await fixture<SlBadge>(html` <sl-badge>Badge</sl-badge> `);
});
it('should render a component that passes accessibility test, with a role of status on the base part.', async () => {
it('should pass accessibility tests with a role of status on the base part.', async () => {
await expect(el).to.be.accessible();
const part = el.shadowRoot!.querySelector('[part="base"]')!;
@ -31,7 +31,7 @@ describe('<sl-badge>', () => {
el = await fixture<SlBadge>(html` <sl-badge pill>Badge</sl-badge> `);
});
it('should render a component that passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -46,7 +46,7 @@ describe('<sl-badge>', () => {
el = await fixture<SlBadge>(html` <sl-badge pulse>Badge</sl-badge> `);
});
it('should render a component that passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -62,7 +62,7 @@ describe('<sl-badge>', () => {
el = await fixture<SlBadge>(html`<sl-badge variant="${variant}">Badge</sl-badge>`);
});
it('should render a component that passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});

Wyświetl plik

@ -9,7 +9,7 @@ describe('<sl-breadcrumb-item>', () => {
el = await fixture<SlBreadcrumbItem>(html` <sl-breadcrumb-item>Home</sl-breadcrumb-item> `);
});
it('should render a component that passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -33,7 +33,7 @@ describe('<sl-breadcrumb-item>', () => {
`);
});
it('should render a component that passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -50,7 +50,7 @@ describe('<sl-breadcrumb-item>', () => {
`);
});
it('should render a component that passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -80,7 +80,7 @@ describe('<sl-breadcrumb-item>', () => {
`);
});
it('should render a component that passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -112,7 +112,7 @@ describe('<sl-breadcrumb-item>', () => {
`);
});
it('should render a component that passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -139,7 +139,7 @@ describe('<sl-breadcrumb-item>', () => {
`);
});
it('should render a component that passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});

Wyświetl plik

@ -16,7 +16,7 @@ describe('<sl-breadcrumb>', () => {
`);
});
it('should render a component that passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -43,7 +43,7 @@ describe('<sl-breadcrumb>', () => {
`);
});
it('should render a component that passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -75,7 +75,7 @@ describe('<sl-breadcrumb>', () => {
`);
});
it('should render a component that passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});
@ -95,7 +95,7 @@ describe('<sl-breadcrumb>', () => {
`);
});
it('should render a component that passes accessibility test', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});

Wyświetl plik

@ -11,7 +11,7 @@ describe('<sl-card>', () => {
);
});
it('should render a component that passes accessibility test.', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -35,7 +35,7 @@ describe('<sl-card>', () => {
);
});
it('should render a component that passes accessibility test.', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -72,7 +72,7 @@ describe('<sl-card>', () => {
);
});
it('should render a component that passes accessibility test.', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -112,7 +112,7 @@ describe('<sl-card>', () => {
);
});
it('should render a component that passes accessibility test.', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});

Wyświetl plik

@ -3,7 +3,6 @@ import { customElement, property, query, state } 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 { autoIncrement } from '~/internal/auto-increment';
import { emit } from '~/internal/event';
import { FormSubmitController } from '~/internal/form-control';
import { watch } from '~/internal/watch';
@ -35,9 +34,6 @@ export default class SlCheckbox extends LitElement {
private readonly formSubmitController = new FormSubmitController(this, {
value: (control: SlCheckbox) => (control.checked ? control.value : undefined)
});
private readonly attrId = autoIncrement();
private readonly inputId = `checkbox-${this.attrId}`;
private readonly labelId = `checkbox-label-${this.attrId}`;
@state() private hasFocus = false;
@ -132,10 +128,8 @@ export default class SlCheckbox extends LitElement {
'checkbox--focused': this.hasFocus,
'checkbox--indeterminate': this.indeterminate
})}
for=${this.inputId}
>
<input
id=${this.inputId}
class="checkbox__input"
type="checkbox"
name=${ifDefined(this.name)}
@ -145,7 +139,6 @@ export default class SlCheckbox extends LitElement {
.disabled=${this.disabled}
.required=${this.required}
aria-checked=${this.checked ? 'true' : 'false'}
aria-labelledby=${this.labelId}
@click=${this.handleClick}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@ -185,7 +178,7 @@ export default class SlCheckbox extends LitElement {
: ''}
</span>
<span part="label" id=${this.labelId} class="checkbox__label">
<span part="label" class="checkbox__label">
<slot></slot>
</span>
</label>

Wyświetl plik

@ -2,7 +2,6 @@ import { html, LitElement } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import '~/components/icon/icon';
import { autoIncrement } from '~/internal/auto-increment';
import { drag } from '~/internal/drag';
import { emit } from '~/internal/event';
import { clamp } from '~/internal/math';
@ -37,9 +36,6 @@ export default class SlImageComparer extends LitElement {
@query('.image-comparer') base: HTMLElement;
@query('.image-comparer__handle') handle: HTMLElement;
private readonly attrId = autoIncrement();
private readonly containerId = `comparer-container-${this.attrId}`;
/** The position of the divider as a percentage. */
@property({ type: Number, reflect: true }) position = 50;
@ -85,7 +81,7 @@ export default class SlImageComparer extends LitElement {
render() {
return html`
<div part="base" class="image-comparer" @keydown=${this.handleKeyDown} id=${this.containerId}>
<div part="base" id="image-comparer" class="image-comparer" @keydown=${this.handleKeyDown}>
<div class="image-comparer__image">
<div part="before" class="image-comparer__before">
<slot name="before"></slot>
@ -114,7 +110,7 @@ export default class SlImageComparer extends LitElement {
aria-valuenow=${this.position}
aria-valuemin="0"
aria-valuemax="100"
aria-controls=${this.containerId}
aria-controls="image-comparer"
tabindex="0"
>
<slot name="handle-icon">

Wyświetl plik

@ -1,9 +1,16 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
// eslint-disable-next-line no-restricted-imports
import { serialize } from '../../utilities/form';
import type SlInput from './input';
describe('<sl-input>', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<SlInput>(html` <sl-input label="Name"></sl-input> `);
await expect(el).to.be.accessible();
});
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<SlInput>(html` <sl-input disabled></sl-input> `);
const input = el.shadowRoot!.querySelector<HTMLInputElement>('[part="input"]')!;
@ -11,27 +18,6 @@ describe('<sl-input>', () => {
expect(input.disabled).to.be.true;
});
it('should be valid by default', async () => {
const el = await fixture<SlInput>(html` <sl-input></sl-input> `);
expect(el.invalid).to.be.false;
});
it('should be invalid when required and empty', async () => {
const el = await fixture<SlInput>(html` <sl-input required></sl-input> `);
expect(el.invalid).to.be.true;
});
it('should be invalid when required and after removing disabled', async () => {
const el = await fixture<SlInput>(html` <sl-input disabled required></sl-input> `);
el.disabled = false;
await el.updateComplete;
expect(el.invalid).to.be.true;
});
it('should submit the form when pressing enter in a form without a submit button', async () => {
const form = await fixture<HTMLFormElement>(html` <form><sl-input></sl-input></form> `);
const input = form.querySelector('sl-input')!;
@ -64,4 +50,56 @@ describe('<sl-input>', () => {
expect(el.value).to.equal(num.toString());
});
it('should focus the input when clicking on the label', async () => {
const el = await fixture<SlInput>(html` <sl-input label="Name"></sl-input> `);
const label = el.shadowRoot!.querySelector('[part="label"]')!;
const submitHandler = sinon.spy();
el.addEventListener('sl-focus', submitHandler);
(label as HTMLLabelElement).click();
await waitUntil(() => submitHandler.calledOnce);
expect(submitHandler).to.have.been.calledOnce;
});
//
// Constraint validation tests
//
it('should be valid by default', async () => {
const el = await fixture<SlInput>(html` <sl-input></sl-input> `);
expect(el.invalid).to.be.false;
});
it('should be invalid when required and empty', async () => {
const el = await fixture<SlInput>(html` <sl-input required></sl-input> `);
expect(el.reportValidity()).to.be.false;
expect(el.invalid).to.be.true;
});
it('should be invalid when the pattern does not match', async () => {
const el = await fixture<SlInput>(html` <sl-input pattern="^test" value="fail"></sl-input> `);
expect(el.invalid).to.be.true;
expect(el.reportValidity()).to.be.false;
});
it('should be invalid when required and disabled is removed', async () => {
const el = await fixture<SlInput>(html` <sl-input disabled required></sl-input> `);
el.disabled = false;
await el.updateComplete;
expect(el.invalid).to.be.true;
});
it('should serialize its name and value with FormData', async () => {
const form = await fixture<HTMLFormElement>(html` <form><sl-input name="a" value="1"></sl-input></form> `);
const formData = new FormData(form);
expect(formData.get('a')).to.equal('1');
});
it('should serialize its name and value with JSON', async () => {
const form = await fixture<HTMLFormElement>(html` <form><sl-input name="a" value="1"></sl-input></form> `);
const json = serialize(form);
expect(json.a).to.equal('1');
});
});

Wyświetl plik

@ -4,9 +4,8 @@ import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import '~/components/icon/icon';
import { autoIncrement } from '~/internal/auto-increment';
import { emit } from '~/internal/event';
import { FormSubmitController, getLabelledBy, renderFormControl } from '~/internal/form-control';
import { FormSubmitController } from '~/internal/form-control';
import { HasSlotController } from '~/internal/slot';
import { watch } from '~/internal/watch';
import styles from './input.styles';
@ -49,10 +48,6 @@ export default class SlInput extends LitElement {
private readonly formSubmitController = new FormSubmitController(this);
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private readonly attrId = autoIncrement();
private readonly inputId = `input-${this.attrId}`;
private readonly helpTextId = `input-help-text-${this.attrId}`;
private readonly labelId = `input-label-${this.attrId}`;
@state() private hasFocus = false;
@state() private isPasswordVisible = false;
@ -283,131 +278,138 @@ export default class SlInput extends LitElement {
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');
const hasLabel = this.label ? true : !!hasLabelSlot;
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
// NOTE - always bind value after min/max, otherwise it will be clamped
return renderFormControl(
{
inputId: this.inputId,
label: this.label,
labelId: this.labelId,
hasLabelSlot,
helpTextId: this.helpTextId,
helpText: this.helpText,
hasHelpTextSlot,
size: this.size
},
html`
<div
part="base"
class=${classMap({
input: true,
return html`
<div
part="form-control"
class=${classMap({
'form-control': true,
'form-control--small': this.size === 'small',
'form-control--medium': this.size === 'medium',
'form-control--large': this.size === 'large',
'form-control--has-label': hasLabel,
'form-control--has-help-text': hasHelpText
})}
>
<label part="label" class="form-control__label" for="input" aria-hidden=${hasLabel ? 'false' : 'true'}>
<slot name="label">${this.label}</slot>
</label>
// Sizes
'input--small': this.size === 'small',
'input--medium': this.size === 'medium',
'input--large': this.size === 'large',
<div class="form-control__input">
<div
part="base"
class=${classMap({
input: true,
// States
'input--pill': this.pill,
'input--standard': !this.filled,
'input--filled': this.filled,
'input--disabled': this.disabled,
'input--focused': this.hasFocus,
'input--empty': this.value.length === 0,
'input--invalid': this.invalid
})}
>
<span part="prefix" class="input__prefix">
<slot name="prefix"></slot>
</span>
// Sizes
'input--small': this.size === 'small',
'input--medium': this.size === 'medium',
'input--large': this.size === 'large',
<input
part="input"
id=${this.inputId}
class="input__control"
type=${this.type === 'password' && this.isPasswordVisible ? 'text' : this.type}
name=${ifDefined(this.name)}
?disabled=${this.disabled}
?readonly=${this.readonly}
?required=${this.required}
placeholder=${ifDefined(this.placeholder)}
minlength=${ifDefined(this.minlength)}
maxlength=${ifDefined(this.maxlength)}
min=${ifDefined(this.min)}
max=${ifDefined(this.max)}
step=${ifDefined(this.step)}
.value=${live(this.value)}
autocapitalize=${ifDefined(this.autocapitalize)}
autocomplete=${ifDefined(this.autocomplete)}
autocorrect=${ifDefined(this.autocorrect)}
?autofocus=${this.autofocus}
spellcheck=${ifDefined(this.spellcheck)}
pattern=${ifDefined(this.pattern)}
inputmode=${ifDefined(this.inputmode)}
aria-labelledby=${ifDefined(
getLabelledBy({
label: this.label,
labelId: this.labelId,
hasLabelSlot,
helpText: this.helpText,
helpTextId: this.helpTextId,
hasHelpTextSlot
})
)}
aria-invalid=${this.invalid ? 'true' : 'false'}
@change=${this.handleChange}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
/>
// States
'input--pill': this.pill,
'input--standard': !this.filled,
'input--filled': this.filled,
'input--disabled': this.disabled,
'input--focused': this.hasFocus,
'input--empty': this.value.length === 0,
'input--invalid': this.invalid
})}
>
<span part="prefix" class="input__prefix">
<slot name="prefix"></slot>
</span>
${this.clearable && this.value.length > 0
? html`
<button
part="clear-button"
class="input__clear"
type="button"
@click=${this.handleClearClick}
tabindex="-1"
>
<slot name="clear-icon">
<sl-icon name="x-circle-fill" library="system"></sl-icon>
</slot>
</button>
`
: ''}
${this.togglePassword
? html`
<button
part="password-toggle-button"
class="input__password-toggle"
type="button"
@click=${this.handlePasswordToggle}
tabindex="-1"
>
${this.isPasswordVisible
? html`
<slot name="show-password-icon">
<sl-icon name="eye-slash" library="system"></sl-icon>
</slot>
`
: html`
<slot name="hide-password-icon">
<sl-icon name="eye" library="system"></sl-icon>
</slot>
`}
</button>
`
: ''}
<input
part="input"
id="input"
class="input__control"
type=${this.type === 'password' && this.isPasswordVisible ? 'text' : this.type}
name=${ifDefined(this.name)}
?disabled=${this.disabled}
?readonly=${this.readonly}
?required=${this.required}
placeholder=${ifDefined(this.placeholder)}
minlength=${ifDefined(this.minlength)}
maxlength=${ifDefined(this.maxlength)}
min=${ifDefined(this.min)}
max=${ifDefined(this.max)}
step=${ifDefined(this.step)}
.value=${live(this.value)}
autocapitalize=${ifDefined(this.autocapitalize)}
autocomplete=${ifDefined(this.autocomplete)}
autocorrect=${ifDefined(this.autocorrect)}
?autofocus=${this.autofocus}
spellcheck=${ifDefined(this.spellcheck)}
pattern=${ifDefined(this.pattern)}
inputmode=${ifDefined(this.inputmode)}
aria-describedby="help-text"
aria-invalid=${this.invalid ? 'true' : 'false'}
@change=${this.handleChange}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
/>
<span part="suffix" class="input__suffix">
<slot name="suffix"></slot>
</span>
${this.clearable && this.value.length > 0
? html`
<button
part="clear-button"
class="input__clear"
type="button"
@click=${this.handleClearClick}
tabindex="-1"
>
<slot name="clear-icon">
<sl-icon name="x-circle-fill" library="system"></sl-icon>
</slot>
</button>
`
: ''}
${this.togglePassword
? html`
<button
part="password-toggle-button"
class="input__password-toggle"
type="button"
@click=${this.handlePasswordToggle}
tabindex="-1"
>
${this.isPasswordVisible
? html`
<slot name="show-password-icon">
<sl-icon name="eye-slash" library="system"></sl-icon>
</slot>
`
: html`
<slot name="hide-password-icon">
<sl-icon name="eye" library="system"></sl-icon>
</slot>
`}
</button>
`
: ''}
<span part="suffix" class="input__suffix">
<slot name="suffix"></slot>
</span>
</div>
</div>
`
);
<div
part="help-text"
id="help-text"
class="form-control__help-text"
aria-hidden=${hasHelpText ? 'false' : 'true'}
>
<slot name="help-text">${this.helpText}</slot>
</div>
</div>
`;
}
}

Wyświetl plik

@ -9,7 +9,7 @@ describe('<sl-progress-bar>', () => {
el = await fixture<SlProgressBar>(html`<sl-progress-bar value="25"></sl-progress-bar>`);
});
it('should render a component that passes accessibility test.', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});
@ -26,7 +26,7 @@ describe('<sl-progress-bar>', () => {
indicator = el.shadowRoot!.querySelector('[part="indicator"]')!;
});
it('should render a component that passes accessibility test.', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -49,7 +49,7 @@ describe('<sl-progress-bar>', () => {
base = el.shadowRoot!.querySelector('[part="base"]')!;
});
it('should render a component that passes accessibility test.', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -65,7 +65,7 @@ describe('<sl-progress-bar>', () => {
);
});
it('should render a component that passes accessibility test.', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});
@ -80,7 +80,7 @@ describe('<sl-progress-bar>', () => {
);
});
it('should render a component that passes accessibility test.', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});

Wyświetl plik

@ -9,7 +9,7 @@ describe('<sl-progress-ring>', () => {
el = await fixture<SlProgressRing>(html`<sl-progress-ring value="25"></sl-progress-ring>`);
});
it('should render a component that passes accessibility test.', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});
@ -24,7 +24,7 @@ describe('<sl-progress-ring>', () => {
base = el.shadowRoot!.querySelector('[part="base"]')!;
});
it('should render a component that passes accessibility test.', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
@ -44,7 +44,7 @@ describe('<sl-progress-ring>', () => {
);
});
it('should render a component that passes accessibility test.', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});
@ -59,7 +59,7 @@ describe('<sl-progress-ring>', () => {
);
});
it('should render a component that passes accessibility test.', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});

Wyświetl plik

@ -104,7 +104,7 @@ export default class SlRadio extends LitElement {
return [this];
}
return [...radioGroup.querySelectorAll('sl-radio')].filter((radio: this) => radio.name === this.name) as this[];
return [...radioGroup.querySelectorAll<SlRadio>('sl-radio')].filter((radio: this) => radio.name === this.name);
}
getSiblingRadios() {

Wyświetl plik

@ -3,9 +3,8 @@ import { customElement, property, query, state } 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 { autoIncrement } from '~/internal/auto-increment';
import { emit } from '~/internal/event';
import { FormSubmitController, getLabelledBy, renderFormControl } from '~/internal/form-control';
import { FormSubmitController } from '~/internal/form-control';
import { HasSlotController } from '~/internal/slot';
import { watch } from '~/internal/watch';
import styles from './range.styles';
@ -41,10 +40,6 @@ export default class SlRange extends LitElement {
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this);
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private readonly attrId = autoIncrement();
private readonly inputId = `input-${this.attrId}`;
private readonly helpTextId = `input-help-text-${this.attrId}`;
private readonly labelId = `input-label-${this.attrId}`;
private resizeObserver: ResizeObserver;
@state() private hasFocus = false;
@ -204,69 +199,76 @@ export default class SlRange extends LitElement {
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');
const hasLabel = this.label ? true : !!hasLabelSlot;
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
// NOTE - always bind value after min/max, otherwise it will be clamped
return renderFormControl(
{
inputId: this.inputId,
label: this.label,
labelId: this.labelId,
hasLabelSlot,
helpTextId: this.helpTextId,
helpText: this.helpText,
hasHelpTextSlot,
size: 'medium'
},
html`
<div
part="base"
class=${classMap({
range: true,
'range--disabled': this.disabled,
'range--focused': this.hasFocus,
'range--tooltip-visible': this.hasTooltip,
'range--tooltip-top': this.tooltip === 'top',
'range--tooltip-bottom': this.tooltip === 'bottom'
})}
@mousedown=${this.handleThumbDragStart}
@mouseup=${this.handleThumbDragEnd}
@touchstart=${this.handleThumbDragStart}
@touchend=${this.handleThumbDragEnd}
>
<input
part="input"
type="range"
class="range__control"
name=${ifDefined(this.name)}
?disabled=${this.disabled}
min=${ifDefined(this.min)}
max=${ifDefined(this.max)}
step=${ifDefined(this.step)}
.value=${live(this.value.toString())}
aria-labelledby=${ifDefined(
getLabelledBy({
label: this.label,
labelId: this.labelId,
hasLabelSlot,
helpText: this.helpText,
helpTextId: this.helpTextId,
hasHelpTextSlot
})
)}
@input=${this.handleInput}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
/>
${this.tooltip !== 'none' && !this.disabled
? html`
<output part="tooltip" class="range__tooltip">
${typeof this.tooltipFormatter === 'function' ? this.tooltipFormatter(this.value) : this.value}
</output>
`
: ''}
return html`
<div
part="form-control"
class=${classMap({
'form-control': true,
'form-control--medium': true, // range only has one size
'form-control--has-label': hasLabel,
'form-control--has-help-text': hasHelpText
})}
>
<label part="label" class="form-control__label" for="input" aria-hidden=${hasLabel ? 'false' : 'true'}>
<slot name="label">${this.label}</slot>
</label>
<div class="form-control__input">
<div
part="base"
class=${classMap({
range: true,
'range--disabled': this.disabled,
'range--focused': this.hasFocus,
'range--tooltip-visible': this.hasTooltip,
'range--tooltip-top': this.tooltip === 'top',
'range--tooltip-bottom': this.tooltip === 'bottom'
})}
@mousedown=${this.handleThumbDragStart}
@mouseup=${this.handleThumbDragEnd}
@touchstart=${this.handleThumbDragStart}
@touchend=${this.handleThumbDragEnd}
>
<input
part="input"
id="input"
type="range"
class="range__control"
name=${ifDefined(this.name)}
?disabled=${this.disabled}
min=${ifDefined(this.min)}
max=${ifDefined(this.max)}
step=${ifDefined(this.step)}
.value=${live(this.value.toString())}
aria-describedby="help-text"
@input=${this.handleInput}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
/>
${this.tooltip !== 'none' && !this.disabled
? html`
<output part="tooltip" class="range__tooltip">
${typeof this.tooltipFormatter === 'function' ? this.tooltipFormatter(this.value) : this.value}
</output>
`
: ''}
</div>
</div>
`
);
<div
part="help-text"
id="help-text"
class="form-control__help-text"
aria-hidden=${hasHelpText ? 'false' : 'true'}
>
<slot name="help-text">${this.helpText}</slot>
</div>
</div>
`;
}
}

Wyświetl plik

@ -3,6 +3,47 @@ import sinon from 'sinon';
import type SlSelect from './select';
describe('<sl-select>', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<SlSelect>(html`
<sl-select>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
</sl-select>
`);
await expect(el).to.be.accessible();
});
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<SlSelect>(html`
<sl-select disabled>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
</sl-select>
`);
expect(el.dropdown.disabled).to.be.true;
expect(el.control.tabIndex).to.equal(-1);
});
it('should focus the select when clicking on the label', async () => {
const el = await fixture<SlSelect>(html`
<sl-select label="Select One">
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
</sl-select>
`);
const label = el.shadowRoot!.querySelector('[part="label"]')!;
const submitHandler = sinon.spy();
el.addEventListener('sl-focus', submitHandler);
(label as HTMLLabelElement).click();
await waitUntil(() => submitHandler.calledOnce);
expect(submitHandler).to.have.been.calledOnce;
});
it('should emit sl-change when the value changes', async () => {
const el = await fixture<SlSelect>(html`
<sl-select>
@ -21,7 +62,7 @@ describe('<sl-select>', () => {
});
it('should open the menu when any letter key is pressed with sl-select is on focus', async () => {
const el = await fixture(html`
const el = await fixture<SlSelect>(html`
<sl-select>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
@ -37,7 +78,7 @@ describe('<sl-select>', () => {
});
it('should not open the menu when ctrl + R is pressed with sl-select is on focus', async () => {
const el = await fixture(html`
const el = await fixture<SlSelect>(html`
<sl-select>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
@ -51,4 +92,24 @@ describe('<sl-select>', () => {
await aTimeout(100);
expect(control.getAttribute('aria-expanded')).to.equal('false');
});
//
// Constraint validation tests
//
it('should focus on the custom control when constraint validation occurs', async () => {
const el = await fixture<HTMLFormElement>(html`
<form>
<sl-select required>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
</sl-select>
</form>
`);
const select = el.querySelector('sl-select')!;
el.requestSubmit();
expect(select.shadowRoot!.activeElement).to.equal(select.control);
});
});

Wyświetl plik

@ -1,7 +1,6 @@
import { html, LitElement } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import '~/components/dropdown/dropdown';
import type SlDropdown from '~/components/dropdown/dropdown';
import '~/components/icon-button/icon-button';
@ -11,9 +10,8 @@ import type SlMenuItem from '~/components/menu-item/menu-item';
import type SlMenu from '~/components/menu/menu';
import type { MenuSelectEventDetail } from '~/components/menu/menu';
import '~/components/tag/tag';
import { autoIncrement } from '~/internal/auto-increment';
import { emit } from '~/internal/event';
import { FormSubmitController, getLabelledBy, renderFormControl } from '~/internal/form-control';
import { FormSubmitController } from '~/internal/form-control';
import { getTextContent, HasSlotController } from '~/internal/slot';
import { watch } from '~/internal/watch';
import styles from './select.styles';
@ -70,11 +68,6 @@ export default class SlSelect extends LitElement {
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this);
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private readonly attrId = autoIncrement();
private readonly inputId = `select-${this.attrId}`;
private readonly helpTextId = `select-help-text-${this.attrId}`;
private readonly labelId = `select-label-${this.attrId}`;
private readonly menuId = `select-menu-${this.attrId}`;
private resizeObserver: ResizeObserver;
@state() private hasFocus = false;
@ -176,7 +169,7 @@ export default class SlSelect extends LitElement {
}
getItems() {
return [...this.querySelectorAll('sl-menu-item')] as SlMenuItem[];
return [...this.querySelectorAll<SlMenuItem>('sl-menu-item')];
}
getValueAsArray() {
@ -440,119 +433,134 @@ export default class SlSelect extends LitElement {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');
const hasSelection = this.multiple ? this.value.length > 0 : this.value !== '';
const hasLabel = this.label ? true : !!hasLabelSlot;
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
return renderFormControl(
{
inputId: this.inputId,
label: this.label,
labelId: this.labelId,
hasLabelSlot,
helpTextId: this.helpTextId,
helpText: this.helpText,
hasHelpTextSlot,
size: this.size,
onLabelClick: () => this.handleLabelClick()
},
html`
<sl-dropdown
part="base"
.hoist=${this.hoist}
.placement=${this.placement}
.stayOpenOnSelect=${this.multiple}
.containingElement=${this as HTMLElement}
?disabled=${this.disabled}
class=${classMap({
select: true,
'select--open': this.isOpen,
'select--empty': this.value.length === 0,
'select--focused': this.hasFocus,
'select--clearable': this.clearable,
'select--disabled': this.disabled,
'select--multiple': this.multiple,
'select--standard': !this.filled,
'select--filled': this.filled,
'select--has-tags': this.multiple && this.displayTags.length > 0,
'select--placeholder-visible': this.displayLabel === '',
'select--small': this.size === 'small',
'select--medium': this.size === 'medium',
'select--large': this.size === 'large',
'select--pill': this.pill,
'select--invalid': this.invalid
})}
@sl-show=${this.handleMenuShow}
@sl-hide=${this.handleMenuHide}
return html`
<div
part="form-control"
class=${classMap({
'form-control': true,
'form-control--small': this.size === 'small',
'form-control--medium': this.size === 'medium',
'form-control--large': this.size === 'large',
'form-control--has-label': hasLabel,
'form-control--has-help-text': hasHelpText
})}
>
<label
part="label"
class="form-control__label"
for="input"
aria-hidden=${hasLabel ? 'false' : 'true'}
@click=${this.handleLabelClick}
>
<div
part="control"
slot="trigger"
id=${this.inputId}
class="select__control"
role="combobox"
aria-labelledby=${ifDefined(
getLabelledBy({
label: this.label,
labelId: this.labelId,
hasLabelSlot,
helpText: this.helpText,
helpTextId: this.helpTextId,
hasHelpTextSlot
})
)}
aria-haspopup="true"
aria-expanded=${this.isOpen ? 'true' : 'false'}
aria-controls=${this.menuId}
tabindex=${this.disabled ? '-1' : '0'}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@keydown=${this.handleKeyDown}
>
<span part="prefix" class="select__prefix">
<slot name="prefix"></slot>
</span>
<slot name="label">${this.label}</slot>
</label>
<div part="display-label" class="select__label">
${this.displayTags.length > 0
? html` <span part="tags" class="select__tags"> ${this.displayTags} </span> `
: this.displayLabel.length > 0
? this.displayLabel
: this.placeholder}
<div class="form-control__input">
<sl-dropdown
part="base"
.hoist=${this.hoist}
.placement=${this.placement}
.stayOpenOnSelect=${this.multiple}
.containingElement=${this as HTMLElement}
?disabled=${this.disabled}
class=${classMap({
select: true,
'select--open': this.isOpen,
'select--empty': this.value.length === 0,
'select--focused': this.hasFocus,
'select--clearable': this.clearable,
'select--disabled': this.disabled,
'select--multiple': this.multiple,
'select--standard': !this.filled,
'select--filled': this.filled,
'select--has-tags': this.multiple && this.displayTags.length > 0,
'select--placeholder-visible': this.displayLabel === '',
'select--small': this.size === 'small',
'select--medium': this.size === 'medium',
'select--large': this.size === 'large',
'select--pill': this.pill,
'select--invalid': this.invalid
})}
@sl-show=${this.handleMenuShow}
@sl-hide=${this.handleMenuHide}
>
<div
part="control"
slot="trigger"
id="input"
class="select__control"
role="combobox"
aria-describedby="help-text"
aria-haspopup="true"
aria-expanded=${this.isOpen ? 'true' : 'false'}
aria-controls="menu"
tabindex=${this.disabled ? '-1' : '0'}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@keydown=${this.handleKeyDown}
>
<span part="prefix" class="select__prefix">
<slot name="prefix"></slot>
</span>
<div part="display-label" class="select__label">
${this.displayTags.length > 0
? html` <span part="tags" class="select__tags"> ${this.displayTags} </span> `
: this.displayLabel.length > 0
? this.displayLabel
: this.placeholder}
</div>
${this.clearable && hasSelection
? html`
<button part="clear-button" class="select__clear" @click=${this.handleClearClick} tabindex="-1">
<slot name="clear-icon">
<sl-icon name="x-circle-fill" library="system"></sl-icon>
</slot>
</button>
`
: ''}
<span part="suffix" class="select__suffix">
<slot name="suffix"></slot>
</span>
<span part="icon" class="select__icon" aria-hidden="true">
<sl-icon name="chevron-down" library="system"></sl-icon>
</span>
<!-- The hidden input tricks the browser's built-in validation so it works as expected. We use an input
instead of a select because, otherwise, iOS will show a list of options during validation. The focus
handler is used to move focus to the primary control when it's marked invalid. -->
<input
class="select__hidden-select"
aria-hidden="true"
?required=${this.required}
.value=${hasSelection ? '1' : ''}
tabindex="-1"
@focus=${() => this.control.focus()}
/>
</div>
${this.clearable && hasSelection
? html`
<button part="clear-button" class="select__clear" @click=${this.handleClearClick} tabindex="-1">
<slot name="clear-icon">
<sl-icon name="x-circle-fill" library="system"></sl-icon>
</slot>
</button>
`
: ''}
<sl-menu part="menu" id="menu" class="select__menu" @sl-select=${this.handleMenuSelect}>
<slot @slotchange=${this.handleMenuSlotChange}></slot>
</sl-menu>
</sl-dropdown>
</div>
<span part="suffix" class="select__suffix">
<slot name="suffix"></slot>
</span>
<span part="icon" class="select__icon" aria-hidden="true">
<sl-icon name="chevron-down" library="system"></sl-icon>
</span>
<!-- The hidden input tricks the browser's built-in validation so it works as expected. We use an input
instead of a select because, otherwise, iOS will show a list of options during validation. -->
<input
class="select__hidden-select"
aria-hidden="true"
?required=${this.required}
.value=${hasSelection ? '1' : ''}
tabindex="-1"
/>
</div>
<sl-menu part="menu" class="select__menu" @sl-select=${this.handleMenuSelect} id=${this.menuId}>
<slot @slotchange=${this.handleMenuSlotChange}></slot>
</sl-menu>
</sl-dropdown>
`
);
<div
part="help-text"
id="help-text"
class="form-control__help-text"
aria-hidden=${hasHelpText ? 'false' : 'true'}
>
<slot name="help-text">${this.helpText}</slot>
</div>
</div>
`;
}
}

Wyświetl plik

@ -9,7 +9,7 @@ describe('<sl-spinner>', () => {
el = await fixture<SlSpinner>(html` <sl-spinner></sl-spinner> `);
});
it('should render a component that passes accessibility test.', async () => {
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});

Wyświetl plik

@ -3,7 +3,6 @@ import { customElement, property, query, state } 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 { autoIncrement } from '~/internal/auto-increment';
import { emit } from '~/internal/event';
import { FormSubmitController } from '~/internal/form-control';
import { watch } from '~/internal/watch';
@ -38,9 +37,6 @@ export default class SlSwitch extends LitElement {
private readonly formSubmitController = new FormSubmitController(this, {
value: (control: SlSwitch) => (control.checked ? control.value : undefined)
});
private readonly attrId = autoIncrement();
private readonly switchId = `switch-${this.attrId}`;
private readonly labelId = `switch-label-${this.attrId}`;
@state() private hasFocus = false;
@ -138,7 +134,6 @@ export default class SlSwitch extends LitElement {
return html`
<label
part="base"
for=${this.switchId}
class=${classMap({
switch: true,
'switch--checked': this.checked,
@ -147,7 +142,6 @@ export default class SlSwitch extends LitElement {
})}
>
<input
id=${this.switchId}
class="switch__input"
type="checkbox"
name=${ifDefined(this.name)}
@ -157,7 +151,6 @@ export default class SlSwitch extends LitElement {
.required=${this.required}
role="switch"
aria-checked=${this.checked ? 'true' : 'false'}
aria-labelledby=${this.labelId}
@click=${this.handleClick}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@ -168,7 +161,7 @@ export default class SlSwitch extends LitElement {
<span part="thumb" class="switch__thumb"></span>
</span>
<span part="label" id=${this.labelId} class="switch__label">
<span part="label" class="switch__label">
<slot></slot>
</span>
</label>

Wyświetl plik

@ -1,7 +1,15 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
// eslint-disable-next-line no-restricted-imports
import { serialize } from '../../utilities/form';
import type SlTextarea from './textarea';
describe('<sl-textarea>', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<SlTextarea>(html` <sl-textarea label="Name"></sl-textarea> `);
await expect(el).to.be.accessible();
});
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<SlTextarea>(html` <sl-textarea disabled></sl-textarea> `);
const textarea = el.shadowRoot!.querySelector<HTMLTextAreaElement>('[part="textarea"]')!;
@ -9,6 +17,22 @@ describe('<sl-textarea>', () => {
expect(textarea.disabled).to.be.true;
});
it('should focus the textarea when clicking on the label', async () => {
const el = await fixture<SlTextarea>(html` <sl-textarea label="Name"></sl-textarea> `);
const label = el.shadowRoot!.querySelector('[part="label"]')!;
const submitHandler = sinon.spy();
el.addEventListener('sl-focus', submitHandler);
(label as HTMLLabelElement).click();
await waitUntil(() => submitHandler.calledOnce);
expect(submitHandler).to.have.been.calledOnce;
});
//
// Constraint validation tests
//
it('should be valid by default', async () => {
const el = await fixture<SlTextarea>(html` <sl-textarea></sl-textarea> `);
@ -29,4 +53,23 @@ describe('<sl-textarea>', () => {
expect(el.invalid).to.be.true;
});
it('should be invalid when required and disabled is removed', async () => {
const el = await fixture<SlTextarea>(html` <sl-textarea disabled required></sl-textarea> `);
el.disabled = false;
await el.updateComplete;
expect(el.invalid).to.be.true;
});
it('should serialize its name and value with FormData', async () => {
const form = await fixture<HTMLFormElement>(html` <form><sl-textarea name="a" value="1"></sl-textarea></form> `);
const formData = new FormData(form);
expect(formData.get('a')).to.equal('1');
});
it('should serialize its name and value with JSON', async () => {
const form = await fixture<HTMLFormElement>(html` <form><sl-textarea name="a" value="1"></sl-textarea></form> `);
const json = serialize(form);
expect(json.a).to.equal('1');
});
});

Wyświetl plik

@ -3,9 +3,8 @@ import { customElement, property, query, state } 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 { autoIncrement } from '~/internal/auto-increment';
import { emit } from '~/internal/event';
import { FormSubmitController, getLabelledBy, renderFormControl } from '~/internal/form-control';
import { FormSubmitController } from '~/internal/form-control';
import { HasSlotController } from '~/internal/slot';
import { watch } from '~/internal/watch';
import styles from './textarea.styles';
@ -37,10 +36,6 @@ export default class SlTextarea extends LitElement {
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this);
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private readonly attrId = autoIncrement();
private readonly inputId = `textarea-${this.attrId}`;
private readonly helpTextId = `textarea-help-text-${this.attrId}`;
private readonly labelId = `textarea-label-${this.attrId}`;
private resizeObserver: ResizeObserver;
@state() private hasFocus = false;
@ -84,9 +79,6 @@ export default class SlTextarea extends LitElement {
/** The maximum length of input that will be considered valid. */
@property({ type: Number }) maxlength: number;
/** A pattern to validate input against. */
@property() pattern: string;
/** Makes the textarea a required field. */
@property({ type: Boolean, reflect: true }) required = false;
@ -256,73 +248,81 @@ export default class SlTextarea extends LitElement {
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');
const hasLabel = this.label ? true : !!hasLabelSlot;
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
return renderFormControl(
{
inputId: this.inputId,
label: this.label,
labelId: this.labelId,
hasLabelSlot,
helpTextId: this.helpTextId,
helpText: this.helpText,
hasHelpTextSlot,
size: this.size
},
html`
<div
part="base"
class=${classMap({
textarea: true,
'textarea--small': this.size === 'small',
'textarea--medium': this.size === 'medium',
'textarea--large': this.size === 'large',
'textarea--standard': !this.filled,
'textarea--filled': this.filled,
'textarea--disabled': this.disabled,
'textarea--focused': this.hasFocus,
'textarea--empty': this.value.length === 0,
'textarea--invalid': this.invalid,
'textarea--resize-none': this.resize === 'none',
'textarea--resize-vertical': this.resize === 'vertical',
'textarea--resize-auto': this.resize === 'auto'
})}
>
<textarea
part="textarea"
id=${this.inputId}
class="textarea__control"
name=${ifDefined(this.name)}
.value=${live(this.value)}
?disabled=${this.disabled}
?readonly=${this.readonly}
?required=${this.required}
placeholder=${ifDefined(this.placeholder)}
rows=${ifDefined(this.rows)}
minlength=${ifDefined(this.minlength)}
maxlength=${ifDefined(this.maxlength)}
autocapitalize=${ifDefined(this.autocapitalize)}
autocorrect=${ifDefined(this.autocorrect)}
?autofocus=${this.autofocus}
spellcheck=${ifDefined(this.spellcheck)}
inputmode=${ifDefined(this.inputmode)}
aria-labelledby=${ifDefined(
getLabelledBy({
label: this.label,
labelId: this.labelId,
hasLabelSlot,
helpText: this.helpText,
helpTextId: this.helpTextId,
hasHelpTextSlot
})
)}
@change=${this.handleChange}
@input=${this.handleInput}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
></textarea>
return html`
<div
part="form-control"
class=${classMap({
'form-control': true,
'form-control--small': this.size === 'small',
'form-control--medium': this.size === 'medium',
'form-control--large': this.size === 'large',
'form-control--has-label': hasLabel,
'form-control--has-help-text': hasHelpText
})}
>
<label part="label" class="form-control__label" for="input" aria-hidden=${hasLabel ? 'false' : 'true'}>
<slot name="label">${this.label}</slot>
</label>
<div class="form-control__input">
<div
part="base"
class=${classMap({
textarea: true,
'textarea--small': this.size === 'small',
'textarea--medium': this.size === 'medium',
'textarea--large': this.size === 'large',
'textarea--standard': !this.filled,
'textarea--filled': this.filled,
'textarea--disabled': this.disabled,
'textarea--focused': this.hasFocus,
'textarea--empty': this.value.length === 0,
'textarea--invalid': this.invalid,
'textarea--resize-none': this.resize === 'none',
'textarea--resize-vertical': this.resize === 'vertical',
'textarea--resize-auto': this.resize === 'auto'
})}
>
<textarea
part="textarea"
id="input"
class="textarea__control"
name=${ifDefined(this.name)}
.value=${live(this.value)}
?disabled=${this.disabled}
?readonly=${this.readonly}
?required=${this.required}
placeholder=${ifDefined(this.placeholder)}
rows=${ifDefined(this.rows)}
minlength=${ifDefined(this.minlength)}
maxlength=${ifDefined(this.maxlength)}
autocapitalize=${ifDefined(this.autocapitalize)}
autocorrect=${ifDefined(this.autocorrect)}
?autofocus=${this.autofocus}
spellcheck=${ifDefined(this.spellcheck)}
inputmode=${ifDefined(this.inputmode)}
aria-describedby="help-text"
@change=${this.handleChange}
@input=${this.handleInput}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
></textarea>
</div>
</div>
`
);
<div
part="help-text"
id="help-text"
class="form-control__help-text"
aria-hidden=${hasHelpText ? 'false' : 'true'}
>
<slot name="help-text">${this.helpText}</slot>
</div>
</div>
`;
}
}

Wyświetl plik

@ -1,9 +1,6 @@
import { html } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import type SlButton from '~/components/button/button';
import './formdata-event-polyfill';
import type { ReactiveController, ReactiveControllerHost, TemplateResult } from 'lit';
import type { ReactiveController, ReactiveControllerHost } from 'lit';
export interface FormSubmitControllerOptions {
/** A function that returns the form containing the form control. */
@ -113,101 +110,3 @@ export class FormSubmitController implements ReactiveController {
}
}
}
export function renderFormControl(
props: {
/** The input id, used to map the input to the label */
inputId: string;
/** The size of the form control */
size: 'small' | 'medium' | 'large';
/** The label id, used to map the label to the input */
labelId?: string;
/** The label text (if the label slot isn't used) */
label?: string;
/** Whether or not a label slot has been provided. */
hasLabelSlot?: boolean;
/** The help text id, used to map the input to the help text */
helpTextId?: string;
/** The help text (if the help-text slot isn't used) */
helpText?: string;
/** Whether or not a help text slot has been provided. */
hasHelpTextSlot?: boolean;
/** A function that gets called when the label is clicked. */
onLabelClick?: (event: MouseEvent) => void;
},
input: TemplateResult
) {
const hasLabel = props.label ? true : !!props.hasLabelSlot;
const hasHelpText = props.helpText ? true : !!props.hasHelpTextSlot;
return html`
<div
part="form-control"
class=${classMap({
'form-control': true,
'form-control--small': props.size === 'small',
'form-control--medium': props.size === 'medium',
'form-control--large': props.size === 'large',
'form-control--has-label': hasLabel,
'form-control--has-help-text': hasHelpText
})}
>
<label
part="label"
id=${ifDefined(props.labelId)}
class="form-control__label"
for=${props.inputId}
aria-hidden=${hasLabel ? 'false' : 'true'}
@click=${(event: MouseEvent) => props.onLabelClick?.(event)}
>
<slot name="label">${props.label}</slot>
</label>
<div class="form-control__input">${html`${input}`}</div>
<div
part="help-text"
id=${ifDefined(props.helpTextId)}
class="form-control__help-text"
aria-hidden=${hasHelpText ? 'false' : 'true'}
>
<slot name="help-text">${props.helpText}</slot>
</div>
</div>
`;
}
export function getLabelledBy(props: {
/** The label id, used to map the label to the input */
labelId: string;
/** The label text (if the label slot isn't used) */
label: string;
/** Whether or not a label slot has been provided. */
hasLabelSlot: boolean;
/** The help text id, used to map the input to the help text */
helpTextId: string;
/** The help text (if the help-text slot isn't used) */
helpText: string;
/** Whether or not a help text slot has been provided. */
hasHelpTextSlot: boolean;
}) {
const labelledBy = [
props.label.length > 0 ? props.label : props.hasLabelSlot ? props.labelId : '',
props.helpText.length > 0 ? props.helpText : props.hasHelpTextSlot ? props.helpTextId : ''
].filter(val => val !== '');
return labelledBy.join(' ');
}

Wyświetl plik

@ -7,7 +7,12 @@ export default {
files: 'src/**/*.test.ts',
concurrentBrowsers: 3,
nodeResolve: true,
plugins: [esbuildPlugin({ ts: true, target: 'auto' })],
plugins: [
esbuildPlugin({
ts: true,
target: 'auto'
})
],
browsers: [
playwrightLauncher({ product: 'chromium' }),
playwrightLauncher({ product: 'firefox' }),