kopia lustrzana https://github.com/shoelace-style/shoelace
fix in screen readers
rodzic
a42b393bf1
commit
f28a0ec743
|
@ -3,7 +3,7 @@
|
||||||
[component-header:sl-select]
|
[component-header:sl-select]
|
||||||
|
|
||||||
```html preview
|
```html preview
|
||||||
<sl-select>
|
<sl-select value="option-1">
|
||||||
<sl-option value="option-1">Option 1</sl-option>
|
<sl-option value="option-1">Option 1</sl-option>
|
||||||
<sl-option value="option-2">Option 2</sl-option>
|
<sl-option value="option-2">Option 2</sl-option>
|
||||||
<sl-option value="option-3">Option 3</sl-option>
|
<sl-option value="option-3">Option 3</sl-option>
|
||||||
|
|
|
@ -9,6 +9,10 @@ export default css`
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:host(:focus) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.option {
|
.option {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -22,21 +26,20 @@ export default css`
|
||||||
padding: var(--sl-spacing-2x-small) var(--sl-spacing-2x-small);
|
padding: var(--sl-spacing-2x-small) var(--sl-spacing-2x-small);
|
||||||
transition: var(--sl-transition-fast) fill;
|
transition: var(--sl-transition-fast) fill;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
:host(:hover) .option {
|
:host(:hover) .option:not(.option--current) {
|
||||||
background-color: var(--sl-color-neutral-100);
|
background-color: var(--sl-color-neutral-100);
|
||||||
color: var(--sl-color-neutral-1000);
|
color: var(--sl-color-neutral-1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
:host([aria-selected='true']) .option {
|
.option--current {
|
||||||
background-color: var(--sl-color-primary-600);
|
background-color: var(--sl-color-primary-600);
|
||||||
color: var(--sl-color-neutral-0);
|
color: var(--sl-color-neutral-0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.option.option--disabled {
|
.option--disabled {
|
||||||
outline: none;
|
outline: none;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
@ -45,7 +48,20 @@ export default css`
|
||||||
.option__label {
|
.option__label {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0 var(--sl-spacing-large);
|
padding: 0 var(--sl-spacing-2x-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option .option__check {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5em;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option--selected .option__check {
|
||||||
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option__prefix {
|
.option__prefix {
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import { html } from 'lit';
|
import { html } from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
import ShoelaceElement from '../../internal/shoelace-element';
|
import ShoelaceElement from '../../internal/shoelace-element';
|
||||||
|
import { watch } from '../../internal/watch';
|
||||||
import { LocalizeController } from '../../utilities/localize';
|
import { LocalizeController } from '../../utilities/localize';
|
||||||
|
import '../icon/icon';
|
||||||
import styles from './option.styles';
|
import styles from './option.styles';
|
||||||
import type { CSSResultGroup } from 'lit';
|
import type { CSSResultGroup } from 'lit';
|
||||||
|
|
||||||
|
@ -31,6 +34,12 @@ export default class SlOption extends ShoelaceElement {
|
||||||
/** The option's value. When selected, the containing form control will receive this value. */
|
/** The option's value. When selected, the containing form control will receive this value. */
|
||||||
@property() value = '';
|
@property() value = '';
|
||||||
|
|
||||||
|
/** Draws the option in a current state, meaning the user has keyed into it but hasn't selected it yet. */
|
||||||
|
@property({ type: Boolean, reflect: true }) current = false;
|
||||||
|
|
||||||
|
/** Draws the option in a selected state. */
|
||||||
|
@property({ type: Boolean, reflect: true }) selected = false;
|
||||||
|
|
||||||
/** Draws the option in a disabled state, preventing selection. */
|
/** Draws the option in a disabled state, preventing selection. */
|
||||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||||
|
|
||||||
|
@ -40,9 +49,28 @@ export default class SlOption extends ShoelaceElement {
|
||||||
this.setAttribute('aria-selected', 'false');
|
this.setAttribute('aria-selected', 'false');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@watch('disabled')
|
||||||
|
handleDisabledChange() {
|
||||||
|
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
@watch('selected')
|
||||||
|
handleSelectedChange() {
|
||||||
|
this.setAttribute('aria-selected', this.selected ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<div class="option">
|
<div
|
||||||
|
class=${classMap({
|
||||||
|
option: true,
|
||||||
|
'option--current': this.current,
|
||||||
|
'option--selected': this.selected
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span part="checked-icon" class="option__check">
|
||||||
|
<sl-icon name="check" library="system" aria-hidden="true"></sl-icon>
|
||||||
|
</span>
|
||||||
<slot name="prefix" class="option__prefix"></slot>
|
<slot name="prefix" class="option__prefix"></slot>
|
||||||
<slot class="option__label"></slot>
|
<slot class="option__label"></slot>
|
||||||
<slot name="suffix" class="option__suffix"></slot>
|
<slot name="suffix" class="option__suffix"></slot>
|
||||||
|
|
|
@ -34,6 +34,7 @@ export default css`
|
||||||
flex: 1 0 auto;
|
flex: 1 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
font-family: var(--sl-input-font-family);
|
font-family: var(--sl-input-font-family);
|
||||||
font-weight: var(--sl-input-font-weight);
|
font-weight: var(--sl-input-font-weight);
|
||||||
letter-spacing: var(--sl-input-letter-spacing);
|
letter-spacing: var(--sl-input-letter-spacing);
|
||||||
|
@ -45,10 +46,11 @@ export default css`
|
||||||
}
|
}
|
||||||
|
|
||||||
.select__combobox {
|
.select__combobox {
|
||||||
flex: 1 0 auto;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
justify-content: start;
|
justify-content: start;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select__combobox:focus {
|
.select__combobox:focus {
|
||||||
|
@ -159,12 +161,18 @@ export default css`
|
||||||
border-radius: var(--sl-input-height-large);
|
border-radius: var(--sl-input-height-large);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Display label */
|
/* Display label (uses a wrapper to allow vertical centering with flex + text truncation on the label) */
|
||||||
.select__display-label {
|
.select__display-label-wrapper {
|
||||||
flex: 1 1 auto;
|
flex: 1 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
user-select: none;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select__display-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select--placeholder-visible .select__display-label {
|
.select--placeholder-visible .select__display-label {
|
||||||
|
|
|
@ -174,28 +174,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.hide();
|
this.hide();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
handleDocumentMouseDown(event: MouseEvent) {
|
|
||||||
// Close when clicking outside of the select
|
|
||||||
const path = event.composedPath();
|
|
||||||
if (this && !path.includes(this)) {
|
|
||||||
this.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLabelClick() {
|
|
||||||
this.combobox.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
// We use mousedown/mouseup instead of click to allow macOS-style menu behavior
|
|
||||||
handleComboboxMouseDown(event: MouseEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
this.combobox.focus();
|
|
||||||
this.open = !this.open;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleComboboxKeyDown(event: KeyboardEvent) {
|
|
||||||
// Handle enter and space. When pressing space, we allow for type to select behaviors so if there's anything in the
|
// Handle enter and space. When pressing space, we allow for type to select behaviors so if there's anything in the
|
||||||
// buffer we _don't_ close it.
|
// buffer we _don't_ close it.
|
||||||
if (event.key === 'Enter' || (event.key === ' ' && this.typeToSelectString === '')) {
|
if (event.key === 'Enter' || (event.key === ' ' && this.typeToSelectString === '')) {
|
||||||
|
@ -208,10 +187,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it is open, update the value based on the current selection and close it
|
// If it is open, update the value based on the current selection and close it
|
||||||
const selectedOption = this.getSelectedOption();
|
const currentOption = this.getCurrentOption();
|
||||||
if (selectedOption) {
|
if (currentOption) {
|
||||||
this.value = selectedOption.value;
|
this.setSelectedOption(currentOption);
|
||||||
this.displayLabel = selectedOption.textContent ?? '';
|
this.value = currentOption.value;
|
||||||
|
this.displayLabel = currentOption.textContent ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hide();
|
this.hide();
|
||||||
|
@ -222,9 +202,9 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
// Navigate options
|
// Navigate options
|
||||||
if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
|
if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
|
||||||
const allOptions = this.getAllOptions();
|
const allOptions = this.getAllOptions();
|
||||||
const selectedOption = this.getSelectedOption();
|
const currentOption = this.getCurrentOption();
|
||||||
const selectedIndex = allOptions.indexOf(selectedOption);
|
const currentIndex = allOptions.indexOf(currentOption);
|
||||||
let newIndex = Math.max(0, selectedIndex);
|
let newIndex = Math.max(0, currentIndex);
|
||||||
|
|
||||||
// Prevent scrolling
|
// Prevent scrolling
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
@ -235,16 +215,16 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
|
|
||||||
// If an option is already selected, stop here because we want that one to remain highlighted when the listbox
|
// If an option is already selected, stop here because we want that one to remain highlighted when the listbox
|
||||||
// opens for the first time
|
// opens for the first time
|
||||||
if (selectedOption) {
|
if (currentOption) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === 'ArrowDown') {
|
if (event.key === 'ArrowDown') {
|
||||||
newIndex = selectedIndex + 1;
|
newIndex = currentIndex + 1;
|
||||||
if (newIndex > allOptions.length - 1) newIndex = 0;
|
if (newIndex > allOptions.length - 1) newIndex = 0;
|
||||||
} else if (event.key === 'ArrowUp') {
|
} else if (event.key === 'ArrowUp') {
|
||||||
newIndex = selectedIndex - 1;
|
newIndex = currentIndex - 1;
|
||||||
if (newIndex < 0) newIndex = allOptions.length - 1;
|
if (newIndex < 0) newIndex = allOptions.length - 1;
|
||||||
} else if (event.key === 'Home') {
|
} else if (event.key === 'Home') {
|
||||||
newIndex = 0;
|
newIndex = 0;
|
||||||
|
@ -252,7 +232,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
newIndex = allOptions.length - 1;
|
newIndex = allOptions.length - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setSelectedOption(allOptions[newIndex]);
|
this.setCurrentOption(allOptions[newIndex]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// All other "printable" keys trigger type to select
|
// All other "printable" keys trigger type to select
|
||||||
|
@ -291,13 +271,37 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
const label = (option.textContent ?? '').toLowerCase();
|
const label = (option.textContent ?? '').toLowerCase();
|
||||||
|
|
||||||
if (label.startsWith(this.typeToSelectString)) {
|
if (label.startsWith(this.typeToSelectString)) {
|
||||||
this.setSelectedOption(option);
|
this.setCurrentOption(option);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleDocumentMouseDown(event: MouseEvent) {
|
||||||
|
// Close when clicking outside of the select
|
||||||
|
const path = event.composedPath();
|
||||||
|
if (this && !path.includes(this)) {
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLabelClick() {
|
||||||
|
this.combobox.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// We use mousedown/mouseup instead of click to allow macOS-style menu behavior
|
||||||
|
handleComboboxMouseDown(event: MouseEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.combobox.focus();
|
||||||
|
this.open = !this.open;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleComboboxKeyDown(event: KeyboardEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.handleDocumentKeyDown(event);
|
||||||
|
}
|
||||||
|
|
||||||
handleClearClick(event: MouseEvent) {
|
handleClearClick(event: MouseEvent) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
|
@ -359,21 +363,45 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
return this.getAllOptions().filter((el: SlOption) => el.value === value)[0];
|
return this.getAllOptions().filter((el: SlOption) => el.value === value)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gets the option that currently has aria-selected="true"
|
// Gets the current option
|
||||||
getSelectedOption() {
|
getCurrentOption() {
|
||||||
return this.getAllOptions().filter(el => el.getAttribute('aria-selected') === 'true')[0];
|
return this.getAllOptions().filter(el => el.current)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adds aria-selected to the target option and removes it from all others
|
// Sets the current option
|
||||||
|
setCurrentOption(option: SlOption | null) {
|
||||||
|
const allOptions = this.getAllOptions();
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
allOptions.forEach(el => {
|
||||||
|
el.current = false;
|
||||||
|
el.tabIndex = -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select the target option
|
||||||
|
if (option) {
|
||||||
|
option.current = true;
|
||||||
|
option.tabIndex = 0;
|
||||||
|
option.focus();
|
||||||
|
scrollIntoView(option, this.listbox);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gets the selected option
|
||||||
|
getSelectedOption() {
|
||||||
|
return this.getAllOptions().filter(el => el.selected)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets the selected option
|
||||||
setSelectedOption(option: SlOption | null) {
|
setSelectedOption(option: SlOption | null) {
|
||||||
const allOptions = this.getAllOptions();
|
const allOptions = this.getAllOptions();
|
||||||
|
|
||||||
// Clear selection
|
// Clear selection
|
||||||
allOptions.forEach(el => el.setAttribute('aria-selected', 'false'));
|
allOptions.forEach(el => (el.selected = false));
|
||||||
|
|
||||||
// Select the target option
|
// Select the target option
|
||||||
if (option) {
|
if (option) {
|
||||||
option.setAttribute('aria-selected', 'true');
|
option.selected = true;
|
||||||
scrollIntoView(option, this.listbox);
|
scrollIntoView(option, this.listbox);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -445,12 +473,15 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
this.listbox.hidden = false;
|
this.listbox.hidden = false;
|
||||||
this.popup.active = true;
|
this.popup.active = true;
|
||||||
|
|
||||||
// Make sure the current option is selected
|
// Select the correct option
|
||||||
this.setSelectedOption(this.getOptionByValue(this.value));
|
const currentOption = this.getOptionByValue(this.value);
|
||||||
|
this.setCurrentOption(currentOption);
|
||||||
|
this.setSelectedOption(currentOption);
|
||||||
|
|
||||||
// Scroll the selected option into view
|
// Scroll the selected option into view
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const selectedOption = this.getSelectedOption();
|
const selectedOption = this.getSelectedOption();
|
||||||
|
|
||||||
if (selectedOption) {
|
if (selectedOption) {
|
||||||
//
|
//
|
||||||
// TODO - improve this logic so the selected option is centered in the listbox instead of at the top
|
// TODO - improve this logic so the selected option is centered in the listbox instead of at the top
|
||||||
|
@ -555,9 +586,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
@blur=${this.handleBlur}
|
@blur=${this.handleBlur}
|
||||||
>
|
>
|
||||||
<slot name="prefix" class="select__prefix"></slot>
|
<slot name="prefix" class="select__prefix"></slot>
|
||||||
<span class="select__display-label"
|
<span class="select__display-label-wrapper">
|
||||||
>${isPlaceholderVisible ? this.placeholder : this.displayLabel}</span
|
<span class="select__display-label">
|
||||||
>
|
${isPlaceholderVisible ? this.placeholder : this.displayLabel}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${hasClearIcon
|
${hasClearIcon
|
||||||
|
@ -587,7 +620,9 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
|
|
||||||
<slot
|
<slot
|
||||||
id="listbox"
|
id="listbox"
|
||||||
|
role="listbox"
|
||||||
aria-expanded=${this.open ? 'true' : 'false'}
|
aria-expanded=${this.open ? 'true' : 'false'}
|
||||||
|
aria-multiselectable="false"
|
||||||
aria-labelledby="label"
|
aria-labelledby="label"
|
||||||
part="panel"
|
part="panel"
|
||||||
class="select__listbox"
|
class="select__listbox"
|
||||||
|
|
Ładowanie…
Reference in New Issue