kopia lustrzana https://github.com/shoelace-style/shoelace
improve form controls a11y; add tests
rodzic
8ae987ea69
commit
3aa5fdba55
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(' ');
|
||||
}
|
||||
|
|
|
@ -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' }),
|
||||
|
|
Ładowanie…
Reference in New Issue