Files
webawesome/src/components/select/select.tsx
2020-12-23 15:47:13 -05:00

492 lines
15 KiB
TypeScript

import { Component, Element, Event, EventEmitter, Method, Prop, State, Watch, h } from '@stencil/core';
import FormControl from '../../functional-components/form-control/form-control';
import { getTextContent } from '../../utilities/slot';
import { hasSlot } from '../../utilities/slot';
let id = 0;
/**
* @since 2.0
* @status stable
*
* @slot - The select's options in the form of menu items.
* @slot label - The select's label. Alternatively, you can use the label prop.
* @slot help-text - Help text that describes how to use the select.
*
* @part base - The component's base wrapper.
* @part clear-button - The input's clear button, exported from <sl-input>.
* @part form-control - The form control that wraps the label, input, and help text.
* @part help-text - The select's help text.
* @part icon - The select's icon.
* @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 {
box: HTMLElement;
dropdown: HTMLSlDropdownElement;
input: HTMLInputElement;
inputId = `select-${++id}`;
labelId = `select-label-${id}`;
helpTextId = `select-help-text-${id}`;
menu: HTMLSlMenuElement;
resizeObserver: ResizeObserver;
@Element() host: HTMLSlSelectElement;
@State() hasFocus = false;
@State() hasHelpTextSlot = false;
@State() hasLabelSlot = 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. Alternatively, you can use the label slot. */
@Prop() label = '';
/** The select's help text. Alternatively, you can use the help-text slot. */
@Prop() helpText = '';
/** 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('disabled')
handleDisabledChange() {
if (this.disabled && this.isOpen) {
this.dropdown.hide();
}
}
@Watch('label')
handleLabelChange() {
this.handleSlotChange();
}
@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({ eventName: 'sl-change' }) slChange: EventEmitter;
/** Emitted when the control gains focus. */
@Event({ eventName: 'sl-focus' }) slFocus: EventEmitter;
/** Emitted when the control loses focus. */
@Event({ eventName: 'sl-blur' }) slBlur: EventEmitter;
connectedCallback() {
this.handleBlur = this.handleBlur.bind(this);
this.handleFocus = this.handleFocus.bind(this);
this.handleClearClick = this.handleClearClick.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleLabelClick = this.handleLabelClick.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);
this.handleTagInteraction = this.handleTagInteraction.bind(this);
this.host.shadowRoot.addEventListener('slotchange', this.handleSlotChange);
}
componentWillLoad() {
this.handleSlotChange();
}
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());
}
disconnectedCallback() {
this.host.shadowRoot.removeEventListener('slotchange', this.handleSlotChange);
}
/** 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();
}
handleClearClick(event: MouseEvent) {
event.stopPropagation();
this.value = this.multiple ? [] : '';
this.syncItemsFromValue();
}
handleKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
const items = this.getItems();
const firstItem = items[0];
const lastItem = items[items.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)) {
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' && firstItem) {
firstItem.setFocus();
return;
}
if (event.key === 'ArrowUp' && lastItem) {
lastItem.setFocus();
return;
}
}
// All other keys open the menu and initiate type to select
if (!this.isOpen) {
event.stopPropagation();
event.preventDefault();
this.dropdown.show();
this.menu.typeToSelect(event.key);
}
}
handleLabelClick() {
this.box.focus();
}
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;
}
handleSlotChange() {
this.hasHelpTextSlot = hasSlot(this.host, 'help-text');
this.hasLabelSlot = hasSlot(this.host, 'label');
this.syncItemsFromValue();
this.reportDuplicateItemValues();
}
handleTagInteraction(event: KeyboardEvent | 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.box.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.handleTagInteraction}
onKeyDown={this.handleTagInteraction}
onSl-clear={event => {
event.stopPropagation();
if (!this.disabled) {
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.displayLabel = '';
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>
);
}
} 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() {
const hasSelection = this.multiple ? this.value.length > 0 : this.value !== '';
return (
<FormControl
inputId={this.inputId}
label={this.label}
labelId={this.labelId}
hasLabelSlot={this.hasLabelSlot}
helpTextId={this.helpTextId}
helpText={this.helpText}
hasHelpTextSlot={this.hasHelpTextSlot}
size={this.size}
onLabelClick={this.handleLabelClick}
>
<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--clearable': this.clearable,
'select--disabled': this.disabled,
'select--multiple': this.multiple,
'select--has-tags': this.multiple && hasSelection,
'select--placeholder-visible': this.displayLabel === '',
'select--small': this.size === 'small',
'select--medium': this.size === 'medium',
'select--large': this.size === 'large',
'select--pill': this.pill,
'select--invalid': this.invalid
}}
onSl-show={this.handleMenuShow}
onSl-hide={this.handleMenuHide}
>
<div
slot="trigger"
ref={el => (this.box = el)}
id={this.inputId}
class="select__box"
role="combobox"
aria-labelledby={this.labelId}
aria-describedby={this.helpTextId}
aria-haspopup="true"
aria-expanded={this.isOpen ? 'true' : 'false'}
tabIndex={this.disabled ? -1 : 0}
onBlur={this.handleBlur}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown}
>
<div class="select__label">
{this.displayTags.length ? (
<span part="tags" class="select__tags">
{this.displayTags}
</span>
) : (
this.displayLabel || this.placeholder
)}
</div>
{this.clearable && hasSelection && (
<sl-icon-button
part="clear-button"
class="select__clear"
name="x-circle"
onClick={this.handleClearClick}
tabindex="-1"
/>
)}
<span part="icon" class="select__icon">
<sl-icon name="chevron-down" />
</span>
{/*
The hidden input tricks the browser's built-in validation so it works as expected. We use an input instead
of a select because, otherwise, iOS will show a list of options during validation.
*/}
<input
ref={el => (this.input = el)}
class="select__hidden-select"
aria-hidden="true"
required={this.required}
value={hasSelection ? '1' : ''}
tabIndex={-1}
/>
</div>
<sl-menu ref={el => (this.menu = el)} part="menu" class="select__menu" onSl-select={this.handleMenuSelect}>
<slot onSlotchange={this.handleSlotChange} />
</sl-menu>
</sl-dropdown>
</FormControl>
);
}
}