- 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`
`;
}
}
+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