fix(combobox): add support for disabled and readonly states
* fix(combobox): don't use setAttribute for disabled/enabled attributes How to reproduce this bug: 1. Go to https://lion.js.org/components/combobox/overview/ or just use the combobox in your application. 2. Inspect the combobox in the browser. Click on the `<lion-combobox>` element at the DOM inspector. 3. At the JavaScript console, print `$0.disabled`. Observe it is currently `false`, and the user can interact with it normally. 4. (optional) Run `$0.disabled =true`. Observe the user cannot interact with it anymore. That's expected. 5. Run `$0.disabled = false`. BUG: The user cannot interact with the combobox anymore, despite `disabled` being false. Root cause: If you inspect the `<input>` element, you can see it has `disabled="false"`. The [specs](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#enabling-and-disabling-form-controls:-the-disabled-attribute) say: > The `disabled` content attribute is a [boolean attribute](https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attribute). > > A form control is **disabled** if any of the following are true: > * the element is a `button`, `input`, `select`, `textarea`, or form-associated custom element, and the `disabled` attribute is specified on this element (regardless of its value); And [also](https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attribute): > A number of attributes are **boolean attributes**. The presence of a boolean attribute on an element represents the true value, and the absence of the attribute represents the false value. Thanks to @thematho for finding the root cause. * fix(combobox): add support for disabled and readonly states with corresponding tests * Update packages/ui/components/combobox/src/LionCombobox.js Co-authored-by: Thijs Louisse <t_louisse@hotmail.com> * Update packages/ui/components/combobox/src/LionCombobox.js Co-authored-by: Thijs Louisse <t_louisse@hotmail.com> * Update packages/ui/components/combobox/src/LionCombobox.js Co-authored-by: Thijs Louisse <t_louisse@hotmail.com> * fix(combobox): added accessibility tests --------- Co-authored-by: Denilson Sá Maia <denilsonsa@gmail.com> Co-authored-by: Thijs Louisse <t_louisse@hotmail.com>
This commit is contained in:
parent
9a80ba9c55
commit
765a1a298c
5 changed files with 256 additions and 9 deletions
5
.changeset/sour-candles-worry.md
Normal file
5
.changeset/sour-candles-worry.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@lion/ui': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fixed disabled and readonly attribute handling for lion-combobox
|
||||||
|
|
@ -29,6 +29,7 @@ import { LitElement, html, repeat } from '@mdjs/mdjs-preview';
|
||||||
import { listboxData, listboxComplexData } from '../listbox/src/listboxData.js';
|
import { listboxData, listboxComplexData } from '../listbox/src/listboxData.js';
|
||||||
import { LionCombobox } from '@lion/ui/combobox.js';
|
import { LionCombobox } from '@lion/ui/combobox.js';
|
||||||
import { Required } from '@lion/ui/form-core.js';
|
import { Required } from '@lion/ui/form-core.js';
|
||||||
|
import '@lion/ui/define/lion-button.js';
|
||||||
import '@lion/ui/define/lion-combobox.js';
|
import '@lion/ui/define/lion-combobox.js';
|
||||||
import '@lion/ui/define/lion-option.js';
|
import '@lion/ui/define/lion-option.js';
|
||||||
import './src/demo-selection-display.js';
|
import './src/demo-selection-display.js';
|
||||||
|
|
@ -176,6 +177,94 @@ export const customMatchCondition = () => html`
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Disabled option
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
class DemoDisabledState extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return { disabled: { type: Boolean } };
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
/** @type {string[]} */
|
||||||
|
this.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get combobox() {
|
||||||
|
return /** @type {LionCombobox} */ (this.shadowRoot?.querySelector('#combobox'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {InputEvent & {target: HTMLInputElement}} e
|
||||||
|
*/
|
||||||
|
toggleDisabled(e) {
|
||||||
|
this.disabled = !this.disabled;
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<lion-button @click=${this.toggleDisabled}>Toggle disabled</lion-button> Disabled state:
|
||||||
|
${this.disabled}
|
||||||
|
<lion-combobox name="search" label="Search" ?disabled=${this.disabled}>
|
||||||
|
${lazyRender(
|
||||||
|
listboxData.map(
|
||||||
|
entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</lion-combobox>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('demo-disabled-state', DemoDisabledState);
|
||||||
|
export const disabledState = () => html`<demo-disabled-state></demo-disabled-state>`;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Readonly option
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
class DemoReadonlyState extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return { readOnly: { type: Boolean } };
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
/** @type {string[]} */
|
||||||
|
this.readOnly = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get combobox() {
|
||||||
|
return /** @type {LionCombobox} */ (this.shadowRoot?.querySelector('#combobox'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {InputEvent & {target: HTMLInputElement}} e
|
||||||
|
*/
|
||||||
|
toggleReadonly(e) {
|
||||||
|
this.readOnly = !this.readOnly;
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<lion-button @click=${this.toggleReadonly}>Toggle readonly</lion-button> ReadOnly state:
|
||||||
|
${this.readOnly}
|
||||||
|
<lion-combobox name="search" label="Search" ?readOnly=${this.readOnly}>
|
||||||
|
${lazyRender(
|
||||||
|
listboxData.map(
|
||||||
|
entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</lion-combobox>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('demo-readonly-state', DemoReadonlyState);
|
||||||
|
export const readonlyState = () => html`<demo-readonly-state></demo-readonly-state>`;
|
||||||
|
```
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
```js preview-story
|
```js preview-story
|
||||||
|
|
|
||||||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -28965,7 +28965,7 @@
|
||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@lion/ui",
|
"name": "@lion/ui",
|
||||||
"version": "0.11.2",
|
"version": "0.11.6",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bundled-es-modules/message-format": "^6.2.4",
|
"@bundled-es-modules/message-format": "^6.2.4",
|
||||||
|
|
|
||||||
|
|
@ -502,6 +502,10 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
|
||||||
if (this._selectionDisplayNode) {
|
if (this._selectionDisplayNode) {
|
||||||
this._selectionDisplayNode.comboboxElement = this;
|
this._selectionDisplayNode.comboboxElement = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.disabled || this.readOnly) {
|
||||||
|
this.__setComboboxDisabledAndReadOnly();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -657,9 +661,11 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
|
||||||
const alwaysHideOn = ['Tab', 'Escape'];
|
const alwaysHideOn = ['Tab', 'Escape'];
|
||||||
const notMultipleChoiceHideOn = ['Enter'];
|
const notMultipleChoiceHideOn = ['Enter'];
|
||||||
if (
|
if (
|
||||||
lastKey &&
|
this.disabled ||
|
||||||
(alwaysHideOn.includes(lastKey) ||
|
this.readOnly ||
|
||||||
(!this.multipleChoice && notMultipleChoiceHideOn.includes(lastKey)))
|
(lastKey &&
|
||||||
|
(alwaysHideOn.includes(lastKey) ||
|
||||||
|
(!this.multipleChoice && notMultipleChoiceHideOn.includes(lastKey))))
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -1213,8 +1219,22 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
|
||||||
*/
|
*/
|
||||||
__setComboboxDisabledAndReadOnly() {
|
__setComboboxDisabledAndReadOnly() {
|
||||||
if (this._comboboxNode) {
|
if (this._comboboxNode) {
|
||||||
this._comboboxNode.setAttribute('disabled', `${this.disabled}`);
|
// Since `._comboboxNode` can either be `<div "role=combobox">` or `<input>`,
|
||||||
this._comboboxNode.setAttribute('readonly', `${this.readOnly}`);
|
// we need to cater for both scenarios (aria for semantics, "native attr" for operability)
|
||||||
|
this._comboboxNode.toggleAttribute('disabled', this.disabled);
|
||||||
|
this._comboboxNode.setAttribute('aria-disabled', `${this.disabled}`);
|
||||||
|
this._comboboxNode.toggleAttribute('readonly', this.readOnly);
|
||||||
|
this._comboboxNode.setAttribute('aria-readonly', `${this.readOnly}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._inputNode) {
|
||||||
|
// N.B. in case ._inputNode === ._comboboxNode (we have <input role="combobox">)
|
||||||
|
// this value has already been set above. This is fine, as a toggle with boolean flag is idempotent.
|
||||||
|
this._inputNode.toggleAttribute('disabled', this.disabled);
|
||||||
|
this._inputNode.toggleAttribute('readOnly', this.readOnly);
|
||||||
|
|
||||||
|
this._inputNode.setAttribute('aria-readonly', `${this.readOnly}`);
|
||||||
|
this._inputNode.tabIndex = this.disabled ? -1 : 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,12 @@ import { isActiveElement } from '../../core/test-helpers/isActiveElement.js';
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ autocomplete?:'none'|'list'|'both', matchMode?:'begin'|'all' }} config
|
* @param {{ autocomplete?:'none'|'list'|'both', matchMode?:'begin'|'all', disabled?: boolean, readonly?: boolean }} config
|
||||||
*/
|
*/
|
||||||
async function fruitFixture({ autocomplete, matchMode } = {}) {
|
async function fruitFixture({ autocomplete, matchMode, disabled, readonly } = {}) {
|
||||||
const el = /** @type {LionCombobox} */ (
|
const el = /** @type {LionCombobox} */ (
|
||||||
await fixture(html`
|
await fixture(html`
|
||||||
<lion-combobox name="foo">
|
<lion-combobox label="Search" name="foo">
|
||||||
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
|
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
|
||||||
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
|
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
|
||||||
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
|
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
|
||||||
|
|
@ -43,6 +43,12 @@ async function fruitFixture({ autocomplete, matchMode } = {}) {
|
||||||
if (matchMode) {
|
if (matchMode) {
|
||||||
el.matchMode = matchMode;
|
el.matchMode = matchMode;
|
||||||
}
|
}
|
||||||
|
if (disabled) {
|
||||||
|
el.disabled = disabled;
|
||||||
|
}
|
||||||
|
if (readonly) {
|
||||||
|
el.readOnly = readonly;
|
||||||
|
}
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
return [el, el.formElements];
|
return [el, el.formElements];
|
||||||
}
|
}
|
||||||
|
|
@ -3431,4 +3437,131 @@ describe('lion-combobox', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Disabled state', () => {
|
||||||
|
it('does not open overlay or allow input when disabled', async () => {
|
||||||
|
const [el] = await fruitFixture({ disabled: true });
|
||||||
|
expect(el.disabled).to.equal(true);
|
||||||
|
|
||||||
|
const { _inputNode } = getComboboxMembers(/** @type {LionCombobox} */ (el));
|
||||||
|
|
||||||
|
expect(el.disabled).to.be.true;
|
||||||
|
expect(_inputNode.disabled).to.be.true;
|
||||||
|
|
||||||
|
// Try to open overlay by clicking input
|
||||||
|
_inputNode.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(el.opened).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does open overlay or allow input when disabled state is removed after it was previously disabled', async () => {
|
||||||
|
const [el] = await fruitFixture({ disabled: true });
|
||||||
|
expect(el.disabled).to.equal(true);
|
||||||
|
|
||||||
|
const { _inputNode } = getComboboxMembers(/** @type {LionCombobox} */ (el));
|
||||||
|
|
||||||
|
expect(el.disabled).to.be.true;
|
||||||
|
expect(_inputNode.disabled).to.be.true;
|
||||||
|
|
||||||
|
// Try to open overlay by clicking input
|
||||||
|
_inputNode.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(el.opened).to.be.false;
|
||||||
|
|
||||||
|
el.disabled = false;
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(el.disabled).to.be.false;
|
||||||
|
expect(_inputNode.disabled).to.be.false;
|
||||||
|
|
||||||
|
// Try to open overlay by clicking input
|
||||||
|
async function open() {
|
||||||
|
await mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch');
|
||||||
|
return el.updateComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
await open();
|
||||||
|
expect(el.opened).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-disabled and disables focus when combobox is disabled', async () => {
|
||||||
|
const [el] = await fruitFixture({ disabled: true });
|
||||||
|
const { _comboboxNode, _inputNode } = getComboboxMembers(/** @type {LionCombobox} */ (el));
|
||||||
|
|
||||||
|
// input should not be focusable
|
||||||
|
expect(_comboboxNode.tabIndex).to.equal(-1);
|
||||||
|
expect(_inputNode.tabIndex).to.equal(-1);
|
||||||
|
|
||||||
|
// aria-disabled should be set
|
||||||
|
expect(el.getAttribute('aria-disabled')).to.equal('true');
|
||||||
|
|
||||||
|
// aria-disabled should be set
|
||||||
|
expect(_inputNode.getAttribute('aria-disabled')).to.equal('true');
|
||||||
|
|
||||||
|
await expect(el).to.be.accessible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ensure focus when combobox is readonly', async () => {
|
||||||
|
const [el] = await fruitFixture({ readonly: true });
|
||||||
|
const { _comboboxNode, _inputNode } = getComboboxMembers(/** @type {LionCombobox} */ (el));
|
||||||
|
|
||||||
|
// input should be focusable
|
||||||
|
expect(_inputNode.tabIndex).to.equal(0);
|
||||||
|
|
||||||
|
// aria-disabled should be set
|
||||||
|
expect(_comboboxNode.getAttribute('aria-readonly')).to.equal('true');
|
||||||
|
|
||||||
|
// aria-disabled should be set
|
||||||
|
expect(_inputNode.getAttribute('aria-readonly')).to.equal('true');
|
||||||
|
|
||||||
|
await expect(el).to.be.accessible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Readonly state', () => {
|
||||||
|
it('does not open overlay or allow input when readonly', async () => {
|
||||||
|
const [el] = await fruitFixture({ readonly: true });
|
||||||
|
|
||||||
|
const { _inputNode } = getComboboxMembers(/** @type {LionCombobox} */ (el));
|
||||||
|
expect(el.readOnly).to.equal(true);
|
||||||
|
|
||||||
|
expect(el.readOnly).to.be.true;
|
||||||
|
expect(_inputNode.readOnly).to.be.true;
|
||||||
|
|
||||||
|
// Try to open overlay by clicking input
|
||||||
|
_inputNode.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(el.opened).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does open overlay or allow input when readonly state is removed after it was previously set', async () => {
|
||||||
|
const [el] = await fruitFixture({ readonly: true });
|
||||||
|
expect(el.readOnly).to.equal(true);
|
||||||
|
|
||||||
|
const { _inputNode } = getComboboxMembers(/** @type {LionCombobox} */ (el));
|
||||||
|
|
||||||
|
expect(el.readOnly).to.be.true;
|
||||||
|
expect(_inputNode.readOnly).to.be.true;
|
||||||
|
|
||||||
|
// Try to open overlay by clicking input
|
||||||
|
_inputNode.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(el.opened).to.be.false;
|
||||||
|
|
||||||
|
el.readOnly = false;
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(el.readOnly).to.be.false;
|
||||||
|
expect(_inputNode.readOnly).to.be.false;
|
||||||
|
|
||||||
|
// Try to open overlay by clicking input
|
||||||
|
async function open() {
|
||||||
|
await mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch');
|
||||||
|
return el.updateComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
await open();
|
||||||
|
expect(el.opened).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue