This commit is contained in:
Cory LaViska
2022-12-28 11:42:08 -05:00
parent fe524e0fac
commit 06dc5740bf
29 changed files with 386 additions and 570 deletions

View File

@@ -151,7 +151,7 @@ module.exports = {
'prefer-rest-params': 'warn',
'prefer-spread': 'warn',
'prefer-template': 'off',
'no-else-return': 'warn',
'no-else-return': 'off',
'func-names': ['warn', 'never'],
'one-var': ['warn', 'never'],
'operator-assignment': 'warn',

View File

@@ -3,459 +3,35 @@
[component-header:sl-select]
```html preview
<sl-select>
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
<sl-option value="option-4">Option 4</sl-option>
<sl-option value="option-5">Option 5</sl-option>
<sl-option value="option-6">Option 6</sl-option>
</sl-select>
```
```jsx react
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
<SlOption value="option-4">Option 4</SlOption>
<SlOption value="option-5">Option 5</SlOption>
<SlOption value="option-6">Option 6</SlOption>
</SlSelect>
);
```
?> This component works with standard `<form>` elements. Please refer to the section on [form controls](/getting-started/form-controls) to learn more about form submission and client-side validation.
## Examples
### Labels
Use the `label` attribute to give the select an accessible label. For labels that contain HTML, use the `label` slot instead.
```html preview
<sl-select label="Select one">
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
```
```jsx react
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect label="Select one">
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
);
```
### Help Text
Add descriptive help text to a select with the `help-text` attribute. For help texts that contain HTML, use the `help-text` slot instead.
```html preview
<sl-select label="Experience" help-text="Please tell us your skill level.">
<sl-option value="1">Novice</sl-option>
<sl-option value="2">Intermediate</sl-option>
<sl-option value="3">Advanced</sl-option>
</sl-select>
```
```jsx react
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect label="Experience" help-text="Please tell us your skill level.">
<SlOption value="1">Novice</SlOption>
<SlOption value="2">Intermediate</SlOption>
<SlOption value="3">Advanced</SlOption>
</SlSelect>
);
```
### Placeholders
Use the `placeholder` attribute to add a placeholder.
```html preview
<sl-select placeholder="Select one">
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
```
```jsx react
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect placeholder="Select one">
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
);
```
### Clearable
Use the `clearable` attribute to make the control clearable. The clear button only appears when an option is selected.
```html preview
<sl-select clearable value="option-1">
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
```
```jsx react
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect placeholder="Clearable" clearable>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
);
```
### Filled Selects
Add the `filled` attribute to draw a filled select.
```html preview
<sl-select filled>
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
```
```jsx react
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect filled>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
);
```
### Pill
Use the `pill` attribute to give selects rounded edges.
```html preview
<sl-select pill>
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
```
```jsx react
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect pill>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
);
```
### Disabled
Use the `disabled` attribute to disable a select.
```html preview
<sl-select placeholder="Disabled" disabled>
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
```
```jsx react
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect placeholder="Disabled" disabled>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
);
```
### Setting the Selection
Use the `value` attribute to set the current selection. When users interact with the control, its `value` will update to reflect the newly selected menu item's value.
```html preview
<sl-select value="option-2">
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
```
```jsx react
import { SlDivider, SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect value="option-2">
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
);
```
### Setting the Selection Imperatively
To programmatically set the selection, update the `value` property as shown below.
```html preview
<div class="selecting-example">
<sl-select>
<form id="f" target="_blank" method="GET">
<sl-select name="single" label="Select One" clearable>
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
<sl-option value="option-4">Option 4</sl-option>
<sl-option value="option-5">Option 5</sl-option>
<sl-option value="option-6">Option 6</sl-option>
</sl-select>
<br />
<sl-button data-option="option-1">Set 1</sl-button>
<sl-button data-option="option-2">Set 2</sl-button>
<sl-button data-option="option-3">Set 3</sl-button>
</div>
<sl-select name="many" label="Select Many" multiple clearable value="option-2">
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
<sl-option value="option-4">Option 4</sl-option>
<sl-option value="option-5">Option 5</sl-option>
<sl-option value="option-6">Option 6</sl-option>
</sl-select>
<br />
<sl-button variant="primary" type="submit">Submit</sl-button>
</form>
<script>
const container = document.querySelector('.selecting-example');
const select = container.querySelector('sl-select');
const form = document.querySelector('#f');
[...container.querySelectorAll('sl-button')].map(button => {
button.addEventListener('click', () => {
select.value = button.dataset.option;
});
});
form.addEventListener('submit', event => {});
</script>
```
```jsx react
import { useState } from 'react';
import { SlButton, SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => {
const [value, setValue] = useState('option-1');
return (
<>
<SlSelect value={value} onSlChange={event => setValue(event.target.value)}>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
<br />
<SlButton onClick={() => setValue('option-1')}>Set 1</SlButton>
<SlButton onClick={() => setValue('option-2')}>Set 2</SlButton>
<SlButton onClick={() => setValue('option-3')}>Set 3</SlButton>
</>
);
};
```
### Multiple
TODO
### Grouping Options
Use `<sl-divider>` to group listbox items visually. You can also use `<small>` to provide labels, but they won't be announced by most assistive devices.
```html preview
<sl-select>
<small>Section 1</small>
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
<sl-divider></sl-divider>
<small>Section 2</small>
<sl-option value="option-4">Option 4</sl-option>
<sl-option value="option-5">Option 5</sl-option>
<sl-option value="option-6">Option 6</sl-option>
</sl-select>
```
```jsx react
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
<SlOption value="option-4">Option 4</SlOption>
<SlOption value="option-5">Option 5</SlOption>
<SlOption value="option-6">Option 6</SlOption>
</SlSelect>
);
```
### Sizes
Use the `size` attribute to change a select's size. Note that size does not apply to listbox options.
```html preview
<sl-select placeholder="Small" size="small">
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
<br />
<sl-select placeholder="Medium" size="medium">
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
<br />
<sl-select placeholder="Large" size="large">
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
```
```jsx react
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<>
<SlSelect placeholder="Small" size="small">
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
<br />
<SlSelect placeholder="Medium" size="medium">
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
<br />
<SlSelect placeholder="Large" size="large">
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
</>
);
```
### Placement
The preferred placement of the select's menu can be set with the `placement` attribute. Note that the actual position may vary to ensure the panel remains in the viewport. Valid placements are `top` and `bottom`.
```html preview
<sl-select placement="top">
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
```
```jsx react
import {
SlOption,
SlSelect
} from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect placement="top">
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlDropdown>
);
```
### Prefix Icons
Use the `prefix` slot to add an icon.
```html preview
<sl-select placeholder="Small" size="small" clearable>
<sl-icon name="house" slot="prefix"></sl-icon>
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
<br />
<sl-select placeholder="Medium" size="medium" clearable>
<sl-icon name="house" slot="prefix"></sl-icon>
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
<br />
<sl-select placeholder="Large" size="large" clearable>
<sl-icon name="house" slot="prefix"></sl-icon>
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
```
```jsx react
import { SlIcon, SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<>
<SlSelect placeholder="Small" size="small">
<SlIcon name="house" slot="prefix"></SlIcon>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
<br />
<SlSelect placeholder="Medium" size="medium">
<SlIcon name="house" slot="prefix"></SlIcon>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
<br />
<SlSelect placeholder="Large" size="large">
<SlIcon name="house" slot="prefix"></SlIcon>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
</>
);
```
[component-metadata:sl-select]

14
package-lock.json generated
View File

@@ -13,7 +13,7 @@
"@floating-ui/dom": "^1.0.7",
"@lit-labs/react": "^1.1.0",
"@shoelace-style/animations": "^1.1.0",
"@shoelace-style/localize": "^3.0.3",
"@shoelace-style/localize": "^3.0.4",
"lit": "^2.4.1",
"qr-creator": "^1.0.0"
},
@@ -1152,9 +1152,9 @@
}
},
"node_modules/@shoelace-style/localize": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.0.3.tgz",
"integrity": "sha512-BVYDsMTpSCjvC8akhTkcnl4WIgAv7AnRq8fSuNhdGDLjkpmN1ARdAXic5luAozoMVjft85ocOmEdT8z7MbXdmQ=="
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.0.4.tgz",
"integrity": "sha512-HFY90KD+b1Td2otSBryCOpQjBEArIwlV6Tv4J4rC/E/D5wof2eLF6JUVrbiRNn8GRmwATe4YDAEK7NUD08xO1w=="
},
"node_modules/@sindresorhus/is": {
"version": "0.7.0",
@@ -16027,9 +16027,9 @@
"integrity": "sha512-Be+cahtZyI2dPKRm8EZSx3YJQ+jLvEcn3xzRP7tM4tqBnvd/eW/64Xh0iOf0t2w5P8iJKfdBbpVNE9naCaOf2g=="
},
"@shoelace-style/localize": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.0.3.tgz",
"integrity": "sha512-BVYDsMTpSCjvC8akhTkcnl4WIgAv7AnRq8fSuNhdGDLjkpmN1ARdAXic5luAozoMVjft85ocOmEdT8z7MbXdmQ=="
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.0.4.tgz",
"integrity": "sha512-HFY90KD+b1Td2otSBryCOpQjBEArIwlV6Tv4J4rC/E/D5wof2eLF6JUVrbiRNn8GRmwATe4YDAEK7NUD08xO1w=="
},
"@sindresorhus/is": {
"version": "0.7.0",

View File

@@ -66,7 +66,7 @@
"@floating-ui/dom": "^1.0.7",
"@lit-labs/react": "^1.1.0",
"@shoelace-style/animations": "^1.1.0",
"@shoelace-style/localize": "^3.0.3",
"@shoelace-style/localize": "^3.0.4",
"lit": "^2.4.1",
"qr-creator": "^1.0.0"
},

View File

@@ -20,7 +20,7 @@ export default css`
color: inherit;
padding: var(--sl-spacing-x-small);
cursor: pointer;
transition: var(--sl-transition-medium) color;
transition: var(--sl-transition-x-fast) color;
-webkit-appearance: none;
}

View File

@@ -1,5 +1,5 @@
import { html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import ShoelaceElement from '../../internal/shoelace-element';
import { getTextContent } from '../../internal/slot';
@@ -39,17 +39,14 @@ export default class SlOption extends ShoelaceElement {
// @ts-expect-error -- Controller is currently unused
private readonly localize = new LocalizeController(this);
@state() current = false; // the user has keyed into the option, but hasn't selected it yet (shows a highlight)
@state() selected = false; // the option is selected and has aria-selected="true"
@query('.option__label') defaultSlot: HTMLSlotElement;
/** The option's value. When selected, the containing form control will receive this 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. */
@property({ type: Boolean, reflect: true }) disabled = false;
@@ -59,6 +56,11 @@ export default class SlOption extends ShoelaceElement {
this.setAttribute('aria-selected', 'false');
}
/** Returns a plain text label based on the option's content. */
getTextLabel() {
return this.textContent ?? '';
}
@watch('disabled')
handleDisabledChange() {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');

View File

@@ -38,7 +38,7 @@ export default css`
width: 100%;
min-width: 0;
position: relative;
align-items: stretch;
align-items: center;
justify-content: start;
font-family: var(--sl-input-font-family);
font-weight: var(--sl-input-font-weight);
@@ -51,6 +51,7 @@ export default css`
}
.select__display-input {
position: relative;
width: 100%;
font: inherit;
border: none;
@@ -66,6 +67,17 @@ export default css`
outline: none;
}
/* Visually hide the display input when multiple is enabled */
.select--multiple .select__display-input {
position: absolute;
z-index: -1;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
}
.select__value-input {
position: absolute;
width: 100%;
@@ -76,6 +88,23 @@ export default css`
z-index: -1;
}
.select__tags {
display: flex;
flex: 1;
align-items: center;
flex-wrap: wrap;
margin-inline-start: var(--sl-spacing-2x-small);
}
.select__tags::slotted(sl-tag) {
cursor: pointer !important;
}
.select--disabled .select__tags,
.select--disabled .select__tags::slotted(sl-tag) {
cursor: not-allowed !important;
}
/* Standard selects */
.select--standard .select__combobox {
background-color: var(--sl-input-background-color);
@@ -137,10 +166,19 @@ export default css`
margin-inline-end: var(--sl-input-spacing-small);
}
.select--small.select--multiple .select__combobox {
padding-inline-start: 0;
padding-block: 2px;
}
.select--small .select__tags {
gap: 2px;
}
.select--medium .select__combobox {
border-radius: var(--sl-input-border-radius-medium);
font-size: var(--sl-input-font-size-medium);
height: var(--sl-input-height-medium);
min-height: var(--sl-input-height-medium);
padding: 0 var(--sl-input-spacing-medium);
}
@@ -152,6 +190,15 @@ export default css`
margin-inline-end: var(--sl-input-spacing-medium);
}
.select--medium.select--multiple .select__combobox {
padding-inline-start: 0;
padding-block: 3px;
}
.select--medium .select__tags {
gap: 3px;
}
.select--large .select__combobox {
border-radius: var(--sl-input-border-radius-large);
font-size: var(--sl-input-font-size-large);
@@ -167,6 +214,15 @@ export default css`
margin-inline-end: var(--sl-input-spacing-large);
}
.select--large.select--multiple .select__combobox {
padding-inline-start: 0;
padding-block: 4px;
}
.select--large .select__tags {
gap: 4px;
}
/* Pills */
.select--pill.select--small .select__combobox {
border-radius: var(--sl-input-height-small);

View File

@@ -13,6 +13,7 @@ import { getAnimation, setDefaultAnimation } from '../../utilities/animation-reg
import { LocalizeController } from '../../utilities/localize';
import '../icon/icon';
import '../popup/popup';
import '../tag/tag';
import styles from './select.styles';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
import type SlOption from '../option/option';
@@ -27,6 +28,7 @@ import type { CSSResultGroup } from 'lit';
*
* @dependency sl-icon
* @dependency sl-popup
* @dependency sl-tag
*
* @slot - The listbox options. Must be `<sl-option>` elements. You can use `<sl-divider>` to group items visually.
* @slot label - The input's label. Alternatively, you can use the `label` attribute.
@@ -73,18 +75,23 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
private typeToSelectString = '';
private typeToSelectTimeout: number;
@state() displayLabel = '';
@state() private hasFocus = false;
@state() displayLabel = '';
@state() currentOption: SlOption;
@state() selectedOptions: SlOption[] = [];
@state() invalid = false;
/** The name of the select, submitted as a name/value pair with form data. */
@property() name = '';
/** The current value of the select, submitted as a name/value pair with form data. */
@property() value = '';
/**
* The current value of the select, submitted as a name/value pair with form data. If `multiple` is enabled, this
* property will be an array. Otherwise, it will be a string.
*/
@property() value: string | string[] = '';
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue() defaultValue = '';
@defaultValue() defaultValue: string | string[] = '';
/** The select's size. */
@property() size: 'small' | 'medium' | 'large' = 'medium';
@@ -92,6 +99,15 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
/** Placeholder text to show as a hint when the select is empty. */
@property() placeholder = '';
/** Allows more than one option to be selected. */
@property({ type: Boolean, reflect: true }) multiple = false;
/**
* The maximum number of selected options to show when `multiple` is true. After the maximum, "+n" will be shown to
* indicate the number of additional items that are selected. Set to 0 to remove the limit.
*/
@property({ attribute: 'max-options-visible', type: Number }) maxOptionsVisible = 3;
/** Disables the select control. */
@property({ type: Boolean, reflect: true }) disabled = false;
@@ -171,18 +187,30 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
this.displayInput.blur();
}
handleFocus() {
private addOpenListeners() {
document.addEventListener('focusin', this.handleDocumentFocusIn);
document.addEventListener('keydown', this.handleDocumentKeyDown);
document.addEventListener('mousedown', this.handleDocumentMouseDown);
}
private removeOpenListeners() {
document.removeEventListener('focusin', this.handleDocumentFocusIn);
document.removeEventListener('keydown', this.handleDocumentKeyDown);
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
}
private handleFocus() {
this.hasFocus = true;
this.displayInput.setSelectionRange(0, 0);
this.emit('sl-focus');
}
handleBlur() {
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
handleDocumentFocusIn(event: KeyboardEvent) {
private handleDocumentFocusIn(event: KeyboardEvent) {
// Close when focusing out of the select
const path = event.composedPath();
if (this && !path.includes(this)) {
@@ -190,12 +218,13 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
}
}
handleDocumentKeyDown(event: KeyboardEvent) {
private handleDocumentKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
const isClearButton = target.closest('.select__clear') !== null;
const isIconButton = target.closest('sl-icon-button') !== null;
// Ignore presses when the target is the clear button
if (isClearButton) {
// Ignore presses when the target is an icon button (e.g. the remove button in <sl-tag>)
if (isClearButton || isIconButton) {
return;
}
@@ -204,12 +233,14 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
event.preventDefault();
event.stopPropagation();
this.hide();
this.displayInput.focus();
}
// 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.
if (event.key === 'Enter' || (event.key === ' ' && this.typeToSelectString === '')) {
event.preventDefault();
event.stopImmediatePropagation();
// If it's not open, open it
if (!this.open) {
@@ -218,14 +249,20 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
}
// If it is open, update the value based on the current selection and close it
const currentOption = this.getCurrentOption();
if (currentOption && !currentOption.disabled) {
this.setSelectedOption(currentOption);
this.setValueFromOption(currentOption);
if (this.currentOption && !this.currentOption.disabled) {
if (this.multiple) {
this.toggleOptionSelection(this.currentOption);
} else {
this.setSelectedOptions(this.currentOption);
}
this.emit('sl-input');
this.emit('sl-change');
this.hide();
this.displayInput.focus();
if (!this.multiple) {
this.hide();
this.displayInput.focus();
}
}
return;
@@ -234,8 +271,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
// Navigate options
if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
const allOptions = this.getAllOptions();
const currentOption = this.getCurrentOption();
const currentIndex = allOptions.indexOf(currentOption);
const currentIndex = allOptions.indexOf(this.currentOption);
let newIndex = Math.max(0, currentIndex);
// Prevent scrolling
@@ -247,7 +283,7 @@ 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
// opens for the first time
if (currentOption) {
if (this.currentOption) {
return;
}
}
@@ -298,7 +334,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
}
for (const option of allOptions) {
const label = (option.textContent ?? '').toLowerCase();
const label = option.getTextLabel().toLowerCase();
if (label.startsWith(this.typeToSelectString)) {
this.setCurrentOption(option);
@@ -308,7 +344,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
}
}
handleDocumentMouseDown(event: MouseEvent) {
private handleDocumentMouseDown(event: MouseEvent) {
// Close when clicking outside of the select
const path = event.composedPath();
if (this && !path.includes(this)) {
@@ -316,27 +352,35 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
}
}
handleLabelClick() {
private handleLabelClick() {
this.displayInput.focus();
}
// We use mousedown/mouseup instead of click to allow macOS-style menu behavior
handleComboboxMouseDown(event: MouseEvent) {
private handleComboboxMouseDown(event: MouseEvent) {
const path = event.composedPath();
const isIconButton = path.some(el => el instanceof Element && el.tagName.toLowerCase() === 'sl-icon-button');
// Ignore clicks on tags (remove buttons)
if (isIconButton) {
return;
}
event.preventDefault();
this.displayInput.focus();
this.open = !this.open;
}
handleComboboxKeyDown(event: KeyboardEvent) {
private handleComboboxKeyDown(event: KeyboardEvent) {
event.stopPropagation();
this.handleDocumentKeyDown(event);
}
handleClearClick(event: MouseEvent) {
private handleClearClick(event: MouseEvent) {
event.stopPropagation();
if (this.value !== '') {
this.setValueFromOption(null);
this.setSelectedOptions([]);
this.displayInput.focus();
this.emit('sl-clear');
this.emit('sl-input');
@@ -344,21 +388,26 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
}
}
handleClearMouseDown(event: MouseEvent) {
private handleClearMouseDown(event: MouseEvent) {
// Don't lose focus or propagate events when clicking the clear button
event.stopPropagation();
event.preventDefault();
}
// We use mousedown/mouseup instead of click to allow macOS-style menu behavior
handleOptionMouseUp(event: MouseEvent) {
private handleOptionMouseUp(event: MouseEvent) {
const target = event.target as HTMLElement;
const option = target.closest('sl-option');
const oldValue = this.value;
if (option && !option.disabled) {
// Update the value and focus after updating so the value is read by screen readers
this.setValueFromOption(option);
if (this.multiple) {
this.toggleOptionSelection(option);
} else {
this.setSelectedOptions(option);
}
// Set focus after updating so the value is announced by screen readers
this.updateComplete.then(() => this.displayInput.focus());
if (this.value !== oldValue) {
@@ -366,11 +415,14 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
this.emit('sl-change');
}
this.hide();
if (!this.multiple) {
this.hide();
this.displayInput.focus();
}
}
}
handleDefaultSlotChange() {
private handleDefaultSlotChange() {
const allOptions = this.getAllOptions();
const values: string[] = [];
@@ -382,39 +434,23 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
values.push(option.value);
});
// Update the selected option
const option = this.getOptionByValue(this.value);
if (option) {
this.setSelectedOption(option);
this.setValueFromOption(option);
} else {
// Clear selection
this.setSelectedOption(null);
}
// Update the selection since it probably changed
this.selectionChanged();
}
// Gets an array of all <sl-option> elements
getAllOptions() {
private getAllOptions() {
return [...this.querySelectorAll<SlOption>('sl-option')];
}
// Gets the first <sl-option> element
getFirstOption() {
private getFirstOption() {
return this.querySelector<SlOption>('sl-option');
}
// Gets an option based on its value
getOptionByValue(value: string) {
return this.getAllOptions().filter((el: SlOption) => el.value === value)[0];
}
// Gets the current option
getCurrentOption() {
return this.getAllOptions().filter(el => el.current)[0];
}
// Sets the current option
setCurrentOption(option: SlOption | null) {
// Sets the current option, which is the option the user is currently interacting with (e.g. via keyboard). Only one
// option may be "current" at a time.
private setCurrentOption(option: SlOption | null) {
const allOptions = this.getAllOptions();
// Clear selection
@@ -425,6 +461,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
// Select the target option
if (option) {
this.currentOption = option;
option.current = true;
option.tabIndex = 0;
option.focus();
@@ -432,49 +469,64 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
}
}
// Gets the selected option
getSelectedOption() {
return this.getAllOptions().filter(el => el.selected)[0];
}
// Sets the selected option
setSelectedOption(option: SlOption | null) {
// Sets the selected option(s)
private setSelectedOptions(option: SlOption | SlOption[]) {
const allOptions = this.getAllOptions();
const newSelectedOptions = Array.isArray(option) ? option : [option];
// Clear selection
// Clear existing selection
allOptions.forEach(el => (el.selected = false));
// Select the target option
if (option) {
option.selected = true;
scrollIntoView(option, this.listbox);
// Set the new selection
if (newSelectedOptions.length) {
newSelectedOptions.forEach(el => (el.selected = true));
// Scroll the first selected option into view
scrollIntoView(newSelectedOptions[0]!, this.listbox);
}
// Update selection, value, and display label
this.selectionChanged();
}
// Sets the value, display label, and syncs validity
setValueFromOption(option: SlOption | null) {
const displayLabel = option ? (option.textContent ?? '').trim() : '';
const value = option ? option.value : '';
// Toggles an option's selected state
private toggleOptionSelection(option: SlOption, force?: boolean) {
if (force === true || force === false) {
option.selected = force;
} else {
option.selected = !option.selected;
}
this.displayLabel = displayLabel;
this.value = value;
this.valueInput.value = value; // synchronous update for validation
this.selectionChanged();
}
// This method must be called whenever the selection changes. It will sync the selected options cache, update the
// current value, and update the display value.
private selectionChanged() {
console.log('selectionChanged');
// Update selection options cache
this.selectedOptions = this.getAllOptions().filter(el => el.selected);
// Update the value and display label
if (this.multiple) {
this.value = this.selectedOptions.map(el => el.value);
this.displayLabel = this.localize.term('numOptionsSelected', this.selectedOptions.length);
} else {
this.value = this.selectedOptions[0]?.value ?? '';
this.displayLabel = this.selectedOptions[0]?.getTextLabel() ?? '';
}
// Update validity
this.invalid = !this.checkValidity();
}
@watch('value', { waitUntilFirstUpdate: true })
handleValueChange() {
const option = this.getOptionByValue(this.value);
const allOptions = this.getAllOptions();
const value = Array.isArray(this.value) ? this.value : [this.value];
// Update the selection
this.setSelectedOption(option);
if (option) {
this.setValueFromOption(option);
} else {
// No option, reset the control
this.setValueFromOption(null);
}
// Select only the options that match the new value
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
}
/** Shows the listbox. */
@@ -499,18 +551,6 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
return waitForEvent(this, 'sl-after-hide');
}
addOpenListeners() {
document.addEventListener('focusin', this.handleDocumentFocusIn);
document.addEventListener('keydown', this.handleDocumentKeyDown);
document.addEventListener('mousedown', this.handleDocumentMouseDown);
}
removeOpenListeners() {
document.removeEventListener('focusin', this.handleDocumentFocusIn);
document.removeEventListener('keydown', this.handleDocumentKeyDown);
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.disabled) {
@@ -519,8 +559,8 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
}
if (this.open) {
const selectedOption = this.getOptionByValue(this.value);
const currentOption = selectedOption || this.getFirstOption();
// Reset the current option
this.setCurrentOption(this.selectedOptions[0] || this.getFirstOption());
// Show
this.emit('sl-show');
@@ -532,16 +572,15 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
// Select the appropriate option based on value after the listbox opens
requestAnimationFrame(() => {
this.setSelectedOption(selectedOption);
this.setCurrentOption(currentOption);
this.setCurrentOption(this.currentOption);
});
const { keyframes, options } = getAnimation(this, 'select.show', { dir: this.localize.dir() });
await animateTo(this.popup.popup, keyframes, options);
// Make sure the current option is scrolled into view (required for Safari)
if (currentOption) {
scrollIntoView(currentOption, this.listbox, 'vertical', 'auto');
if (this.currentOption) {
scrollIntoView(this.currentOption, this.listbox, 'vertical', 'auto');
}
this.emit('sl-after-show');
@@ -589,6 +628,8 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
<slot name="label">${this.label}</slot>
</label>
/ Value: ${Array.isArray(this.value) ? this.value.join(' + ') : this.value}
<div part="form-control-input" class="form-control-input">
<sl-popup
class=${classMap({
@@ -598,6 +639,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
'select--pill': this.pill,
'select--open': this.open,
'select--disabled': this.disabled,
'select--multiple': this.multiple,
'select--focused': this.hasFocus,
'select--top': this.placement === 'top',
'select--bottom': this.placement === 'bottom',
@@ -645,12 +687,41 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
@blur=${this.handleBlur}
/>
${this.multiple
? html`
<div part="tags" class="select__tags">
${this.selectedOptions.map((option, index) => {
if (index < this.maxOptionsVisible || this.maxOptionsVisible <= 0) {
return html`
<sl-tag
size=${this.size}
removable
@sl-remove=${(event: CustomEvent) => {
event.stopPropagation();
if (!this.disabled) {
this.toggleOptionSelection(option, false);
}
}}
>
${option.getTextLabel()}
</sl-tag>
`;
} else if (index === this.maxOptionsVisible) {
return html` <sl-tag size=${this.size}> +${this.selectedOptions.length - index} </sl-tag> `;
} else {
return null;
}
})}
</div>
`
: ''}
<input
class="select__value-input"
type="text"
?disabled=${this.disabled}
?required=${this.required}
.value=${this.value}
.value=${Array.isArray(this.value) ? this.value.join(', ') : this.value}
tabindex="-1"
aria-hidden="true"
@focus=${() => this.focus()}
@@ -683,7 +754,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
id="listbox"
role="listbox"
aria-expanded=${this.open ? 'true' : 'false'}
aria-multiselectable="false"
aria-multiselectable=${this.multiple ? 'true' : 'false'}
aria-labelledby="label"
part="listbox"
class="select__listbox"

View File

@@ -15,7 +15,6 @@ export default css`
line-height: 1;
white-space: nowrap;
user-select: none;
cursor: default;
}
.tag__remove::part(base) {
@@ -33,30 +32,50 @@ export default css`
color: var(--sl-color-primary-800);
}
.tag--primary:active > sl-icon-button {
color: var(--sl-color-primary-600);
}
.tag--success {
background-color: var(--sl-color-success-50);
border-color: var(--sl-color-success-200);
color: var(--sl-color-success-800);
}
.tag--success:active > sl-icon-button {
color: var(--sl-color-success-600);
}
.tag--neutral {
background-color: var(--sl-color-neutral-50);
border-color: var(--sl-color-neutral-200);
color: var(--sl-color-neutral-800);
}
.tag--neutral:active > sl-icon-button {
color: var(--sl-color-neutral-600);
}
.tag--warning {
background-color: var(--sl-color-warning-50);
border-color: var(--sl-color-warning-200);
color: var(--sl-color-warning-800);
}
.tag--warning:active > sl-icon-button {
color: var(--sl-color-warning-600);
}
.tag--danger {
background-color: var(--sl-color-danger-50);
border-color: var(--sl-color-danger-200);
color: var(--sl-color-danger-800);
}
.tag--danger:active > sl-icon-button {
color: var(--sl-color-danger-600);
}
/*
* Size modifiers
*/

View File

@@ -82,6 +82,7 @@ export default class SlTag extends ShoelaceElement {
label=${this.localize.term('remove')}
class="tag__remove"
@click=${this.handleRemoveClick}
tabindex="-1"
></sl-icon-button>
`
: ''}

View File

@@ -3,12 +3,17 @@ import type { Translation } from '../utilities/localize';
const translation: Translation = {
$code: 'da',
$name: 'Danish',
$name: 'Dansk',
$dir: 'ltr',
clearEntry: 'Ryd indtastning',
close: 'Luk',
copy: 'Kopier',
numOptionsSelected: (num: number) => {
if (num === 0) return 'Geen opties geselecteerd';
if (num === 1) return '1 optie geselecteerd';
return `${num} opties geselecteerd`;
},
currentValue: 'Nuværende regerer',
hidePassword: 'Skjul adgangskode',
loading: 'Indlæser',

View File

@@ -9,6 +9,11 @@ const translation: Translation = {
clearEntry: 'Eingabe löschen',
close: 'Schließen',
copy: 'Kopieren',
numOptionsSelected: (num: number) => {
if (num === 0) return 'Keine Optionen ausgewählt';
if (num === 1) return '1 Option ausgewählt';
return `${num} optionen ausgewählt`;
},
currentValue: 'Aktueller Wert',
hidePassword: 'Passwort verbergen',
loading: 'Wird geladen',

View File

@@ -9,6 +9,11 @@ const translation: Translation = {
clearEntry: 'Eingabe löschen',
close: 'Schliessen',
copy: 'Kopieren',
numOptionsSelected: (num: number) => {
if (num === 0) return 'Keine Optionen ausgewählt';
if (num === 1) return '1 Option ausgewählt';
return `${num} optionen ausgewählt`;
},
currentValue: 'Aktueller Wert',
hidePassword: 'Passwort verbergen',
loading: 'Wird geladen',

View File

@@ -9,6 +9,11 @@ const translation: Translation = {
clearEntry: 'Eingabe löschen',
close: 'Schließen',
copy: 'Kopieren',
numOptionsSelected: (num: number) => {
if (num === 0) return 'Keine Optionen ausgewählt';
if (num === 1) return '1 Option ausgewählt';
return `${num} optionen ausgewählt`;
},
currentValue: 'Aktueller Wert',
hidePassword: 'Passwort verbergen',
loading: 'Wird geladen',

View File

@@ -9,6 +9,11 @@ const translation: Translation = {
clearEntry: 'Clear entry',
close: 'Close',
copy: 'Copy',
numOptionsSelected: (num: number) => {
if (num === 0) return 'No options selected';
if (num === 1) return '1 option selected';
return `${num} options selected`;
},
currentValue: 'Current value',
hidePassword: 'Hide password',
loading: 'Loading',

View File

@@ -9,6 +9,11 @@ const translation: Translation = {
clearEntry: 'Clear entry',
close: 'Close',
copy: 'Copy',
numOptionsSelected: (num: number) => {
if (num === 0) return 'No options selected';
if (num === 1) return '1 option selected';
return `${num} options selected`;
},
currentValue: 'Current value',
hidePassword: 'Hide password',
loading: 'Loading',

View File

@@ -9,6 +9,11 @@ const translation: Translation = {
clearEntry: 'Borrar entrada',
close: 'Cerrar',
copy: 'Copiar',
numOptionsSelected: (num: number) => {
if (num === 0) return 'No hay opciones seleccionadas';
if (num === 1) return '1 opción seleccionada';
return `${num} opción seleccionada`;
},
currentValue: 'Valor actual',
hidePassword: 'Ocultar contraseña',
loading: 'Cargando',

View File

@@ -9,6 +9,11 @@ const translation: Translation = {
clearEntry: 'پاک کردن ورودی',
close: 'بستن',
copy: 'رونوشت',
numOptionsSelected: (num: number) => {
if (num === 0) return 'هیچ گزینه ای انتخاب نشده است';
if (num === 1) return '1 گزینه انتخاب شده است';
return `${num} گزینه انتخاب شده است`;
},
currentValue: 'مقدار فعلی',
hidePassword: 'پنهان کردن رمز',
loading: 'بارگذاری',

View File

@@ -9,6 +9,11 @@ const translation: Translation = {
clearEntry: `Effacer l'entrée`,
close: 'Fermer',
copy: 'Copier',
numOptionsSelected: (num: number) => {
if (num === 0) return 'Aucune option sélectionnée';
if (num === 1) return '1 option sélectionnée';
return `${num} options sélectionnées`;
},
currentValue: 'Valeur actuelle',
hidePassword: 'Masquer le mot de passe',
loading: 'Chargement',

View File

@@ -9,6 +9,11 @@ const translation: Translation = {
clearEntry: 'נקה קלט',
close: 'סגור',
copy: 'העתק',
numOptionsSelected: (num: number) => {
if (num === 0) return 'לא נבחרו אפשרויות';
if (num === 1) return 'נבחרה אפשרות אחת';
return `נבחרו ${num} אפשרויות`;
},
currentValue: 'ערך נוכחי',
hidePassword: 'הסתר סיסמא',
loading: 'טוען',

View File

@@ -9,6 +9,11 @@ const translation: Translation = {
clearEntry: 'Bejegyzés törlése',
close: 'Bezárás',
copy: 'Másolás',
numOptionsSelected: (num: number) => {
if (num === 0) return 'Nincsenek kiválasztva opciók';
if (num === 1) return '1 lehetőség kiválasztva';
return `${num} lehetőség kiválasztva`;
},
currentValue: 'Aktuális érték',
hidePassword: 'Jelszó elrejtése',
loading: 'Betöltés',

View File

@@ -9,6 +9,11 @@ const translation: Translation = {
clearEntry: 'クリアエントリ',
close: '閉じる',
copy: 'コピー',
numOptionsSelected: (num: number) => {
if (num === 0) return 'オプションが選択されていません';
if (num === 1) return '1 つのオプションが選択されました';
return `${num} つのオプションが選択されました`;
},
currentValue: '現在の価値',
hidePassword: 'パスワードを隠す',
loading: '読み込み中',

View File

@@ -3,12 +3,17 @@ import type { Translation } from '../utilities/localize';
const translation: Translation = {
$code: 'nl',
$name: 'Dutch',
$name: 'Nederlands',
$dir: 'ltr',
clearEntry: 'Invoer wissen',
close: 'Sluiten',
copy: 'Kopiëren',
numOptionsSelected: (num: number) => {
if (num === 0) return 'Geen optie geselecteerd';
if (num === 1) return '1 optie geselecteerd';
return `${num} opties geselecteerd`;
},
currentValue: 'Huidige waarde',
hidePassword: 'Verberg wachtwoord',
loading: 'Bezig met laden',

View File

@@ -9,6 +9,11 @@ const translation: Translation = {
clearEntry: 'Wyczyść wpis',
close: 'Zamknij',
copy: 'Kopiuj',
numOptionsSelected: (num: number) => {
if (num === 0) return 'Nie wybrano opcji';
if (num === 1) return 'Wybrano 1 opcję';
return `Wybrano ${num} opcje`;
},
currentValue: 'Aktualna wartość',
hidePassword: 'Ukryj hasło',
loading: 'Ładowanie',

View File

@@ -9,6 +9,11 @@ const translation: Translation = {
clearEntry: 'Limpar entrada',
close: 'Fechar',
copy: 'Copiar',
numOptionsSelected: (num: number) => {
if (num === 0) return 'Nenhuma opção selecionada';
if (num === 1) return '1 opção selecionada';
return `${num} opções selecionadas`;
},
currentValue: 'Valor atual',
hidePassword: 'Esconder a senha',
loading: 'Carregando',

View File

@@ -9,6 +9,11 @@ const translation: Translation = {
clearEntry: 'Очистить запись',
close: 'Закрыть',
copy: 'Скопировать',
numOptionsSelected: (num: number) => {
if (num === 0) return 'выбрано 0 вариантов';
if (num === 1) return 'Выбран 1 вариант';
return `выбрано ${num} варианта`;
},
currentValue: 'Текущее значение',
hidePassword: 'Скрыть пароль',
loading: 'Загрузка',

View File

@@ -9,6 +9,11 @@ const translation: Translation = {
clearEntry: 'Återställ val',
close: 'Stäng',
copy: 'Kopiera',
numOptionsSelected: (num: number) => {
if (num === 0) return 'Inga alternativ har valts';
if (num === 1) return '1 alternativ valt';
return `${num} alternativ valda`;
},
currentValue: 'Nuvarande värde',
hidePassword: 'Dölj lösenord',
loading: 'Läser in',

View File

@@ -9,6 +9,11 @@ const translation: Translation = {
clearEntry: 'Girişi sil',
close: 'Kapat',
copy: 'Kopya',
numOptionsSelected: (num: number) => {
if (num === 0) return 'Hiçbir seçenek seçilmedi';
if (num === 1) return '1 seçenek seçildi';
return `${num} seçenek seçildi`;
},
currentValue: 'Mevcut değer',
hidePassword: 'Şifreyi sakla',
loading: 'Yükleme',

View File

@@ -16,6 +16,7 @@ export interface Translation extends DefaultTranslation {
clearEntry: string;
close: string;
copy: string;
numOptionsSelected: (num: number) => string;
currentValue: string;
hidePassword: string;
loading: string;