diff --git a/packages/picker/src/Picker.ts b/packages/picker/src/Picker.ts index 6fdcd5f090d..22da462bc19 100644 --- a/packages/picker/src/Picker.ts +++ b/packages/picker/src/Picker.ts @@ -40,6 +40,7 @@ import type { Tooltip } from '@spectrum-web-components/tooltip'; import '@spectrum-web-components/icons-ui/icons/sp-icon-chevron100.js'; import '@spectrum-web-components/icons-workflow/icons/sp-icon-alert.js'; import '@spectrum-web-components/menu/sp-menu.js'; +import '@spectrum-web-components/tags/sp-tag.js'; import type { Menu, MenuItem, @@ -139,7 +140,39 @@ export class PickerBase extends SizedMixin(SpectrumElement, { @property({ type: Boolean, reflect: true }) public readonly = false; - public selects: undefined | 'single' = 'single'; + /** + * When `true`, the user can select multiple options. + * When `multiple` is enabled, the value attribute will be a space-delimited list of values + * based on the options selected, and the value property will be an array. + * For this reason, values must not contain spaces. + */ + @property({ type: Boolean, reflect: true }) + public multiple = false; + + /** + * The maximum number of selected options to show when `multiple` is true. + * After the maximum, "+n" will be shown to indicate the number of additional items that are selected. + * Set to 0 to remove the limit. + */ + @property({ type: Number, attribute: 'max-options-visible' }) + public maxOptionsVisible = 3; + + /** + * A function that customizes the tags to be rendered when multiple=true. + * The first argument is the option, the second is the current tag's index. + * The function should return either a Lit TemplateResult or a string containing + * trusted HTML of the symbol to render at the specified value. + */ + @property({ attribute: false }) + public renderTag?: ( + option: MenuItem, + index: number + ) => TemplateResult | string; + + @state() + protected _selectedItems: MenuItem[] = []; + + public selects: undefined | 'single' | 'multiple' = 'single'; @state() public labelAlignment?: 'inline'; @@ -177,8 +210,20 @@ export class PickerBase extends SizedMixin(SpectrumElement, { @property({ type: String }) public value = ''; + /** + * When TypeScript compiler needs help with the types + */ + public get typedValue(): string | string[] { + return this.value; + } + @property({ attribute: false }) public get selectedItem(): MenuItem | undefined { + if (this.multiple) { + return this._selectedItems.length > 0 + ? this._selectedItems[0] + : undefined; + } return this._selectedItem; } @@ -194,6 +239,36 @@ export class PickerBase extends SizedMixin(SpectrumElement, { } public set selectedItem(selectedItem: MenuItem | undefined) { + if (this.multiple) { + if (!selectedItem) { + this._selectedItems = []; + this.selectedItemContent = undefined; + this.value = ''; + return; + } + + const index = this._selectedItems.indexOf(selectedItem); + if (index === -1) { + this._selectedItems = [...this._selectedItems, selectedItem]; + } else { + this._selectedItems = this._selectedItems.filter( + (_, i) => i !== index + ); + } + + // @ts-expect-error Type 'MenuItemChildren[] | undefined' is not assignable to type 'MenuItemChildren | undefined' + this.selectedItemContent = this._selectedItems.length + ? this._selectedItems.map((item) => item.itemChildren) + : undefined; + + // Use string value for DOM attributes + this.value = this._selectedItems.length + ? this._selectedItems.map((item) => item.value).join(' ') + : ''; + + return; + } + this.selectedItemContent = selectedItem ? selectedItem.itemChildren : undefined; @@ -318,48 +393,86 @@ export class PickerBase extends SizedMixin(SpectrumElement, { item: MenuItem, menuChangeEvent?: Event ): Promise { - this.open = false; - // should always close when "setting" a value - const oldSelectedItem = this.selectedItem; - const oldValue = this.value; + if (this.readonly) { + return; + } - // Set a value. - this.selectedItem = item; - this.value = item?.value ?? ''; - await this.updateComplete; - const applyDefault = this.dispatchEvent( - new Event('change', { - bubbles: true, - // Allow it to be prevented. - cancelable: true, - composed: true, - }) - ); - if (!applyDefault && this.selects) { - if (menuChangeEvent) { - menuChangeEvent.preventDefault(); - } - this.setMenuItemSelected(this.selectedItem as MenuItem, false); - if (oldSelectedItem) { - this.setMenuItemSelected(oldSelectedItem, true); + if (this.multiple) { + const index = this._selectedItems.findIndex( + (i) => i.value === item.value + ); + const wasSelected = index !== -1; + + if (wasSelected) { + // Deselect the item + this._selectedItems = this._selectedItems.filter( + (i) => i.value !== item.value + ); + this.setMenuItemSelected(item, false); + } else { + // Select the item + this._selectedItems = [...this._selectedItems, item]; + this.setMenuItemSelected(item, true); } - this.selectedItem = oldSelectedItem; - this.value = oldValue; - this.open = true; - if (this.strategy) { - this.strategy.open = true; + + // Update the value property as an array internally + const valueArray = this._selectedItems.map( + (selectedItem) => selectedItem.value + ); + + // For the attribute, use space-delimited string + const oldValue = this.value; + this.value = valueArray.join(' '); + + if (oldValue !== this.value) { + this.selectedItemContent = + this._selectedItems.length > 0 + ? (this._selectedItems.map( + (item) => item.itemChildren + ) as unknown as MenuItemChildren) + : undefined; + + if (menuChangeEvent) { + this.handleChange(menuChangeEvent); + } else { + const changeEvent = new Event('change', { + bubbles: true, + }); + this.dispatchEvent(changeEvent); + } } + + // Keep the menu open for multiple selection return; - } else if (!this.selects) { - // Unset the value if not carrying a selection - this.selectedItem = oldSelectedItem; - this.value = oldValue; - return; } - if (oldSelectedItem) { - this.setMenuItemSelected(oldSelectedItem, false); + + // Original single selection behavior + const oldValue = this.value; + this.value = item.value; + this.selectedItem = item; + + if (oldValue !== this.value) { + const selectedItems = this.menuItems; + for (const selectedItem of selectedItems) { + this.setMenuItemSelected( + selectedItem, + selectedItem.value === this.value + ); + } + + if (menuChangeEvent) { + this.handleChange(menuChangeEvent); + } else { + const changeEvent = new Event('change', { + bubbles: true, + }); + this.dispatchEvent(changeEvent); + } + } + + if (!this.multiple) { + await this.close(); } - this.setMenuItemSelected(item, !!this.selects); } protected setMenuItemSelected(item: MenuItem, value: boolean): void { @@ -453,7 +566,76 @@ export class PickerBase extends SizedMixin(SpectrumElement, { `; } + private renderSelectedTags(): TemplateResult { + if (!this.multiple || this._selectedItems.length === 0) { + return html``; + } + + const visibleCount = + this.maxOptionsVisible > 0 + ? Math.min(this._selectedItems.length, this.maxOptionsVisible) + : this._selectedItems.length; + + const hiddenCount = this._selectedItems.length - visibleCount; + + return html` +
+ ${this._selectedItems + .slice(0, visibleCount) + .map((item, index) => { + if (this.renderTag) { + const customTag = this.renderTag(item, index); + if (typeof customTag === 'string') { + return html` + ${customTag} + `; + } + return customTag; + } + + // Get icon from menu item's children if available + const itemChildren = item.itemChildren || {}; + const hasIcon = + 'icon' in itemChildren && !!itemChildren.icon; + + return html` + { + event.stopPropagation(); + this.setValueFromItem(item); + }} + > + ${hasIcon + ? html` + + ${itemChildren.icon} + + ` + : nothing} + ${item.textContent} + + `; + })} + ${hiddenCount > 0 + ? html` + + +${hiddenCount} + + ` + : nothing} +
+ `; + } + protected get buttonContent(): TemplateResult[] { + if (this.multiple && this._selectedItems.length > 0) { + return [this.renderSelectedTags()]; + } + const labelClasses = { 'visually-hidden': this.icons === 'only' && !!this.value, placeholder: !this.value, @@ -671,6 +853,32 @@ export class PickerBase extends SizedMixin(SpectrumElement, { if (changes.has('open')) { this.strategy.open = this.open; } + + if (changes.has('multiple')) { + this.selects = this.multiple ? 'multiple' : 'single'; + if (this.optionsMenu) { + this.optionsMenu.selects = this.selects; + } + } + + if (changes.has('value') && this.multiple) { + const valueArray = + typeof this.value === 'string' + ? this.value.split(' ') + : this.value; + + if (this.optionsMenu) { + const validOptions = this.menuItems.filter( + (option) => !!option.value + ); + validOptions.forEach((item) => { + this.setMenuItemSelected( + item, + valueArray.includes(item.value) + ); + }); + } + } } protected override firstUpdated(changes: PropertyValues): void { @@ -810,36 +1018,70 @@ export class PickerBase extends SizedMixin(SpectrumElement, { * updates menu selection based on value */ protected async manageSelection(): Promise { - if (this.selects == null) return; + const values = this.menuItems; + const validOptions = values.filter((option) => !!option.value); + + if (this.multiple) { + // For multiple selection + if (this.value) { + const selectedValues = + typeof this.value === 'string' + ? this.value.split(' ') + : this.value; + + this._selectedItems = validOptions.filter((option) => + selectedValues.includes(option.value) + ); - this.selectionPromise = new Promise( - (res) => (this.selectionResolver = res) - ); - let selectedItem: MenuItem | undefined; - await this.optionsMenu.updateComplete; - if (this.recentlyConnected) { - // Work around for attach timing differences in Safari and Firefox. - // Remove when refactoring to Menu passthrough wrapper. - await new Promise((res) => requestAnimationFrame(() => res(true))); - this.recentlyConnected = false; - } - this.menuItems.forEach((item) => { - if (this.value === item.value && !item.disabled) { - selectedItem = item; + this._selectedItems.forEach((item) => { + this.setMenuItemSelected(item, true); + }); + + if (this._selectedItems.length > 0) { + this.selectedItemContent = this._selectedItems.map( + (item) => item.itemChildren + ) as unknown as MenuItemChildren; + } } else { - item.selected = false; + this._selectedItems = []; + validOptions.forEach((item) => + this.setMenuItemSelected(item, false) + ); } - }); - if (selectedItem) { - selectedItem.selected = !!this.selects; - this.selectedItem = selectedItem; } else { - this.value = ''; - this.selectedItem = undefined; - } - if (this.open) { + if (this.selects == null) return; + + this.selectionPromise = new Promise( + (res) => (this.selectionResolver = res) + ); + let selectedItem: MenuItem | undefined; await this.optionsMenu.updateComplete; - this.optionsMenu.updateSelectedItemIndex(); + if (this.recentlyConnected) { + // Work around for attach timing differences in Safari and Firefox. + // Remove when refactoring to Menu passthrough wrapper. + await new Promise((res) => + requestAnimationFrame(() => res(true)) + ); + this.recentlyConnected = false; + } + this.menuItems.forEach((item) => { + if (this.value === item.value && !item.disabled) { + selectedItem = item; + } else { + item.selected = false; + } + }); + if (selectedItem) { + selectedItem.selected = !!this.selects; + this.selectedItem = selectedItem; + } else { + this.value = ''; + this.selectedItem = undefined; + } + if (this.open) { + await this.optionsMenu.updateComplete; + this.optionsMenu.updateSelectedItemIndex(); + } } this.selectionResolver(); this.willManageSelection = false; diff --git a/packages/picker/src/picker.css b/packages/picker/src/picker.css index 912e5ab45a5..1fae13146c5 100644 --- a/packages/picker/src/picker.css +++ b/packages/picker/src/picker.css @@ -32,6 +32,21 @@ governing permissions and limitations under the License. ); } +:host([multiple]) { + display: flex; + width: 100%; + position: relative; + align-items: center; + justify-content: start; + cursor: pointer; + + --spectrum-picker-block-size: var(--spectrum-component-height-200); +} + +:host([multiple]) #button { + justify-content: flex-start; +} + :host([quiet]) { width: auto; min-width: 0; diff --git a/packages/picker/stories/picker.stories.ts b/packages/picker/stories/picker.stories.ts index d62a2f23824..d699caadf85 100644 --- a/packages/picker/stories/picker.stories.ts +++ b/packages/picker/stories/picker.stories.ts @@ -722,6 +722,82 @@ export const readonly = (args: StoryArgs): TemplateResult => { `; }; +export const multipleSelection = (args: StoryArgs): TemplateResult => { + const items = [ + { value: 'option-1', label: 'Option 1' }, + { value: 'option-2', label: 'Option 2' }, + { value: 'option-3', label: 'Option 3' }, + { value: 'option-4', label: 'Option 4' }, + { value: 'option-5', label: 'Option 5' }, + ]; + + return html` + + Select multiple items: + + + ${items.map( + (item) => html` + + ${item.label} + + ` + )} + + `; +}; +multipleSelection.args = { + maxOptionsVisible: 3, +}; + +export const multipleWithIcons = (args: StoryArgs): TemplateResult => { + return html` + + Select multiple actions: + + + + + Edit + + + + Copy + + + + Delete + + + + The overlay stays open when making multiple selections. Selected + items appear as tags with icons from the menu items. + + `; +}; +multipleWithIcons.args = { + open: true, + maxOptionsVisible: 3, +}; +multipleWithIcons.decorators = [isOverlayOpen]; + export const custom = (args: StoryArgs): TemplateResult => { const initialState = 'lb1-mo'; return html`