diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 84e9158e5..90699f675 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -66,6 +66,7 @@ - [Tooltip](/components/tooltip) - [Tree](/components/tree) - [Tree Item](/components/tree-item) + - [Option](/components/option) - Utilities diff --git a/docs/components/option.md b/docs/components/option.md new file mode 100644 index 000000000..8d4a1172e --- /dev/null +++ b/docs/components/option.md @@ -0,0 +1,21 @@ +# Option + +[component-header:sl-option] + +A description of the component goes here. + +```html preview + +``` + +## Examples + +### First Example + +TODO + +### Second Example + +TODO + +[component-metadata:sl-option] diff --git a/docs/components/select.md b/docs/components/select.md index 4335cfadd..b314b72b9 100644 --- a/docs/components/select.md +++ b/docs/components/select.md @@ -4,28 +4,42 @@ ```html preview - Option 1 - Option 2 - Option 3 + Option 1 + Option 2 + Option 3 - Option 4 - Option 5 - Option 6 + Option 4 + Option 5 + Option 6 + Option 7 + Option 8 + Option 9 + Option 10 + Option 11 + Option 12 + Option 13 + Option 14 + Option 15 + Option 16 + Option 17 + Option 18 + Option 19 + Option 20 ``` ```jsx react -import { SlDivider, SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react'; +import { SlDivider, SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; const App = () => ( - Option 1 - Option 2 - Option 3 + Option 1 + Option 2 + Option 3 - Option 4 - Option 5 - Option 6 + Option 4 + Option 5 + Option 6 ); ``` @@ -40,20 +54,20 @@ Use the `label` attribute to give the select an accessible label. For labels tha ```html preview - Option 1 - Option 2 - Option 3 + Option 1 + Option 2 + Option 3 ``` ```jsx react -import { SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react'; +import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; const App = () => ( - Option 1 - Option 2 - Option 3 + Option 1 + Option 2 + Option 3 ); ``` @@ -64,20 +78,20 @@ Add descriptive help text to a select with the `help-text` attribute. For help t ```html preview - Novice - Intermediate - Advanced + Novice + Intermediate + Advanced ``` ```jsx react -import { SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react'; +import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; const App = () => ( - Novice - Intermediate - Advanced + Novice + Intermediate + Advanced ); ``` @@ -88,44 +102,44 @@ Use the `placeholder` attribute to add a placeholder. ```html preview - Option 1 - Option 2 - Option 3 + Option 1 + Option 2 + Option 3 ``` ```jsx react -import { SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react'; +import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; const App = () => ( - Option 1 - Option 2 - Option 3 + Option 1 + Option 2 + Option 3 ); ``` ### Clearable -Use the `clearable` attribute to make the control 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 + + Option 1 + Option 2 + Option 3 ``` ```jsx react -import { SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react'; +import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; const App = () => ( - Option 1 - Option 2 - Option 3 + Option 1 + Option 2 + Option 3 ); ``` @@ -136,20 +150,20 @@ Add the `filled` attribute to draw a filled select. ```html preview - Option 1 - Option 2 - Option 3 + Option 1 + Option 2 + Option 3 ``` ```jsx react -import { SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react'; +import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; const App = () => ( - Option 1 - Option 2 - Option 3 + Option 1 + Option 2 + Option 3 ); ``` @@ -160,20 +174,20 @@ Use the `pill` attribute to give selects rounded edges. ```html preview - Option 1 - Option 2 - Option 3 + Option 1 + Option 2 + Option 3 ``` ```jsx react -import { SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react'; +import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; const App = () => ( - Option 1 - Option 2 - Option 3 + Option 1 + Option 2 + Option 3 ); ``` @@ -184,58 +198,58 @@ Use the `disabled` attribute to disable a select. ```html preview - Option 1 - Option 2 - Option 3 + Option 1 + Option 2 + Option 3 ``` ```jsx react -import { SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react'; +import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; const App = () => ( - Option 1 - Option 2 - Option 3 + 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. Note that the value must be an array when using the [`multiple`](#multiple) option. +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 + Option 1 + Option 2 + Option 3 ``` ```jsx react -import { SlDivider, SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react'; +import { SlDivider, SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; const App = () => ( - Option 1 - Option 2 - Option 3 + Option 1 + Option 2 + Option 3 ); ``` ### Setting the Selection Imperatively -To programmatically set the selection, update the `value` property as shown below. Note that the value must be an array when using the [`multiple`](#multiple) option. +To programmatically set the selection, update the `value` property as shown below. ```html preview
- Option 1 - Option 2 - Option 3 + Option 1 + Option 2 + Option 3
@@ -259,7 +273,7 @@ To programmatically set the selection, update the `value` property as shown belo ```jsx react import { useState } from 'react'; -import { SlButton, SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react'; +import { SlButton, SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; const App = () => { const [value, setValue] = useState('option-1'); @@ -267,9 +281,9 @@ const App = () => { return ( <> setValue(event.target.value)}> - Option 1 - Option 2 - Option 3 + Option 1 + Option 2 + Option 3
@@ -284,127 +298,65 @@ const App = () => { ### Multiple -To allow multiple options to be selected, use the `multiple` attribute. With this option, `value` will be an array of strings instead of a string. It's a good practice to use `clearable` when this option is enabled. - -```html preview - - Option 1 - Option 2 - Option 3 - - Option 4 - Option 5 - Option 6 - -``` - -```jsx react -import { SlDivider, SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - - Option 1 - Option 2 - Option 3 - - Option 4 - Option 5 - Option 6 - -); -``` - -?> When using the `multiple` option, the value will be an array instead of a string. You may need to [set the selection imperatively](#setting-the-selection-imperatively) unless you're using a framework that supports binding properties declaratively. +TODO ### Grouping Options -Options can be grouped visually using menu labels and dividers. - -```html preview - - Group 1 - Option 1 - Option 2 - Option 3 - - Group 2 - Option 4 - Option 5 - Option 6 - -``` - -```jsx react -import { SlDivider, SlMenuItem, SlMenuLabel, SlSelect } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - - Group 1 - Option 1 - Option 2 - Option 3 - - Group 2 - Option 4 - Option 5 - Option 6 - -); -``` +TODO ### Sizes Use the `size` attribute to change a select's size. ```html preview - - Option 1 - Option 2 - Option 3 + + Option 1 + Option 2 + Option 3
- - Option 1 - Option 2 - Option 3 + + Option 1 + Option 2 + Option 3
- - Option 1 - Option 2 - Option 3 + + Option 1 + Option 2 + Option 3 ``` ```jsx react -import { SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/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 + + Option 1 + Option 2 + Option 3
- - Option 1 - Option 2 - Option 3 + + Option 1 + Option 2 + Option 3 ); @@ -416,84 +368,78 @@ The preferred placement of the select's menu can be set with the `placement` att ```html preview - Option 1 - Option 2 - Option 3 + Option 1 + Option 2 + Option 3 ``` ```jsx react import { - SlMenuItem, + SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; const App = () => ( - Option 1 - Option 2 - Option 3 + Option 1 + Option 2 + Option 3 ); ``` -### Prefix & Suffix Icons +### Prefix Icons -Use the `prefix` and `suffix` slots to add 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 - + Option 1 + Option 2 + Option 3
- + - Option 1 - Option 2 - Option 3 - + Option 1 + Option 2 + Option 3 ``` ```jsx react -import { SlIcon, SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/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 - + Option 1 + Option 2 + Option 3
- Option 1 - Option 2 - Option 3 - + Option 1 + Option 2 + Option 3 ); diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index 62f96a5b4..7878732af 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -8,6 +8,13 @@ New versions of Shoelace are released as-needed and generally occur when a criti ?> During the beta period, these restrictions may be relaxed in the event of a mission-critical bug. 🐛 +## Next + +This release includes a complete rewrite of `` to improve accessibility and improve simplify the internal structure. + +- 🚨 BREAKING: removed the `multiple` attribute from `` because it was inaccessible and made the getting/setting the value inconsistent and confusing (see the docs for a suggested multiselect pattern) +- 🚨 BREAKING: removed the `suffix` slot from `` because it was confusing to users and its position made the clear button inaccessible + ## 2.0.0-beta.87 - 🚨 BREAKING: changed the default size of medium checkboxes, radios, and switches to 18px instead of 16px diff --git a/src/components/dropdown/dropdown.styles.ts b/src/components/dropdown/dropdown.styles.ts index fd0bb84fb..32fb234a9 100644 --- a/src/components/dropdown/dropdown.styles.ts +++ b/src/components/dropdown/dropdown.styles.ts @@ -36,7 +36,6 @@ export default css` font-family: var(--sl-font-sans); font-size: var(--sl-font-size-medium); font-weight: var(--sl-font-weight-normal); - color: var(--color); box-shadow: var(--sl-shadow-large); border-radius: var(--sl-border-radius-medium); pointer-events: none; diff --git a/src/components/input/input.ts b/src/components/input/input.ts index f4c6cf6c7..7a4f202a6 100644 --- a/src/components/input/input.ts +++ b/src/components/input/input.ts @@ -88,9 +88,6 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont | 'time' | 'url' = 'text'; - /** The input's size. */ - @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; - /** The name of the input, submitted as a name/value pair with form data. */ @property() name = ''; @@ -100,6 +97,9 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont /** The default value of the form control. Primarily used for resetting the form control. */ @defaultValue() defaultValue = ''; + /** The input's size. */ + @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; + /** Draws a filled input. */ @property({ type: Boolean, reflect: true }) filled = false; diff --git a/src/components/menu-item/menu-item.ts b/src/components/menu-item/menu-item.ts index 85d08dad0..20fa17f14 100644 --- a/src/components/menu-item/menu-item.ts +++ b/src/components/menu-item/menu-item.ts @@ -44,7 +44,7 @@ export default class SlMenuItem extends ShoelaceElement { /** A unique value to store in the menu item. This can be used as a way to identify menu items when selected. */ @property() value = ''; - /** Draws the menu item in a disabled state. */ + /** Draws the menu item in a disabled state, preventing selection. */ @property({ type: Boolean, reflect: true }) disabled = false; firstUpdated() { diff --git a/src/components/option/option.styles.ts b/src/components/option/option.styles.ts new file mode 100644 index 000000000..9718c016d --- /dev/null +++ b/src/components/option/option.styles.ts @@ -0,0 +1,70 @@ +import { css } from 'lit'; +import componentStyles from '../../styles/component.styles'; + +export default css` + ${componentStyles} + + :host { + display: block; + user-select: none; + } + + .option { + position: relative; + display: flex; + align-items: stretch; + font-family: var(--sl-font-sans); + font-size: var(--sl-font-size-medium); + font-weight: var(--sl-font-weight-normal); + line-height: var(--sl-line-height-normal); + letter-spacing: var(--sl-letter-spacing-normal); + color: var(--sl-color-neutral-700); + padding: var(--sl-spacing-2x-small) var(--sl-spacing-2x-small); + transition: var(--sl-transition-fast) fill; + user-select: none; + white-space: nowrap; + cursor: pointer; + } + + :host(:hover) .option { + background-color: var(--sl-color-neutral-100); + color: var(--sl-color-neutral-1000); + } + + :host([aria-selected='true']) .option { + background-color: var(--sl-color-primary-600); + color: var(--sl-color-neutral-0); + } + + .option.option--disabled { + outline: none; + opacity: 0.5; + cursor: not-allowed; + } + + .option__label { + flex: 1 1 auto; + display: inline-block; + padding: 0 var(--sl-spacing-large); + } + + .option__prefix { + flex: 0 0 auto; + display: flex; + align-items: center; + } + + .option__prefix::slotted(*) { + margin-inline-end: var(--sl-spacing-x-small); + } + + .option__suffix { + flex: 0 0 auto; + display: flex; + align-items: center; + } + + .option__suffix::slotted(*) { + margin-inline-start: var(--sl-spacing-x-small); + } +`; diff --git a/src/components/option/option.test.ts b/src/components/option/option.test.ts new file mode 100644 index 000000000..123f0a4f1 --- /dev/null +++ b/src/components/option/option.test.ts @@ -0,0 +1,9 @@ +import { expect, fixture, html } from '@open-wc/testing'; + +describe('', () => { + it('should render a component', async () => { + const el = await fixture(html` `); + + expect(el).to.exist; + }); +}); diff --git a/src/components/option/option.ts b/src/components/option/option.ts new file mode 100644 index 000000000..eeb104b5c --- /dev/null +++ b/src/components/option/option.ts @@ -0,0 +1,58 @@ +import { html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import ShoelaceElement from '../../internal/shoelace-element'; +import { LocalizeController } from '../../utilities/localize'; +import styles from './option.styles'; +import type { CSSResultGroup } from 'lit'; + +/** + * @summary Short summary of the component's intended use. + * + * @since 2.0 + * @status experimental + * + * @dependency sl-icon + * + * @event sl-event-name - Emitted as an example. + * + * @slot - The default slot. + * @slot example - An example slot. + * + * @csspart base - The component's base wrapper. + * + * @cssproperty --example - An example CSS custom property. + */ +@customElement('sl-option') +export default class SlOption extends ShoelaceElement { + static styles: CSSResultGroup = styles; + + private readonly localize = new LocalizeController(this); + + /** The option's value. When selected, the containing form control will receive this value. */ + @property() value = ''; + + /** Draws the option in a disabled state, preventing selection. */ + @property({ type: Boolean, reflect: true }) disabled = false; + + connectedCallback() { + super.connectedCallback(); + this.setAttribute('role', 'option'); + this.setAttribute('aria-selected', 'false'); + } + + render() { + return html` +
+ + + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-option': SlOption; + } +} diff --git a/src/components/select/select.styles.ts b/src/components/select/select.styles.ts index f01fcfef8..06bdb3daf 100644 --- a/src/components/select/select.styles.ts +++ b/src/components/select/select.styles.ts @@ -10,55 +10,58 @@ export default css` display: block; } + /** The popup */ .select { - display: block; - } - - .select::part(panel) { - overflow: hidden; - } - - .select__control { - display: inline-flex; - align-items: center; - justify-content: start; + flex: 1 1 auto; + display: flex; position: relative; + } + + .select::part(popup) { + z-index: var(--sl-z-index-dropdown); + } + + .select--top::part(popup) { + transform-origin: bottom; + } + + .select--bottom::part(popup) { + transform-origin: top; + } + + /* Combobox */ + .select__combobox-wrapper { + flex: 1 0 auto; + display: flex; width: 100%; font-family: var(--sl-input-font-family); font-weight: var(--sl-input-font-weight); letter-spacing: var(--sl-input-letter-spacing); vertical-align: middle; - overflow: hidden; - transition: var(--sl-transition-fast) color, var(--sl-transition-fast) border, var(--sl-transition-fast) box-shadow; + cursor: text; + transition: var(--sl-transition-fast) color, var(--sl-transition-fast) border, var(--sl-transition-fast) box-shadow, + var(--sl-transition-fast) background-color; cursor: pointer; } - .select::part(panel) { - border-radius: var(--sl-border-radius-medium); + .select__combobox { + flex: 1 0 auto; + display: flex; + align-items: stretch; + justify-content: start; } - /* Standard selects */ - .select--standard .select__control { - background-color: var(--sl-input-background-color); - border: solid var(--sl-input-border-width) var(--sl-input-border-color); - color: var(--sl-input-color); - } - - .select--standard:not(.select--disabled) .select__control:hover { - background-color: var(--sl-input-background-color-hover); - border-color: var(--sl-input-border-color-hover); - color: var(--sl-input-color-hover); - } - - .select--standard.select--focused:not(.select--disabled) .select__control { - background-color: var(--sl-input-background-color-focus); - border-color: var(--sl-input-border-color-focus); - color: var(--sl-input-color-focus); - box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-input-focus-ring-color); + .select__combobox:focus { outline: none; } - .select--standard.select--disabled .select__control { + /* Standard selects */ + .select--standard .select__combobox-wrapper { + background-color: var(--sl-input-background-color); + border: solid var(--sl-input-border-width) var(--sl-input-border-color); + } + + .select--standard.select--disabled .select__combobox-wrapper { background-color: var(--sl-input-background-color-disabled); border-color: var(--sl-input-border-color-disabled); color: var(--sl-input-color-disabled); @@ -67,64 +70,120 @@ export default css` outline: none; } + .select--standard:not(.select--disabled).select--open .select__combobox-wrapper, + .select--standard:not(.select--disabled).select--focused .select__combobox-wrapper { + background-color: var(--sl-input-background-color-focus); + border-color: var(--sl-input-border-color-focus); + box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-input-focus-ring-color); + } + /* Filled selects */ - .select--filled .select__control { + .select--filled .select__combobox-wrapper { border: none; background-color: var(--sl-input-filled-background-color); color: var(--sl-input-color); } - .select--filled:hover:not(.select--disabled) .select__control { + .select--filled:hover:not(.select--disabled) .select__combobox-wrapper { background-color: var(--sl-input-filled-background-color-hover); } - .select--filled.select--focused:not(.select--disabled) .select__control { - background-color: var(--sl-input-filled-background-color-focus); - outline: var(--sl-focus-ring); - outline-offset: var(--sl-focus-ring-offset); - } - - .select--filled.select--disabled .select__control { + .select--filled.select--disabled .select__combobox-wrapper { background-color: var(--sl-input-filled-background-color-disabled); opacity: 0.5; cursor: not-allowed; } - .select--disabled .select__tags, - .select--disabled .select__clear { - pointer-events: none; + .select--filled:not(.select--disabled).select--open .select__combobox-wrapper, + .select--filled:not(.select--disabled).select--focused .select__combobox-wrapper { + background-color: var(--sl-input-filled-background-color-focus); + outline: var(--sl-focus-ring); } + /* Sizes */ + .select--small .select__combobox-wrapper { + border-radius: var(--sl-input-border-radius-small); + font-size: var(--sl-input-font-size-small); + min-height: var(--sl-input-height-small); + padding: 0 var(--sl-input-spacing-small); + } + + .select--small .select__clear { + margin-inline-start: var(--sl-input-spacing-small); + } + + .select--small .select__prefix::slotted(*) { + margin-inline-end: var(--sl-input-spacing-small); + } + + .select--medium .select__combobox-wrapper { + border-radius: var(--sl-input-border-radius-medium); + font-size: var(--sl-input-font-size-medium); + height: var(--sl-input-height-medium); + padding: 0 var(--sl-input-spacing-medium); + } + + .select--medium .select__clear { + margin-inline-start: var(--sl-input-spacing-medium); + } + + .select--medium .select__prefix::slotted(*) { + margin-inline-end: var(--sl-input-spacing-medium); + } + + .select--large .select__combobox-wrapper { + border-radius: var(--sl-input-border-radius-large); + font-size: var(--sl-input-font-size-large); + min-height: var(--sl-input-height-large); + padding: 0 var(--sl-input-spacing-large); + } + + .select--large .select__clear { + margin-inline-start: var(--sl-input-spacing-large); + } + + .select--large .select__prefix::slotted(*) { + margin-inline-end: var(--sl-input-spacing-large); + } + + /* Pills */ + .select--pill.select--small .select__combobox-wrapper { + border-radius: var(--sl-input-height-small); + } + + .select--pill.select--medium .select__combobox-wrapper { + border-radius: var(--sl-input-height-medium); + } + + .select--pill.select--large .select__combobox-wrapper { + border-radius: var(--sl-input-height-large); + } + + /* Display label */ + .select__display-label { + flex: 1 1 auto; + display: flex; + align-items: center; + user-select: none; + } + + .select--placeholder-visible .select__display-label { + color: var(--sl-input-placeholder-color); + } + + /* Prefix */ .select__prefix { + flex: 0; display: inline-flex; align-items: center; color: var(--sl-input-placeholder-color); } - .select__label { - flex: 1 1 auto; - display: flex; - align-items: center; - user-select: none; - overflow-x: auto; - overflow-y: hidden; - white-space: nowrap; - - /* Hide scrollbar in Firefox */ - scrollbar-width: none; - } - - /* Hide scrollbar in Chrome/Safari */ - .select__label::-webkit-scrollbar { - width: 0; - height: 0; - } - + /* Clear button */ .select__clear { - flex: 0 0 auto; display: inline-flex; align-items: center; - width: 1.25em; + justify-content: center; font-size: inherit; color: var(--sl-input-icon-color); border: none; @@ -138,197 +197,45 @@ export default css` color: var(--sl-input-icon-color-hover); } - .select__suffix { - display: inline-flex; - align-items: center; - color: var(--sl-input-placeholder-color); + .select__clear:focus { + outline: none; } - .select__icon { + /* Expand icon */ + .select__expand-icon { flex: 0 0 auto; - display: inline-flex; + display: flex; + align-items: center; transition: var(--sl-transition-medium) rotate ease; + rotate: 0; + margin-inline-start: var(--sl-spacing-small); } - .select--open .select__icon { + .select--open .select__expand-icon { rotate: -180deg; } - /* Placeholder */ - .select--placeholder-visible .select__label { - color: var(--sl-input-placeholder-color); + /* Listbox */ + .select__listbox { + display: block; + position: relative; + font-family: var(--sl-font-sans); + font-size: var(--sl-font-size-medium); + font-weight: var(--sl-font-weight-normal); + box-shadow: var(--sl-shadow-large); + background: var(--sl-panel-background-color); + border: solid var(--sl-panel-border-width) var(--sl-panel-border-color); + border-radius: var(--sl-border-radius-medium); + padding: var(--sl-spacing-x-small) 0; + overflow: auto; + overscroll-behavior: none; + + /* Make sure it adheres to the popup's auto size */ + max-width: var(--auto-size-available-width); + max-height: var(--auto-size-available-height); } - .select--disabled.select--placeholder-visible .select__label { - color: var(--sl-input-placeholder-color-disabled); - } - - /* Tags */ - .select__tags { - display: inline-flex; - align-items: center; - flex-wrap: wrap; - justify-content: left; - margin-inline-start: var(--sl-spacing-2x-small); - } - - /* Hidden input (for form control validation to show) */ - .select__hidden-select { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - clip: rect(0 0 0 0); - clip-path: inset(50%); - overflow: hidden; - white-space: nowrap; - } - - /* - * Size modifiers - */ - - /* Small */ - .select--small .select__control { - border-radius: var(--sl-input-border-radius-small); - font-size: var(--sl-input-font-size-small); - min-height: var(--sl-input-height-small); - } - - .select--small .select__prefix::slotted(*) { - margin-inline-start: var(--sl-input-spacing-small); - } - - .select--small .select__label { - margin: 0 var(--sl-input-spacing-small); - } - - .select--small .select__clear { - margin-inline-end: var(--sl-input-spacing-small); - } - - .select--small .select__suffix::slotted(*) { - margin-inline-end: var(--sl-input-spacing-small); - } - - .select--small .select__icon { - margin-inline-end: var(--sl-input-spacing-small); - } - - .select--small .select__tags { - padding-bottom: 2px; - } - - .select--small .select__tags sl-tag { - padding-top: 2px; - } - - .select--small .select__tags sl-tag:not(:last-of-type) { - margin-inline-end: var(--sl-spacing-2x-small); - } - - .select--small.select--has-tags .select__label { - margin-inline-start: 0; - } - - /* Medium */ - .select--medium .select__control { - border-radius: var(--sl-input-border-radius-medium); - font-size: var(--sl-input-font-size-medium); - min-height: var(--sl-input-height-medium); - } - - .select--medium .select__prefix::slotted(*) { - margin-inline-start: var(--sl-input-spacing-medium); - } - - .select--medium .select__label { - margin: 0 var(--sl-input-spacing-medium); - } - - .select--medium .select__clear { - margin-inline-end: var(--sl-input-spacing-medium); - } - - .select--medium .select__suffix::slotted(*) { - margin-inline-end: var(--sl-input-spacing-medium); - } - - .select--medium .select__icon { - margin-inline-end: var(--sl-input-spacing-medium); - } - - .select--medium .select__tags { - padding-bottom: 3px; - } - - .select--medium .select__tags sl-tag { - padding-top: 3px; - } - - .select--medium .select__tags sl-tag:not(:last-of-type) { - margin-inline-end: var(--sl-spacing-2x-small); - } - - .select--medium.select--has-tags .select__label { - margin-inline-start: 0; - } - - /* Large */ - .select--large .select__control { - border-radius: var(--sl-input-border-radius-large); - font-size: var(--sl-input-font-size-large); - min-height: var(--sl-input-height-large); - } - - .select--large .select__prefix::slotted(*) { - margin-inline-start: var(--sl-input-spacing-large); - } - - .select--large .select__label { - margin: 0 var(--sl-input-spacing-large); - } - - .select--large .select__clear { - margin-inline-end: var(--sl-input-spacing-large); - } - - .select--large .select__suffix::slotted(*) { - margin-inline-end: var(--sl-input-spacing-large); - } - - .select--large .select__icon { - margin-inline-end: var(--sl-input-spacing-large); - } - - .select--large .select__tags { - padding-bottom: 4px; - } - .select--large .select__tags sl-tag { - padding-top: 4px; - } - - .select--large .select__tags sl-tag:not(:last-of-type) { - margin-inline-end: var(--sl-spacing-2x-small); - } - - .select--large.select--has-tags .select__label { - margin-inline-start: 0; - } - - /* - * Pill modifier - */ - .select--pill.select--small .select__control { - border-radius: var(--sl-input-height-small); - } - - .select--pill.select--medium .select__control { - border-radius: var(--sl-input-height-medium); - } - - .select--pill.select--large .select__control { - border-radius: var(--sl-input-height-large); + .select__listbox::slotted(sl-divider) { + --spacing: var(--sl-spacing-x-small); } `; diff --git a/src/components/select/select.ts b/src/components/select/select.ts index cbf33dc60..f4d428b64 100644 --- a/src/components/select/select.ts +++ b/src/components/select/select.ts @@ -1,117 +1,92 @@ import { html } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; +import { scrollIntoView } from 'src/internal/scroll'; +import { animateTo, stopAnimations } from '../../internal/animate'; import { defaultValue } from '../../internal/default-value'; +import { waitForEvent } from '../../internal/event'; import { FormSubmitController } from '../../internal/form'; import ShoelaceElement from '../../internal/shoelace-element'; import { HasSlotController } from '../../internal/slot'; import { watch } from '../../internal/watch'; +import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry'; import { LocalizeController } from '../../utilities/localize'; -import '../dropdown/dropdown'; -import '../icon-button/icon-button'; -import '../icon/icon'; -import '../menu/menu'; -import '../tag/tag'; import styles from './select.styles'; import type { ShoelaceFormControl } from '../../internal/shoelace-element'; -import type SlDropdown from '../dropdown/dropdown'; -import type SlIconButton from '../icon-button/icon-button'; -import type SlMenuItem from '../menu-item/menu-item'; -import type { MenuSelectEventDetail } from '../menu/menu'; -import type SlMenu from '../menu/menu'; -import type { TemplateResult, CSSResultGroup } from 'lit'; +import type SlOption from '../option/option'; +import type SlPopup from '../popup/popup'; +import type { CSSResultGroup } from 'lit'; /** - * @summary Selects allow you to choose one or more items from a dropdown menu. + * @summary Selects allow you to choose items from a menu of predefined options. * * @since 2.0 * @status stable * - * @dependency sl-dropdown * @dependency sl-icon - * @dependency sl-icon-button - * @dependency sl-menu - * @dependency sl-tag * * @slot - The select's options in the form of menu items. - * @slot prefix - A presentational icon or similar element to prepend to the select's label. - * @slot suffix - A presentational icon or similar element to append to the select's label. - * @slot clear-icon - An icon to use in lieu of the default clear icon. Works best with ``. - * @slot label - The select's label. Alternatively, you can use the `label` attribute. - * @slot help-text - Text that describes how to use the select. Alternatively, you can use the `help-text` attribute. * - * @event sl-clear - Emitted when the clear button is activated. * @event sl-change - Emitted when the control's value changes. + * @event sl-clear - Emitted when the control's value is cleared. * @event sl-input - Emitted when the control receives input. * @event sl-focus - Emitted when the control gains focus. * @event sl-blur - Emitted when the control loses focus. + * @event sl-show - Emitted when the select's menu opens. + * @event sl-after-show - Emitted after the select's menu opens and all animations are complete. + * @event sl-hide - Emitted when the select's menu closes. + * @event sl-after-hide - Emitted after the select's menu closes and all animations are complete. * * @csspart form-control - The form control that wraps the label, input, and help text. * @csspart form-control-label - The label's wrapper. * @csspart form-control-input - The select's wrapper. * @csspart form-control-help-text - The help text's wrapper. - * @csspart base - The component's base wrapper. - * @csspart clear-button - The clear button. - * @csspart control - The container that holds the prefix, label, and suffix. - * @csspart display-label - The label that displays the current selection. Not available when used with `multiple`. - * @csspart icon - The select's expand/collapse icon. - * @csspart prefix - The container that wraps the prefix. - * @csspart suffix - The container that wraps the suffix. - * @csspart menu - The select's menu, an `` element. - * @csspart tags - The container that wraps tags when using `multiple`. - * @csspart tag - Tags that represent selected options when using `multiple`. - * @csspart tag__base - The tag's exported `base` part. - * @csspart tag__content - The tag's exported `content` part. - * @csspart tag__remove-button - The tag's exported `remove-button` part. */ @customElement('sl-select') export default class SlSelect extends ShoelaceElement implements ShoelaceFormControl { static styles: CSSResultGroup = styles; - @query('.select') dropdown: SlDropdown; - @query('.select__control') control: SlDropdown; - @query('.select__hidden-select') input: HTMLInputElement; - @query('.select__menu') menu: SlMenu; + @query('.select') popup: SlPopup; + @query('.select__combobox') combobox: HTMLSlotElement; + @query('.select__listbox') listbox: HTMLSlotElement; // @ts-expect-error -- Controller is currently unused private readonly formSubmitController = new FormSubmitController(this); private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label'); private readonly localize = new LocalizeController(this); - private menuItems: SlMenuItem[] = []; - private resizeObserver: ResizeObserver; + private typeToSelectString = ''; + private typeToSelectTimeout: number; + @state() displayLabel = ''; @state() private hasFocus = false; - @state() private isOpen = false; - @state() private displayLabel = ''; - @state() private displayTags: TemplateResult[] = []; @state() invalid = false; - /** Enables multiselect. With this enabled, value will be an array. */ - @property({ type: Boolean, reflect: true }) multiple = false; - - /** - * The maximum number of tags 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 -1 to remove the limit. - */ - @property({ attribute: 'max-tags-visible', type: Number }) maxTagsVisible = 3; - - /** Disables the select control. */ - @property({ type: Boolean, reflect: true }) disabled = 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: string | string[] = ''; + @property() value = ''; - /** Placeholder text to show as a hint when the input is empty. */ - @property() placeholder = ''; + /** The default value of the form control. Primarily used for resetting the form control. */ + @defaultValue() defaultValue = ''; /** The select's size. */ @property() size: 'small' | 'medium' | 'large' = 'medium'; + /** Placeholder text to show as a hint when the select is empty. */ + @property() placeholder = ''; + + /** Disables the select control. */ + @property({ type: Boolean, reflect: true }) disabled = false; + /** - * Enable this option to prevent the panel from being clipped when the component is placed inside a container with + * Indicates whether or not the select is open. You can toggle this attribute to show and hide the menu, or you can + * use the `show()` and `hide()` methods and this attribute will reflect the select's open state. + */ + @property({ type: Boolean, reflect: true }) open = false; + + /** + * Enable this option to prevent the listbox from being clipped when the component is placed inside a container with * `overflow: auto|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all, scenarios. */ @property({ type: Boolean }) hoist = false; @@ -126,10 +101,10 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon @property() label = ''; /** - * The preferred placement of the select's menu. Note that the actual placement may vary as needed to keep the panel + * The preferred placement of the select's menu. Note that the actual placement may vary as needed to keep the listbox * inside of the viewport. */ - @property() placement: 'top' | 'bottom' = 'bottom'; + @property({ reflect: true }) placement: 'top' | 'bottom' = 'bottom'; /** The select's help text. If you need to display HTML, use the `help-text` slot instead. */ @property({ attribute: 'help-text' }) helpText = ''; @@ -140,348 +115,377 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon /** Adds a clear button when the select is not empty. */ @property({ type: Boolean }) clearable = false; - /** The default value of the form control. Primarily used for resetting the form control. */ - @defaultValue() defaultValue = ''; - connectedCallback() { super.connectedCallback(); - this.resizeObserver = new ResizeObserver(() => this.resizeMenu()); + this.handleDocumentFocusIn = this.handleDocumentFocusIn.bind(this); + this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this); + this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this); - this.updateComplete.then(() => { - this.resizeObserver.observe(this); - this.syncItemsFromValue(); - }); - } - - firstUpdated() { - this.invalid = !this.input.checkValidity(); - } - - disconnectedCallback() { - super.disconnectedCallback(); - this.resizeObserver.unobserve(this); + // Because this is a form control, it shouldn't be opened initially + this.open = false; } /** Checks for validity but does not show the browser's validation message. */ checkValidity() { - return this.input.checkValidity(); + // return this.input.checkValidity(); } /** Checks for validity and shows the browser's validation message if the control is invalid. */ reportValidity() { - return this.input.reportValidity(); + // return this.input.reportValidity(); } /** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */ setCustomValidity(message: string) { - this.input.setCustomValidity(message); + // this.input.setCustomValidity(message); this.invalid = !this.input.checkValidity(); } - getValueAsArray() { - // Single selects use '' as an empty selection value, so convert this to [] for an empty multiselect - if (this.multiple && this.value === '') { - return []; - } - - return Array.isArray(this.value) ? this.value : [this.value]; - } - /** Sets focus on the control. */ focus(options?: FocusOptions) { - this.control.focus(options); + // this.control.focus(options); } /** Removes focus from the control. */ blur() { - this.control.blur(); - } - - handleBlur() { - // Don't blur if the control is open. We'll move focus back once it closes. - if (!this.isOpen) { - this.hasFocus = false; - this.emit('sl-blur'); - } - } - - handleClearClick(event: MouseEvent) { - const oldValue = this.value; - - event.stopPropagation(); - this.value = this.multiple ? [] : ''; - - if (this.value !== oldValue) { - this.emit('sl-clear'); - this.emit('sl-input'); - this.emit('sl-change'); - } - - this.syncItemsFromValue(); - } - - @watch('disabled', { waitUntilFirstUpdate: true }) - handleDisabledChange() { - if (this.disabled && this.isOpen) { - this.dropdown.hide(); - } - - // Disabled form controls are always valid, so we need to recheck validity when the state changes - this.input.disabled = this.disabled; - this.invalid = !this.input.checkValidity(); + // this.control.blur(); } handleFocus() { - if (!this.hasFocus) { - this.hasFocus = true; - this.emit('sl-focus'); + this.hasFocus = true; + } + + handleBlur() { + this.hasFocus = false; + } + + handleDocumentFocusIn(event: KeyboardEvent) { + // Close when focusing out of the select + const path = event.composedPath(); + if (this && !path.includes(this)) { + this.hide(); } } - handleKeyDown(event: KeyboardEvent) { - const target = event.target as HTMLElement; - const firstItem = this.menuItems[0]; - const lastItem = this.menuItems[this.menuItems.length - 1]; - - // Ignore key presses on tags - if (target.tagName.toLowerCase() === 'sl-tag') { - return; - } - - // Tabbing out of the control closes it - if (event.key === 'Tab') { - if (this.isOpen) { - this.dropdown.hide(); - } - return; - } - - // Up/down opens the menu - if (['ArrowDown', 'ArrowUp'].includes(event.key)) { + handleDocumentKeyDown(event: KeyboardEvent) { + // Close when pressing escape + if (event.key === 'Escape' && this.open) { event.preventDefault(); - - // Show the menu if it's not already open - if (!this.isOpen) { - this.dropdown.show(); - } - - // Focus on a menu item - if (event.key === 'ArrowDown') { - this.menu.setCurrentItem(firstItem); - firstItem.focus(); - return; - } - - if (event.key === 'ArrowUp') { - this.menu.setCurrentItem(lastItem); - lastItem.focus(); - return; - } - } - - // don't open the menu when a CTRL/Command key is pressed - if (event.ctrlKey || event.metaKey) { - return; - } - - // All other "printable" keys open the menu and initiate type to select - if (!this.isOpen && event.key.length === 1) { event.stopPropagation(); - event.preventDefault(); - this.dropdown.show(); - this.menu.typeToSelect(event); + 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.focus(); + this.combobox.focus(); } - handleMenuSelect(event: CustomEvent) { - const item = event.detail.item; - const oldValue = this.value; + // 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; + } - if (this.multiple) { - this.value = this.value.includes(item.value) - ? (this.value as []).filter(v => v !== item.value) - : [...this.value, item.value]; - } else { - this.value = item.value; + handleComboboxKeyDown(event: KeyboardEvent) { + // 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(); + + // If it's not open, open it + if (!this.open) { + this.show(); + return; + } + + // If it is open, update the value based on the current selection and close it + const selectedOption = this.getSelectedOption(); + if (selectedOption) { + this.value = selectedOption.value; + this.displayLabel = selectedOption.textContent ?? ''; + } + + this.hide(); + this.combobox.focus(); + return; } - if (this.value !== oldValue) { - this.emit('sl-change'); - this.emit('sl-input'); + // Navigate options + if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { + const allOptions = this.getAllOptions(); + const selectedOption = this.getSelectedOption(); + const selectedIndex = allOptions.indexOf(selectedOption); + let newIndex = Math.max(0, selectedIndex); + + // Prevent scrolling + event.preventDefault(); + + // Open it + if (!this.open) { + this.show(); + + // 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 (selectedOption) { + return; + } + } + + if (event.key === 'ArrowDown') { + newIndex = selectedIndex + 1; + if (newIndex > allOptions.length - 1) newIndex = 0; + } else if (event.key === 'ArrowUp') { + newIndex = selectedIndex - 1; + if (newIndex < 0) newIndex = allOptions.length - 1; + } else if (event.key === 'Home') { + newIndex = 0; + } else if (event.key === 'End') { + newIndex = allOptions.length - 1; + } + + this.setSelectedOption(allOptions[newIndex]); } - this.syncItemsFromValue(); - } + // All other "printable" keys trigger type to select + if (event.key.length === 1 || event.key === 'Backspace') { + const allOptions = this.getAllOptions(); - handleMenuShow() { - this.resizeMenu(); - this.isOpen = true; - } + // Don't block important key combos like CMD+R + if (event.metaKey || event.ctrlKey || event.altKey) { + return; + } - handleMenuHide() { - this.isOpen = false; + // Open, unless the key that triggered is backspace + if (!this.open) { + if (event.key === 'Backspace') { + return; + } - // Restore focus on the box after the menu is hidden - this.control.focus(); - } + this.show(); + } - handleMenuItemLabelChange() { - // Update the display label when checked menu item's label changes - if (!this.multiple) { - const checkedItem = this.menuItems.find(item => item.value === this.value); - this.displayLabel = checkedItem ? checkedItem.getTextLabel() : ''; + event.stopPropagation(); + event.preventDefault(); + + clearTimeout(this.typeToSelectTimeout); + this.typeToSelectTimeout = window.setTimeout(() => (this.typeToSelectString = ''), 1000); + + if (event.key === 'Backspace') { + this.typeToSelectString = this.typeToSelectString.slice(0, -1); + } else { + this.typeToSelectString += event.key.toLowerCase(); + } + + console.log(this.typeToSelectString); + + for (const option of allOptions) { + const label = (option.textContent ?? '').toLowerCase(); + + if (label.startsWith(this.typeToSelectString)) { + this.setSelectedOption(option); + break; + } + } } } - @watch('multiple') - handleMultipleChange() { - // Cast to array | string based on `this.multiple` - const value = this.getValueAsArray(); - this.value = this.multiple ? value : value[0] ?? ''; - this.syncItemsFromValue(); + handleClearClick(event: MouseEvent) { + event.stopPropagation(); + + if (this.value !== '') { + this.value = ''; + this.displayLabel = ''; + this.emit('sl-clear'); + } } - async handleMenuSlotChange() { - // Wait for items to render before gathering labels otherwise the slot won't exist - this.menuItems = [...this.querySelectorAll('sl-menu-item')]; + handleClearMouseDown(event: MouseEvent) { + event.stopPropagation(); + } + + // We use mousedown/mouseup instead of click to allow macOS-style menu behavior + handleOptionMouseUp(event: MouseEvent) { + const target = event.target as HTMLElement; + const option = target.closest('sl-option'); + if (!option) { + return; + } + + this.value = option.value; + this.hide(); + this.combobox.focus(); + } + + handleDefaultSlotChange() { + const allOptions = this.getAllOptions(); + const values: string[] = []; // Check for duplicate values in menu items - const values: string[] = []; - this.menuItems.forEach(item => { - if (values.includes(item.value)) { - console.error(`Duplicate value found in menu item: '${item.value}'`, item); + allOptions.forEach(option => { + if (values.includes(option.value)) { + console.error(`Duplicate value found in `, option); } - - values.push(item.value); + values.push(option.value); }); - await Promise.all(this.menuItems.map(item => item.render)); - this.syncItemsFromValue(); + // Update the selected option + const option = this.getOptionByValue(this.value); + if (option) { + this.setSelectedOption(option); + this.value = option.value; + this.displayLabel = option.textContent ?? ''; + } else { + // Clear selection + this.setSelectedOption(null); + } } - handleTagInteraction(event: KeyboardEvent | MouseEvent) { - // Don't toggle the menu when a tag's clear button is activated - const path = event.composedPath(); - const clearButton = path.find((el: SlIconButton) => { - if (el instanceof HTMLElement) { - const element = el as HTMLElement; - return element.classList.contains('tag__remove'); - } - return false; - }); + // Gets an array of all elements + getAllOptions() { + return [...this.querySelectorAll('sl-option')]; + } - if (clearButton) { - event.stopPropagation(); + // Gets an option based on its value + getOptionByValue(value: string) { + return this.getAllOptions().filter((el: SlOption) => el.value === value)[0]; + } + + // Gets the option that currently has aria-selected="true" + getSelectedOption() { + return this.getAllOptions().filter(el => el.getAttribute('aria-selected') === 'true')[0]; + } + + // Adds aria-selected to the target option and removes it from all others + setSelectedOption(option: SlOption | null) { + const allOptions = this.getAllOptions(); + + // Clear selection + allOptions.forEach(el => el.setAttribute('aria-selected', 'false')); + + // Select the target option + if (option) { + option.setAttribute('aria-selected', 'true'); + scrollIntoView(option, this.listbox); } } @watch('value', { waitUntilFirstUpdate: true }) - async handleValueChange() { - this.syncItemsFromValue(); - await this.updateComplete; + handleValueChange() { + const option = this.getOptionByValue(this.value); - this.invalid = !this.input.checkValidity(); + // Update the selection + this.setSelectedOption(option); + + if (option) { + this.value = option.value; + this.displayLabel = option.textContent ?? ''; + } else { + // No option, reset the control + this.value = ''; + this.displayLabel = ''; + } } - resizeMenu() { - this.menu.style.width = `${this.control.clientWidth}px`; - requestAnimationFrame(() => this.dropdown.reposition()); + /** Shows the listbox. */ + async show() { + if (this.open || this.disabled) { + this.open = false; + return undefined; + } + + this.open = true; + return waitForEvent(this, 'sl-after-show'); } - syncItemsFromValue() { - const value = this.getValueAsArray(); + /** Hides the listbox. */ + async hide() { + if (!this.open || this.disabled) { + this.open = false; + return undefined; + } - // Sync checked states - this.menuItems.forEach(item => (item.checked = value.includes(item.value))); + this.open = false; + return waitForEvent(this, 'sl-after-hide'); + } - // Sync display label and tags - if (this.multiple) { - const checkedItems = this.menuItems.filter(item => value.includes(item.value)); + addOpenListeners() { + document.addEventListener('focusin', this.handleDocumentFocusIn); + document.addEventListener('keydown', this.handleDocumentKeyDown); + document.addEventListener('mousedown', this.handleDocumentMouseDown); + } - this.displayLabel = checkedItems.length > 0 ? checkedItems[0].getTextLabel() : ''; - this.displayTags = checkedItems.map((item: SlMenuItem) => { - return html` - { - event.stopPropagation(); - if (!this.disabled) { - item.checked = false; - this.syncValueFromItems(); - } - }} - > - ${item.getTextLabel()} - - `; + 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) { + this.hide(); + return; + } + + if (this.open) { + // Show + this.emit('sl-show'); + this.addOpenListeners(); + + await stopAnimations(this); + this.listbox.hidden = false; + this.popup.active = true; + + // Make sure the current option is selected + this.setSelectedOption(this.getOptionByValue(this.value)); + + // Scroll the selected option into view + requestAnimationFrame(() => { + const selectedOption = this.getSelectedOption(); + if (selectedOption) { + // + // TODO - improve this logic so the selected option is centered in the listbox instead of at the top + // + this.listbox.scrollTop = selectedOption.offsetTop; + } }); - if (this.maxTagsVisible > 0 && this.displayTags.length > this.maxTagsVisible) { - const total = this.displayTags.length; - this.displayLabel = ''; - this.displayTags = this.displayTags.slice(0, this.maxTagsVisible); - this.displayTags.push(html` - - +${total - this.maxTagsVisible} - - `); - } + const { keyframes, options } = getAnimation(this, 'select.show', { dir: this.localize.dir() }); + await animateTo(this.popup.popup, keyframes, options); + + this.emit('sl-after-show'); } else { - const checkedItem = this.menuItems.find(item => item.value === value[0]); + // Hide + this.emit('sl-hide'); + this.removeOpenListeners(); - this.displayLabel = checkedItem ? checkedItem.getTextLabel() : ''; - this.displayTags = []; - } - } + await stopAnimations(this); + const { keyframes, options } = getAnimation(this, 'select.hide', { dir: this.localize.dir() }); + await animateTo(this.popup.popup, keyframes, options); + this.listbox.hidden = true; + this.popup.active = false; - syncValueFromItems() { - const checkedItems = this.menuItems.filter(item => item.checked); - const checkedValues = checkedItems.map(item => item.value); - const oldValue = this.value; - - if (this.multiple) { - this.value = (this.value as []).filter(val => checkedValues.includes(val)); - } else { - this.value = checkedValues.length > 0 ? checkedValues[0] : ''; - } - - if (this.value !== oldValue) { - this.emit('sl-change'); - this.emit('sl-input'); + this.emit('sl-after-hide'); } } render() { const hasLabelSlot = this.hasSlotController.test('label'); const hasHelpTextSlot = this.hasSlotController.test('help-text'); - const hasSelection = this.multiple ? this.value.length > 0 : this.value !== ''; const hasLabel = this.label ? true : !!hasLabelSlot; const hasHelpText = this.helpText ? true : !!hasHelpTextSlot; - const hasClearIcon = this.clearable && !this.disabled && hasSelection; + const hasClearIcon = this.clearable && !this.disabled && this.value.length > 0; + const isPlaceholderVisible = this.value === ''; + const isRtl = this.localize.dir() === 'rtl'; return html`
- 0, - 'select--placeholder-visible': this.displayLabel === '', + 'select--pill': this.pill, + 'select--open': this.open, + 'select--disabled': this.disabled, + 'select--focused': this.hasFocus, + 'select--placeholder-visible': isPlaceholderVisible, + 'select--top': this.placement === 'top', + 'select--bottom': this.placement === 'bottom', 'select--small': this.size === 'small', 'select--medium': this.size === 'medium', - 'select--large': this.size === 'large', - 'select--pill': this.pill, - 'select--invalid': this.invalid + 'select--large': this.size === 'large' })} - @sl-show=${this.handleMenuShow} - @sl-hide=${this.handleMenuHide} + placement=${this.placement} + strategy=${this.hoist ? 'fixed' : 'absolute'} + flip + shift + sync="width" + auto-size="vertical" + auto-size-padding="10" >
- - -
- ${this.displayTags.length > 0 - ? html` ${this.displayTags} ` - : this.displayLabel.length > 0 - ? this.displayLabel - : this.placeholder} +
+ + ${isPlaceholderVisible ? this.placeholder : this.displayLabel}
${hasClearIcon @@ -565,8 +565,10 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
- - - - -
+ + - - ${this.helpText} - + + ${this.helpText} + +
`; } } +setDefaultAnimation('select.show', { + keyframes: [ + { opacity: 0, scale: 0.9 }, + { opacity: 1, scale: 1 } + ], + options: { duration: 100, easing: 'ease' } +}); + +setDefaultAnimation('select.hide', { + keyframes: [ + { opacity: 1, scale: 1 }, + { opacity: 0, scale: 0.9 } + ], + options: { duration: 100, easing: 'ease' } +}); + declare global { interface HTMLElementTagNameMap { 'sl-select': SlSelect; diff --git a/src/shoelace.ts b/src/shoelace.ts index a84bd4820..a325a42cb 100644 --- a/src/shoelace.ts +++ b/src/shoelace.ts @@ -53,6 +53,7 @@ export { default as SlTooltip } from './components/tooltip/tooltip'; export { default as SlTree } from './components/tree/tree'; export { default as SlTreeItem } from './components/tree-item/tree-item'; export { default as SlVisuallyHidden } from './components/visually-hidden/visually-hidden'; +export { default as SlOption } from './components/option/option'; /* plop:component */ // Utilities