From 06dc5740bf9b5c1d2197a929ae697f6120f9a51f Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Wed, 28 Dec 2022 11:42:08 -0500 Subject: [PATCH] updates --- .eslintrc.cjs | 2 +- docs/components/select.md | 464 +----------------- package-lock.json | 14 +- package.json | 2 +- .../icon-button/icon-button.styles.ts | 2 +- src/components/option/option.ts | 16 +- src/components/select/select.styles.ts | 60 ++- src/components/select/select.ts | 279 +++++++---- src/components/tag/tag.styles.ts | 21 +- src/components/tag/tag.ts | 1 + src/translations/da.ts | 7 +- src/translations/de-at.ts | 5 + src/translations/de-ch.ts | 5 + src/translations/de.ts | 5 + src/translations/en-gb.ts | 5 + src/translations/en.ts | 5 + src/translations/es.ts | 5 + src/translations/fa.ts | 5 + src/translations/fr.ts | 5 + src/translations/he.ts | 5 + src/translations/hu.ts | 5 + src/translations/ja.ts | 5 + src/translations/nl.ts | 7 +- src/translations/pl.ts | 5 + src/translations/pt.ts | 5 + src/translations/ru.ts | 5 + src/translations/sv.ts | 5 + src/translations/tr.ts | 5 + src/utilities/localize.ts | 1 + 29 files changed, 386 insertions(+), 570 deletions(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d5b073d6e..f00a158c8 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -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', diff --git a/docs/components/select.md b/docs/components/select.md index 51f482e51..79ab2ddf8 100644 --- a/docs/components/select.md +++ b/docs/components/select.md @@ -3,459 +3,35 @@ [component-header:sl-select] ```html preview - - Option 1 - Option 2 - Option 3 - Option 4 - Option 5 - Option 6 - -``` - -```jsx react -import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - - Option 1 - Option 2 - Option 3 - Option 4 - Option 5 - Option 6 - -); -``` - -?> This component works with standard `
` 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 - - Option 1 - Option 2 - Option 3 - -``` - -```jsx react -import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - - Option 1 - Option 2 - Option 3 - -); -``` - -### 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 - - Novice - Intermediate - Advanced - -``` - -```jsx react -import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - - Novice - Intermediate - Advanced - -); -``` - -### Placeholders - -Use the `placeholder` attribute to add a placeholder. - -```html preview - - Option 1 - Option 2 - Option 3 - -``` - -```jsx react -import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - - Option 1 - Option 2 - Option 3 - -); -``` - -### Clearable - -Use the `clearable` attribute to make the control clearable. The clear button only appears when an option is selected. - -```html preview - - Option 1 - Option 2 - Option 3 - -``` - -```jsx react -import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - - Option 1 - Option 2 - Option 3 - -); -``` - -### Filled Selects - -Add the `filled` attribute to draw a filled select. - -```html preview - - Option 1 - Option 2 - Option 3 - -``` - -```jsx react -import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - - Option 1 - Option 2 - Option 3 - -); -``` - -### Pill - -Use the `pill` attribute to give selects rounded edges. - -```html preview - - Option 1 - Option 2 - Option 3 - -``` - -```jsx react -import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - - Option 1 - Option 2 - Option 3 - -); -``` - -### Disabled - -Use the `disabled` attribute to disable a select. - -```html preview - - Option 1 - Option 2 - Option 3 - -``` - -```jsx react -import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - - Option 1 - Option 2 - Option 3 - -); -``` - -### 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 - - Option 1 - Option 2 - Option 3 - -``` - -```jsx react -import { SlDivider, SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - - Option 1 - Option 2 - Option 3 - -); -``` - -### Setting the Selection Imperatively - -To programmatically set the selection, update the `value` property as shown below. - -```html preview -
- + + Option 1 Option 2 Option 3 + Option 4 + Option 5 + Option 6
- Set 1 - Set 2 - Set 3 -
+ + Option 1 + Option 2 + Option 3 + Option 4 + Option 5 + Option 6 + + +
+ + Submit +
``` - -```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 ( - <> - setValue(event.target.value)}> - Option 1 - Option 2 - Option 3 - - -
- - setValue('option-1')}>Set 1 - setValue('option-2')}>Set 2 - setValue('option-3')}>Set 3 - - ); -}; -``` - -### Multiple - -TODO - -### Grouping Options - -Use `` to group listbox items visually. You can also use `` to provide labels, but they won't be announced by most assistive devices. - -```html preview - - Section 1 - Option 1 - Option 2 - Option 3 - - Section 2 - Option 4 - Option 5 - Option 6 - -``` - -```jsx react -import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - - Option 1 - Option 2 - Option 3 - Option 4 - Option 5 - Option 6 - -); -``` - -### Sizes - -Use the `size` attribute to change a select's size. Note that size does not apply to listbox options. - -```html preview - - Option 1 - Option 2 - Option 3 - - -
- - - Option 1 - Option 2 - Option 3 - - -
- - - Option 1 - Option 2 - Option 3 - -``` - -```jsx react -import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - <> - - Option 1 - Option 2 - Option 3 - - -
- - - Option 1 - Option 2 - Option 3 - - -
- - - Option 1 - Option 2 - Option 3 - - -); -``` - -### 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 - - Option 1 - Option 2 - Option 3 - -``` - -```jsx react -import { - SlOption, - SlSelect -} from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - - Option 1 - Option 2 - Option 3 - -); -``` - -### Prefix Icons - -Use the `prefix` slot to add an icon. - -```html preview - - - Option 1 - Option 2 - Option 3 - -
- - - Option 1 - Option 2 - Option 3 - -
- - - Option 1 - Option 2 - Option 3 - -``` - -```jsx react -import { SlIcon, SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - <> - - - Option 1 - Option 2 - Option 3 - -
- - - Option 1 - Option 2 - Option 3 - -
- - - Option 1 - Option 2 - Option 3 - - -); -``` - -[component-metadata:sl-select] diff --git a/package-lock.json b/package-lock.json index a2d87ce3d..ca15967c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5ca20ec34..e156e718a 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/components/icon-button/icon-button.styles.ts b/src/components/icon-button/icon-button.styles.ts index c65528e5a..c86ff38c4 100644 --- a/src/components/icon-button/icon-button.styles.ts +++ b/src/components/icon-button/icon-button.styles.ts @@ -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; } diff --git a/src/components/option/option.ts b/src/components/option/option.ts index 590fc49e2..b13298cac 100644 --- a/src/components/option/option.ts +++ b/src/components/option/option.ts @@ -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'); diff --git a/src/components/select/select.styles.ts b/src/components/select/select.styles.ts index 4323eef78..8f86563d2 100644 --- a/src/components/select/select.styles.ts +++ b/src/components/select/select.styles.ts @@ -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); diff --git a/src/components/select/select.ts b/src/components/select/select.ts index 8e54e00ff..a36a87be3 100644 --- a/src/components/select/select.ts +++ b/src/components/select/select.ts @@ -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 `` elements. You can use `` 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 ) + 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 elements - getAllOptions() { + private getAllOptions() { return [...this.querySelectorAll('sl-option')]; } // Gets the first element - getFirstOption() { + private getFirstOption() { return this.querySelector('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 ${this.label} + / Value: ${Array.isArray(this.value) ? this.value.join(' + ') : this.value} +
+ ${this.multiple + ? html` +
+ ${this.selectedOptions.map((option, index) => { + if (index < this.maxOptionsVisible || this.maxOptionsVisible <= 0) { + return html` + { + event.stopPropagation(); + if (!this.disabled) { + this.toggleOptionSelection(option, false); + } + }} + > + ${option.getTextLabel()} + + `; + } else if (index === this.maxOptionsVisible) { + return html` +${this.selectedOptions.length - index} `; + } else { + return null; + } + })} +
+ ` + : ''} + 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" diff --git a/src/components/tag/tag.styles.ts b/src/components/tag/tag.styles.ts index 969c5c7bc..0b55c1ffe 100644 --- a/src/components/tag/tag.styles.ts +++ b/src/components/tag/tag.styles.ts @@ -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 */ diff --git a/src/components/tag/tag.ts b/src/components/tag/tag.ts index 7a457db51..0847e661f 100644 --- a/src/components/tag/tag.ts +++ b/src/components/tag/tag.ts @@ -82,6 +82,7 @@ export default class SlTag extends ShoelaceElement { label=${this.localize.term('remove')} class="tag__remove" @click=${this.handleRemoveClick} + tabindex="-1" > ` : ''} diff --git a/src/translations/da.ts b/src/translations/da.ts index e319ce8b1..a85d46f6a 100644 --- a/src/translations/da.ts +++ b/src/translations/da.ts @@ -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', diff --git a/src/translations/de-at.ts b/src/translations/de-at.ts index 5ba5daf2f..6049bd863 100644 --- a/src/translations/de-at.ts +++ b/src/translations/de-at.ts @@ -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', diff --git a/src/translations/de-ch.ts b/src/translations/de-ch.ts index 5b2cb1c89..15191c60b 100644 --- a/src/translations/de-ch.ts +++ b/src/translations/de-ch.ts @@ -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', diff --git a/src/translations/de.ts b/src/translations/de.ts index 509d21cc7..fe31063f0 100644 --- a/src/translations/de.ts +++ b/src/translations/de.ts @@ -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', diff --git a/src/translations/en-gb.ts b/src/translations/en-gb.ts index a839e7575..0202cf4b9 100644 --- a/src/translations/en-gb.ts +++ b/src/translations/en-gb.ts @@ -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', diff --git a/src/translations/en.ts b/src/translations/en.ts index 52a99423d..2938b4085 100644 --- a/src/translations/en.ts +++ b/src/translations/en.ts @@ -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', diff --git a/src/translations/es.ts b/src/translations/es.ts index 5a734b2e8..dcfd816c1 100644 --- a/src/translations/es.ts +++ b/src/translations/es.ts @@ -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', diff --git a/src/translations/fa.ts b/src/translations/fa.ts index 01293fddd..c42b47a6d 100644 --- a/src/translations/fa.ts +++ b/src/translations/fa.ts @@ -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: 'بارگذاری', diff --git a/src/translations/fr.ts b/src/translations/fr.ts index fcb0b5052..42df2dd7c 100644 --- a/src/translations/fr.ts +++ b/src/translations/fr.ts @@ -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', diff --git a/src/translations/he.ts b/src/translations/he.ts index 7238b3363..0187b80cb 100644 --- a/src/translations/he.ts +++ b/src/translations/he.ts @@ -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: 'טוען', diff --git a/src/translations/hu.ts b/src/translations/hu.ts index 382423ab3..5e298c9a8 100644 --- a/src/translations/hu.ts +++ b/src/translations/hu.ts @@ -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', diff --git a/src/translations/ja.ts b/src/translations/ja.ts index 731f453a1..72d0a6aa0 100644 --- a/src/translations/ja.ts +++ b/src/translations/ja.ts @@ -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: '読み込み中', diff --git a/src/translations/nl.ts b/src/translations/nl.ts index dc020846f..baac06a8e 100644 --- a/src/translations/nl.ts +++ b/src/translations/nl.ts @@ -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', diff --git a/src/translations/pl.ts b/src/translations/pl.ts index 793024e9d..2d60a314f 100644 --- a/src/translations/pl.ts +++ b/src/translations/pl.ts @@ -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', diff --git a/src/translations/pt.ts b/src/translations/pt.ts index 1b77ef66f..0ac85a245 100644 --- a/src/translations/pt.ts +++ b/src/translations/pt.ts @@ -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', diff --git a/src/translations/ru.ts b/src/translations/ru.ts index 0b5935071..2c5ced6c8 100644 --- a/src/translations/ru.ts +++ b/src/translations/ru.ts @@ -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: 'Загрузка', diff --git a/src/translations/sv.ts b/src/translations/sv.ts index 0974127c5..a38cf5320 100644 --- a/src/translations/sv.ts +++ b/src/translations/sv.ts @@ -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', diff --git a/src/translations/tr.ts b/src/translations/tr.ts index cb7d21d9d..a5771b3d4 100644 --- a/src/translations/tr.ts +++ b/src/translations/tr.ts @@ -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', diff --git a/src/utilities/localize.ts b/src/utilities/localize.ts index f9de14f59..88662b5d9 100644 --- a/src/utilities/localize.ts +++ b/src/utilities/localize.ts @@ -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;