mirror of
https://github.com/shoelace-style/shoelace.git
synced 2026-01-12 11:09:13 +00:00
441 lines
13 KiB
TypeScript
441 lines
13 KiB
TypeScript
import { Component, Element, Event, EventEmitter, Method, Prop, State, Watch, h } from '@stencil/core';
|
|
import ResizeObserver from 'resize-observer-polyfill';
|
|
import { getTextContent } from '../../utilities/slot';
|
|
|
|
let id = 0;
|
|
|
|
/**
|
|
* @since 2.0
|
|
* @status stable
|
|
*
|
|
* @slot - The select's options in the form of menu items.
|
|
* @slot help-text - Help text that describes how to use the select.
|
|
*
|
|
* @part base - The component's base wrapper.
|
|
* @part form-control - The form control that wraps the label and the input.
|
|
* @part help-text - The select's help text.
|
|
* @part icon - The select's icon.
|
|
* @part input - The select's input control.
|
|
* @part label - The select's label.
|
|
* @part menu - The select menu, a <sl-menu> element.
|
|
* @part tag - The multiselect option, a <sl-tag> element.
|
|
* @part tags - The container in which multiselect options are rendered.
|
|
*/
|
|
|
|
@Component({
|
|
tag: 'sl-select',
|
|
styleUrl: 'select.scss',
|
|
shadow: true
|
|
})
|
|
export class Select {
|
|
dropdown: HTMLSlDropdownElement;
|
|
input: HTMLSlInputElement;
|
|
inputId = `select-${++id}`;
|
|
labelId = `select-label-${id}`;
|
|
helpTextId = `select-help-text-${id}`;
|
|
menu: HTMLSlMenuElement;
|
|
resizeObserver: any;
|
|
|
|
@Element() host: HTMLSlSelectElement;
|
|
|
|
@State() hasFocus = false;
|
|
@State() isOpen = false;
|
|
@State() items = [];
|
|
@State() displayLabel = '';
|
|
@State() displayTags = [];
|
|
|
|
/** Set to true to enable multiselect. */
|
|
@Prop() 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.
|
|
*/
|
|
@Prop() maxTagsVisible = 3;
|
|
|
|
/** Set to true to disable the select control. */
|
|
@Prop() disabled = false;
|
|
|
|
/** The select's name. */
|
|
@Prop() name = '';
|
|
|
|
/** The select's placeholder text. */
|
|
@Prop() placeholder = '';
|
|
|
|
/** The select's size. */
|
|
@Prop() size: 'small' | 'medium' | 'large' = 'medium';
|
|
|
|
/**
|
|
* Enable this option to prevent the panel from being clipped when the component is placed inside a container with
|
|
* `overflow: auto|scroll`.
|
|
*/
|
|
@Prop() hoist = false;
|
|
|
|
/** The value of the control. This will be a string or an array depending on `multiple`. */
|
|
@Prop({ mutable: true }) value: string | Array<string> = '';
|
|
|
|
/** Set to true to draw a pill-style select with rounded edges. */
|
|
@Prop() pill = false;
|
|
|
|
/** The select's label. */
|
|
@Prop() label = '';
|
|
|
|
/** The select's required attribute. */
|
|
@Prop() required = false;
|
|
|
|
/** Set to true to add a clear button when the select is populated. */
|
|
@Prop() clearable = false;
|
|
|
|
/** This will be true when the control is in an invalid state. Validity is determined by the `required` prop. */
|
|
@Prop({ mutable: true }) invalid = false;
|
|
|
|
@Watch('multiple')
|
|
handleMultipleChange() {
|
|
// Cast to array | string based on `this.multiple`
|
|
const value = this.getValueAsArray();
|
|
this.value = this.multiple ? value : value[0] || '';
|
|
this.syncItemsFromValue();
|
|
}
|
|
|
|
@Watch('value')
|
|
handleValueChange() {
|
|
this.syncItemsFromValue();
|
|
this.slChange.emit();
|
|
}
|
|
|
|
/** Emitted when the control's value changes. */
|
|
@Event() slChange: EventEmitter;
|
|
|
|
/** Emitted when the control gains focus */
|
|
@Event() slFocus: EventEmitter;
|
|
|
|
/** Emitted when the control loses focus */
|
|
@Event() slBlur: EventEmitter;
|
|
|
|
connectedCallback() {
|
|
this.handleBlur = this.handleBlur.bind(this);
|
|
this.handleFocus = this.handleFocus.bind(this);
|
|
this.handleClear = this.handleClear.bind(this);
|
|
this.handleKeyDown = this.handleKeyDown.bind(this);
|
|
this.handleLabelClick = this.handleLabelClick.bind(this);
|
|
this.handleTagClick = this.handleTagClick.bind(this);
|
|
this.handleMenuKeyDown = this.handleMenuKeyDown.bind(this);
|
|
this.handleMenuHide = this.handleMenuHide.bind(this);
|
|
this.handleMenuShow = this.handleMenuShow.bind(this);
|
|
this.handleMenuSelect = this.handleMenuSelect.bind(this);
|
|
this.handleSlotChange = this.handleSlotChange.bind(this);
|
|
}
|
|
|
|
componentDidLoad() {
|
|
this.resizeObserver = new ResizeObserver(() => this.resizeMenu());
|
|
this.reportDuplicateItemValues();
|
|
|
|
// We need to do an initial sync after the component has rendered, so this will suppress the re-render warning
|
|
requestAnimationFrame(() => this.syncItemsFromValue());
|
|
}
|
|
|
|
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
|
@Method()
|
|
async reportValidity() {
|
|
return this.input.reportValidity();
|
|
}
|
|
|
|
/** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */
|
|
@Method()
|
|
async setCustomValidity(message: string) {
|
|
// this.input.setCustomValidity(message);
|
|
// this.invalid = !this.input.checkValidity();
|
|
}
|
|
|
|
getItemLabel(item: HTMLSlMenuItemElement) {
|
|
const slot = item.shadowRoot.querySelector('slot:not([name])') as HTMLSlotElement;
|
|
return getTextContent(slot);
|
|
}
|
|
|
|
getItems() {
|
|
return [...this.host.querySelectorAll('sl-menu-item')];
|
|
}
|
|
|
|
getValueAsArray() {
|
|
return Array.isArray(this.value) ? this.value : [this.value];
|
|
}
|
|
|
|
handleBlur() {
|
|
this.hasFocus = false;
|
|
this.slBlur.emit();
|
|
}
|
|
|
|
handleFocus() {
|
|
this.hasFocus = true;
|
|
this.slFocus.emit();
|
|
this.input.setSelectionRange(0, 0);
|
|
}
|
|
|
|
handleClear() {
|
|
this.value = this.multiple ? [] : '';
|
|
this.syncItemsFromValue();
|
|
this.dropdown.hide();
|
|
}
|
|
|
|
handleKeyDown(event: KeyboardEvent) {
|
|
const target = event.target as HTMLElement;
|
|
|
|
// Open the dropdown when enter is pressed while the input is focused
|
|
if (!this.isOpen && event.key === 'Enter' && target === this.input) {
|
|
this.dropdown.show();
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
|
|
handleLabelClick() {
|
|
this.input.setFocus();
|
|
}
|
|
|
|
handleMenuKeyDown(event: KeyboardEvent) {
|
|
// Close when escape or tab pressed
|
|
if (event.key === 'Escape' || event.key === 'Tab') {
|
|
this.dropdown.hide();
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
|
|
handleMenuSelect(event: CustomEvent) {
|
|
const item = event.detail.item;
|
|
|
|
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;
|
|
}
|
|
|
|
this.syncItemsFromValue();
|
|
}
|
|
|
|
handleMenuShow(event: CustomEvent) {
|
|
if (this.disabled) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
this.resizeMenu();
|
|
this.resizeObserver.observe(this.host);
|
|
this.isOpen = true;
|
|
}
|
|
|
|
handleMenuHide() {
|
|
this.resizeObserver.unobserve(this.host);
|
|
this.isOpen = false;
|
|
this.input.setFocus();
|
|
}
|
|
|
|
handleSlotChange() {
|
|
this.syncItemsFromValue();
|
|
this.reportDuplicateItemValues();
|
|
}
|
|
|
|
handleTagClick(event: MouseEvent) {
|
|
// Don't toggle the menu when a tag's clear button is activated
|
|
const path = event.composedPath() as Array<EventTarget>;
|
|
const clearButton = path.find(el => {
|
|
if (el instanceof HTMLElement) {
|
|
const element = el as HTMLElement;
|
|
return element.getAttribute('part') === 'clear-button';
|
|
}
|
|
});
|
|
|
|
if (clearButton) {
|
|
event.stopPropagation();
|
|
}
|
|
}
|
|
|
|
reportDuplicateItemValues() {
|
|
const items = this.getItems();
|
|
|
|
// Report duplicate values since they can break selection logic
|
|
const duplicateValues = items.map(item => item.value).filter((e, i, a) => a.indexOf(e) !== i);
|
|
if (duplicateValues.length) {
|
|
throw new Error('Duplicate value found on <sl-menu-item> in <sl-select>: "' + duplicateValues.join('", "') + '"');
|
|
}
|
|
}
|
|
|
|
resizeMenu() {
|
|
this.menu.style.width = `${this.input.clientWidth}px`;
|
|
}
|
|
|
|
syncItemsFromValue() {
|
|
const items = this.getItems();
|
|
const value = this.getValueAsArray();
|
|
|
|
// Sync checked states
|
|
items.map(item => (item.checked = value.includes(item.value)));
|
|
|
|
// Sync display label
|
|
if (this.multiple) {
|
|
const checkedItems = [];
|
|
value.map(val => items.map(item => (item.value === val ? checkedItems.push(item) : null)));
|
|
|
|
this.displayTags = checkedItems.map(item => {
|
|
return (
|
|
<sl-tag
|
|
exportparts="base:tag"
|
|
type="info"
|
|
size={this.size}
|
|
pill={this.pill}
|
|
clearable
|
|
onClick={this.handleTagClick}
|
|
onSlClear={event => {
|
|
event.stopPropagation();
|
|
item.checked = false;
|
|
this.syncValueFromItems();
|
|
}}
|
|
>
|
|
{this.getItemLabel(item)}
|
|
</sl-tag>
|
|
);
|
|
});
|
|
|
|
if (this.maxTagsVisible > 0 && this.displayTags.length > this.maxTagsVisible) {
|
|
const total = this.displayTags.length;
|
|
this.displayTags = this.displayTags.slice(0, this.maxTagsVisible);
|
|
this.displayTags.push(
|
|
<sl-tag exportparts="base:tag" type="info" size={this.size}>
|
|
+{total - this.maxTagsVisible}
|
|
</sl-tag>
|
|
);
|
|
}
|
|
|
|
this.displayLabel = '';
|
|
} else {
|
|
const checkedItem = items.filter(item => item.value === value[0])[0];
|
|
this.displayLabel = checkedItem ? this.getItemLabel(checkedItem) : '';
|
|
this.displayTags = [];
|
|
}
|
|
}
|
|
|
|
syncValueFromItems() {
|
|
const items = this.getItems();
|
|
const checkedItems = items.filter(item => item.checked);
|
|
const checkedValues = checkedItems.map(item => item.value);
|
|
|
|
if (this.multiple) {
|
|
this.value = (this.value as []).filter(val => checkedValues.includes(val));
|
|
} else {
|
|
this.value = checkedValues.length > 0 ? checkedValues[0] : '';
|
|
}
|
|
}
|
|
|
|
render() {
|
|
return (
|
|
<div
|
|
part="form-control"
|
|
class={{
|
|
'form-control': true,
|
|
'form-control--has-label': this.label.length > 0,
|
|
'form-control--invalid': this.invalid
|
|
}}
|
|
>
|
|
<label
|
|
part="label"
|
|
id={this.labelId}
|
|
class={{
|
|
label: true,
|
|
'label--small': this.size === 'small',
|
|
'label--medium': this.size === 'medium',
|
|
'label--large': this.size === 'large',
|
|
'label--invalid': this.invalid
|
|
}}
|
|
htmlFor={this.inputId}
|
|
onClick={this.handleLabelClick}
|
|
>
|
|
{this.label}
|
|
</label>
|
|
|
|
<sl-dropdown
|
|
part="base"
|
|
ref={el => (this.dropdown = el)}
|
|
hoist={this.hoist}
|
|
closeOnSelect={!this.multiple}
|
|
containingElement={this.host}
|
|
class={{
|
|
select: true,
|
|
'select--open': this.isOpen,
|
|
'select--empty': this.value?.length === 0,
|
|
'select--focused': this.hasFocus,
|
|
'select--disabled': this.disabled,
|
|
'select--multiple': this.multiple,
|
|
'select--has-tags': this.multiple && this.displayTags.length > 0,
|
|
'select--small': this.size === 'small',
|
|
'select--medium': this.size === 'medium',
|
|
'select--large': this.size === 'large',
|
|
'select--pill': this.pill
|
|
}}
|
|
onSlShow={this.handleMenuShow}
|
|
onSlHide={this.handleMenuHide}
|
|
>
|
|
<sl-input
|
|
slot="trigger"
|
|
part="input"
|
|
ref={el => (this.input = el)}
|
|
id={this.inputId}
|
|
class="select__input"
|
|
name={this.name}
|
|
value={this.displayLabel}
|
|
disabled={this.disabled}
|
|
pill={this.pill}
|
|
placeholder={this.displayLabel === '' && this.displayTags.length === 0 ? this.placeholder : null}
|
|
readonly={true}
|
|
size={this.size}
|
|
invalid={this.invalid}
|
|
clearable={this.clearable}
|
|
required={this.required}
|
|
aria-labelledby={this.labelId}
|
|
aria-describedby={this.helpTextId}
|
|
onSlFocus={this.handleFocus}
|
|
onSlBlur={this.handleBlur}
|
|
onSlClear={this.handleClear}
|
|
onKeyDown={this.handleKeyDown}
|
|
>
|
|
{this.displayTags.length && (
|
|
<span part="tags" slot="prefix" class="select__tags">
|
|
{this.displayTags}
|
|
</span>
|
|
)}
|
|
|
|
<span part="icon" slot="suffix" class="select__icon">
|
|
<sl-icon name="chevron-down" />
|
|
</span>
|
|
</sl-input>
|
|
|
|
<sl-menu
|
|
ref={el => (this.menu = el)}
|
|
part="menu"
|
|
class="select__menu"
|
|
onSlSelect={this.handleMenuSelect}
|
|
onKeyDown={this.handleMenuKeyDown}
|
|
>
|
|
<slot onSlotchange={this.handleSlotChange} />
|
|
</sl-menu>
|
|
</sl-dropdown>
|
|
|
|
<div
|
|
part="help-text"
|
|
id={this.helpTextId}
|
|
class={{
|
|
'help-text': true,
|
|
'help-text--small': this.size === 'small',
|
|
'help-text--medium': this.size === 'medium',
|
|
'help-text--large': this.size === 'large',
|
|
'help-text--invalid': this.invalid
|
|
}}
|
|
>
|
|
<slot name="help-text" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|