From 63095d9fd721cd02ec52321a04f71d9375d279e5 Mon Sep 17 00:00:00 2001 From: Boris Diakur Date: Thu, 26 Jan 2023 16:58:11 +0100 Subject: [PATCH] feat(ld-select): creatable mode --- screenshot/builds/master.json | 15 +- .../be321548175c35b98fd373ca4cfdc518.png | Bin 0 -> 7968 bytes src/liquid/components/ld-button/readme.md | 2 + src/liquid/components/ld-icon/readme.md | 2 + .../ld-option-internal.shadow.css | 4 + .../ld-option-internal/ld-option-internal.tsx | 3 +- .../ld-select/ld-option/ld-option.tsx | 7 +- .../components/ld-select/ld-option/readme.md | 1 - .../ld-select-popper.shadow.css | 46 ++- .../ld-select-popper/ld-select-popper.tsx | 103 +++++- src/liquid/components/ld-select/ld-select.tsx | 124 +++++++- src/liquid/components/ld-select/readme.md | 298 ++++++++++++++++-- .../test/__snapshots__/ld-select.spec.ts.snap | 2 +- .../ld-select/test/ld-select.e2e.ts | 19 ++ .../ld-select/test/ld-select.spec.ts | 252 ++++++++++++++- 15 files changed, 800 insertions(+), 78 deletions(-) create mode 100644 screenshot/images/be321548175c35b98fd373ca4cfdc518.png diff --git a/screenshot/builds/master.json b/screenshot/builds/master.json index 343b58f1dd..9ca74b325f 100644 --- a/screenshot/builds/master.json +++ b/screenshot/builds/master.json @@ -15188,6 +15188,19 @@ "isLandscape": false, "isMobile": false }, + { + "id": "19948d3e", + "image": "be321548175c35b98fd373ca4cfdc518.png", + "userAgent": "default", + "desc": "ld-select filter with create button", + "testPath": "./src/liquid/components/ld-select/test/ld-select.e2e.ts", + "width": 600, + "height": 600, + "deviceScaleFactor": 1, + "hasTouch": false, + "isLandscape": false, + "isMobile": false + }, { "id": "82fda3bf", "image": "71f6a317d5d38f78087a9d107a54579e.png", @@ -32037,4 +32050,4 @@ "isMobile": false } ] -} +} \ No newline at end of file diff --git a/screenshot/images/be321548175c35b98fd373ca4cfdc518.png b/screenshot/images/be321548175c35b98fd373ca4cfdc518.png new file mode 100644 index 0000000000000000000000000000000000000000..c25f139d1d2d76070776c50d9c60a0ad77e5bc28 GIT binary patch literal 7968 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 ZNVuKi7Tb-=2A;e@*Uc` - [ld-cookie-consent](../ld-cookie-consent) - [ld-pagination](../ld-pagination) + - ld-select-popper - [ld-table-header](../ld-table/ld-table-header) ### Graph @@ -675,6 +676,7 @@ Type: `Promise` graph TD; ld-cookie-consent --> ld-button ld-pagination --> ld-button + ld-select-popper --> ld-button ld-table-header --> ld-button style ld-button fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/src/liquid/components/ld-icon/readme.md b/src/liquid/components/ld-icon/readme.md index 02c9f28d37..dc591608fc 100644 --- a/src/liquid/components/ld-icon/readme.md +++ b/src/liquid/components/ld-icon/readme.md @@ -341,6 +341,7 @@ Left-click an icon below to download its SVG file. To download all icons at once - [ld-input-message](../ld-input-message) - [ld-notice](../ld-notice) - [ld-pagination](../ld-pagination) + - ld-select-popper - [ld-sidenav-header](../ld-sidenav/ld-sidenav-header) - [ld-sidenav-navitem](../ld-sidenav/ld-sidenav-navitem) - [ld-sidenav-toggle-outside](../ld-sidenav/ld-sidenav-toggle-outside) @@ -357,6 +358,7 @@ graph TD; ld-input-message --> ld-icon ld-notice --> ld-icon ld-pagination --> ld-icon + ld-select-popper --> ld-icon ld-sidenav-header --> ld-icon ld-sidenav-navitem --> ld-icon ld-sidenav-toggle-outside --> ld-icon diff --git a/src/liquid/components/ld-select/ld-option-internal/ld-option-internal.shadow.css b/src/liquid/components/ld-select/ld-option-internal/ld-option-internal.shadow.css index 1056f9ce2b..1af26157dc 100644 --- a/src/liquid/components/ld-select/ld-option-internal/ld-option-internal.shadow.css +++ b/src/liquid/components/ld-select/ld-option-internal/ld-option-internal.shadow.css @@ -47,6 +47,10 @@ padding-left: var(--ld-option-padding-left-lg); } + &--filtered { + display: none; + } + *, *::before, *::after { diff --git a/src/liquid/components/ld-select/ld-option-internal/ld-option-internal.tsx b/src/liquid/components/ld-select/ld-option-internal/ld-option-internal.tsx index 98d9aaf0b6..8aa2f02bfc 100644 --- a/src/liquid/components/ld-select/ld-option-internal/ld-option-internal.tsx +++ b/src/liquid/components/ld-select/ld-option-internal/ld-option-internal.tsx @@ -55,7 +55,7 @@ export class LdOptionInternal { @Prop() size?: 'sm' | 'lg' /** Set to true on filtering via select input. */ - @Prop({ reflect: true }) hidden = false + @Prop() filtered = false /** * Sets focus internally. @@ -130,6 +130,7 @@ export class LdOptionInternal { class={getClassNames([ 'ld-option-internal', this.size && `ld-option-internal--${this.size}`, + this.filtered && 'ld-option-internal--filtered', ])} role="option" ref={(el) => (this.optionRef = el as HTMLElement)} diff --git a/src/liquid/components/ld-select/ld-option/ld-option.tsx b/src/liquid/components/ld-select/ld-option/ld-option.tsx index 86f8ee6db8..1c164f0dfb 100644 --- a/src/liquid/components/ld-select/ld-option/ld-option.tsx +++ b/src/liquid/components/ld-select/ld-option/ld-option.tsx @@ -29,8 +29,11 @@ export class LdOption { */ @Prop() disabled: boolean - /** Set to true on filtering via select input. */ - @Prop({ reflect: true }) hidden = false + /** + * @internal + * Set to true on filtering via select input. + */ + @Prop() filtered = false componentWillLoad() { // Setting selected via prop directly triggers the mutation observer to fire twice on attribute chage. diff --git a/src/liquid/components/ld-select/ld-option/readme.md b/src/liquid/components/ld-select/ld-option/readme.md index 2d42b6cd6e..a3e4370764 100644 --- a/src/liquid/components/ld-select/ld-option/readme.md +++ b/src/liquid/components/ld-select/ld-option/readme.md @@ -24,7 +24,6 @@ Please refer to the [`ld-select` documentation](components/ld-select) for usage | Property | Attribute | Description | Type | Default | | ---------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----------- | | `disabled` | `disabled` | Disables the option. | `boolean` | `undefined` | -| `hidden` | `hidden` | Set to true on filtering via select input. | `boolean` | `false` | | `key` | `key` | for tracking the node's identity when working with lists | `string \| number` | `undefined` | | `ref` | `ref` | reference to component | `any` | `undefined` | | `selected` | `selected` | If present, this boolean attribute indicates that the option is selected. | `boolean` | `undefined` | diff --git a/src/liquid/components/ld-select/ld-select-popper/ld-select-popper.shadow.css b/src/liquid/components/ld-select/ld-select-popper/ld-select-popper.shadow.css index 96ab734a6f..06d82d681a 100644 --- a/src/liquid/components/ld-select/ld-select-popper/ld-select-popper.shadow.css +++ b/src/liquid/components/ld-select/ld-select-popper/ld-select-popper.shadow.css @@ -40,6 +40,10 @@ border-top: 0; border-radius: var(--ld-br-m); } + + .ld-select-popper--all-filtered & { + border-top: 0; + } } .ld-select-popper__shadow { @@ -57,14 +61,48 @@ } } -.ld-select-popper__filter-input { - appearance: none; +.ld-select-popper__filter-container { + align-items: center; background-color: var(--ld-col-wht); - border: 0; border-top: solid var(--ld-col-neutral-100) var(--ld-sp-1); - box-sizing: border-box; color: var(--ld-col-neutral-900); + display: grid; font: var(--ld-typo-label-m); + grid-template-columns: 1fr auto; + + .ld-select-popper--detached &, + .ld-select-popper--pinned & { + border-top: 0; + border-top-left-radius: var(--ld-br-m); + border-top-right-radius: var(--ld-br-m); + } + + .ld-select-popper--all-filtered & { + border-bottom-left-radius: var(--ld-br-m); + border-bottom-right-radius: var(--ld-br-m); + } +} + +.ld-select-popper__create-button { + font: var(--ld-typo-label-s); + line-height: var(--ld-select-trigger-line-height); + margin-right: var(--ld-sp-8); + + &::part(button) { + --ld-button-padding-x-sm: var(--ld-sp-6); + --ld-button-padding-y-sm: var(--ld-sp-4); + min-height: 0px; + min-width: 0px; + } +} + +.ld-select-popper__filter-input { + appearance: none; + background-color: transparent; + border: 0; + box-sizing: border-box; + color: inherit; + font: inherit; height: 2.5rem; line-height: var(--ld-select-trigger-line-height); outline: none; diff --git a/src/liquid/components/ld-select/ld-select-popper/ld-select-popper.tsx b/src/liquid/components/ld-select/ld-select-popper/ld-select-popper.tsx index 1f43d45ab0..913a55c60c 100644 --- a/src/liquid/components/ld-select/ld-select-popper/ld-select-popper.tsx +++ b/src/liquid/components/ld-select/ld-select-popper/ld-select-popper.tsx @@ -21,9 +21,24 @@ import { getClassNames } from '../../../utils/getClassNames' export class LdSelectPopper { @Element() el: HTMLElement + /** Indicates that all options are filtered (used in creatable mode) */ + @Prop() allOptionsFiltered: boolean + /** A watcher is applied to the CSS class in order to be able to react to tether changes. */ @Prop({ reflect: true }) class: string + /** + * Creatable mode can be enabled when the filter prop is set to true. + * This mode allows the user to create new options using the filter input field. + */ + @Prop() creatable: boolean + + /** The "create" input label (creatable mode). */ + @Prop() createInputLabel: string + + /** The "create" button label (creatable mode). */ + @Prop() createButtonLabel: string + /** Popper is visually detached from the select trigger element (there's a gap between the two). */ @Prop() detached: boolean @@ -33,6 +48,9 @@ export class LdSelectPopper { /** Set this property to `true` in order to enable an input field for filtering options. */ @Prop() filter: boolean + /** The filter input value matches an option (do not allow to create the option). */ + @Prop() filterMatchesOption: boolean + /** The filter input placeholder. */ @Prop() filterPlaceholder: string @@ -47,6 +65,8 @@ export class LdSelectPopper { @State() isPinned = false @State() shadowHeight = '100%' + @State() filterInputValue = '' + @State() canCreate = false /** * @internal @@ -54,10 +74,32 @@ export class LdSelectPopper { */ @Event() ldselectfilterchange: EventEmitter + /** + * @internal + * Emitted on create button click in filter input field. + */ + @Event() ldselectfiltercreate: EventEmitter + private handleFilterInput = (ev) => { + this.filterInputValue = ev.target.value this.ldselectfilterchange.emit(ev.target.value) } + private handleCreate = (ev) => { + ev.preventDefault() + this.ldselectfiltercreate.emit(this.filterInputValue) + this.filterInputValue = '' + } + + @Watch('creatable') + @Watch('filterMatchesOption') + @Watch('filterInputValue') + updateCanCreate() { + this.canCreate = Boolean( + this.creatable && !this.filterMatchesOption && this.filterInputValue + ) + } + @Watch('class') updatePinnedState() { this.isPinned = this.el.classList.contains('ld-tether-pinned') @@ -69,14 +111,25 @@ export class LdSelectPopper { if (newValue) this.el.classList.add(`ld-theme-${newValue}`) } - /** - * Focuses the tab - */ + @Watch('expanded') + updateFilter(newExpanded: boolean) { + if (!newExpanded) { + this.filterInputValue = '' + } + } + + /** Updates shadow height */ @Method() async updateShadowHeight(height: string) { this.shadowHeight = height } + /** Focuses the tab */ + @Method() + async resetFilter() { + this.filterInputValue = '' + } + componentWillLoad() { this.popperClass && this.el.classList.add(this.popperClass) } @@ -95,20 +148,48 @@ export class LdSelectPopper { this.detached && 'ld-select-popper--detached', this.expanded && 'ld-select-popper--expanded', this.filter && 'ld-select-popper--filter', + this.allOptionsFiltered && 'ld-select-popper--all-filtered', this.isPinned && 'ld-select-popper--pinned', this.size && `ld-select-popper--${this.size}`, ])} part="popper" > {this.filter && ( - +
+ + {this.canCreate && ( + + + + + + + + )} +
)}
| string + @State() allOptionsFiltered = false + @State() filterMatchesOption = false @State() ariaDisabled = false @State() expanded = false @State() hasCustomIcon = false @@ -114,15 +129,23 @@ export class LdSelect implements InnerFocusable { @State() typeAheadTimeout: NodeJS.Timeout | null /** - * Emitted with an array of selected values when an alteration to the selection is committed by the user. + * Emitted with an array of selected values + * when an alteration to the selection is committed. */ @Event() ldchange: EventEmitter /** - * Emitted with an array of selected values when an alteration to the selection is committed by the user. + * Emitted with an array of selected values + * when an alteration to the selection is committed. */ @Event() ldinput: EventEmitter + /** + * Emitted when an option is created in create mode + * with the filter input value. + */ + @Event() ldoptioncreate: EventEmitter + /** Sets focus on the trigger button. */ @Method() async focusInner() { @@ -148,6 +171,19 @@ export class LdSelect implements InnerFocusable { this.updateSelectedHiddenInputs(newSelection) } + // Synchronize options with internal options. + this.isObserverEnabled = false + this.el.querySelectorAll('ld-option').forEach((ldOption) => { + ldOption.selected = newValues.some((value) => value === ldOption.value) + if (!ldOption.selected && ldOption.hidden) { + this.listboxRef + .querySelector(`ld-option-internal[value="${ldOption.value}"]`) + .remove() + ldOption.remove() + } + }) + this.isObserverEnabled = true + this.el.dispatchEvent(new InputEvent('change', { bubbles: true })) this.el.dispatchEvent( new InputEvent('input', { bubbles: true, composed: true }) @@ -466,6 +502,7 @@ export class LdSelect implements InnerFocusable { } private handleSlotChange = (mutationsList: MutationRecord[]) => { + if (!this.isObserverEnabled) return if (!mutationsList.some((record) => this.isLdOption(record.target))) { return } @@ -632,10 +669,20 @@ export class LdSelect implements InnerFocusable { ? this.internalOptionsContainerRef.querySelectorAll('ld-option-internal') : this.el.querySelectorAll('ld-option') const query = ev.detail.trim().toLowerCase() + let allFiltered = true + let filterMatchesOption = false options.forEach((ldOption) => { - ldOption.hidden = - Boolean(query) && !ldOption.textContent.toLowerCase().includes(query) + const optionTextLower = ldOption.textContent.toLowerCase() + ldOption.filtered = Boolean(query) && !optionTextLower.includes(query) + if (optionTextLower === query) { + filterMatchesOption = true + } + if (!ldOption.filtered) { + allFiltered = false + } }) + this.allOptionsFiltered = allFiltered + this.filterMatchesOption = filterMatchesOption // Re-position popper after new height has been applied. requestAnimationFrame(() => { @@ -643,6 +690,25 @@ export class LdSelect implements InnerFocusable { }) } + private handleFilterCreate = () => { + // In single select mode, deselect currently selected option + if (!this.multiple) { + const options = this.el.querySelectorAll('ld-option') + options.forEach((ldOption) => { + ldOption.selected = false + }) + } + + this.ldoptioncreate.emit(this.getFilterInput().value) + this.resetFilter() + } + + private canCreate = () => { + return Boolean( + this.creatable && !this.filterMatchesOption && this.getFilterInput().value + ) + } + @Listen('keydown', { passive: false, target: 'window' }) handleKeyDown(ev: KeyboardEvent) { if (this.disabled || this.ariaDisabled) return @@ -659,13 +725,23 @@ export class LdSelect implements InnerFocusable { this.filter && this.listboxRef?.shadowRoot.activeElement === this.getFilterInput() - // Ignore events if filter input has focus, - // except for navigation-specific keys. - if ( - filterHasFocus && - !['ArrowDown', 'ArrowUp', 'End', 'Escape', 'Home', 'Tab'].includes(ev.key) - ) { - return + // If filter has focus... + if (filterHasFocus) { + // ... and create mode is active + if (this.canCreate() && ev.key === 'Enter') { + this.handleFilterCreate() + return + } + + // Ignore events if filter input has focus, + // except for navigation-specific keys. + if ( + !['ArrowDown', 'ArrowUp', 'End', 'Escape', 'Home', 'Tab'].includes( + ev.key + ) + ) { + return + } } // If the clear button is focused, ignore Enter and Space key events. @@ -702,7 +778,7 @@ export class LdSelect implements InnerFocusable { } else { const nextLdOption = Array.from( this.listboxRef.querySelectorAll('ld-option-internal') - ).find((ldOption) => !ldOption.hidden) + ).find((ldOption) => !ldOption.filtered) this.selectAndFocus(ev, nextLdOption) } } else { @@ -710,7 +786,10 @@ export class LdSelect implements InnerFocusable { let nextLdOption while (nextLdOption === undefined) { if (this.isLdOption(current.nextElementSibling)) { - if (current.nextElementSibling.hidden) { + if ( + current.nextElementSibling.filtered || + current.nextElementSibling.hidden + ) { current = current.nextElementSibling } else { nextLdOption = current.nextElementSibling @@ -746,7 +825,10 @@ export class LdSelect implements InnerFocusable { let current = document.activeElement while (prevLdOption === undefined) { if (this.isLdOption(current.previousElementSibling)) { - if (current.previousElementSibling.hidden) { + if ( + current.previousElementSibling.hidden || + current.previousElementSibling.filtered + ) { current = current.previousElementSibling } else { prevLdOption = current.previousElementSibling @@ -855,6 +937,9 @@ export class LdSelect implements InnerFocusable { } private resetFilter = () => { + this.allOptionsFiltered = false + this.filterMatchesOption = false + if (!this.filter) return const filterInput = this.getFilterInput() if (!filterInput) return @@ -863,8 +948,10 @@ export class LdSelect implements InnerFocusable { const options = this.internalOptionsContainerRef.querySelectorAll('ld-option-internal') options.forEach((ldOption) => { - ldOption.hidden = false + ldOption.filtered = false }) + + this.listboxRef.resetFilter() } private handleFocusEvent = (ev: FocusEvent) => { @@ -962,6 +1049,7 @@ export class LdSelect implements InnerFocusable { } } + /* istanbul ignore next */ disconnectedCallback() { clearTimeout(this.typeAheadTimeout) this.popper?.destroy() @@ -1198,13 +1286,19 @@ export class LdSelect implements InnerFocusable {
(this.listboxRef = el)} role="listbox" diff --git a/src/liquid/components/ld-select/readme.md b/src/liquid/components/ld-select/readme.md index 5ad286b249..0d77ca58ed 100644 --- a/src/liquid/components/ld-select/readme.md +++ b/src/liquid/components/ld-select/readme.md @@ -1648,6 +1648,246 @@ For both, the ld-select Web Component and the CSS Component, you can use a custo {% endexample %} +#### Creatable + +Creatable mode can be enabled when the `filter` prop is set to `true`. +This mode allows the user to create new options using the filter input field. + +{% example %} + + Apple + Banana + Strawberry + + + + + + +const [options, setOptions] = useState([ + { value: 'apple', title: 'Apple', selected: false }, + { value: 'banana', title: 'Banana', selected: false }, + { value: 'strawberry', title: 'Strawberry', selected: false }, +]) + +return ( + <> + { + const value = ev.detail + setOptions([ + { + value: value.toLowerCase(), + title: value, + selected: true, + }, + ...options.map((option) => ({ + ...option, + selected: false, + })), + ]) + }} + onLdchange={(ev) => { + const values = ev.detail + setOptions([ + ...options.map((option) => ({ + ...option, + selected: values.includes(option.value), + })), + ]) + }} + > + {options.map((option) => ( + + {option.title} + + ))} + + +) +{% endexample %} + +The `creatable` prop does also work with the multiple select mode. + +{% example %} + + Apple + Banana + Strawberry + + + + + + +const [options, setOptions] = useState([ + { value: 'apple', title: 'Apple', selected: false }, + { value: 'banana', title: 'Banana', selected: false }, + { value: 'strawberry', title: 'Strawberry', selected: false }, +]) + +return ( + <> + { + const value = ev.detail + setOptions([ + { + value: value.toLowerCase(), + title: value, + selected: true, + }, + ...options, + ]) + }} + onLdchange={(ev) => { + const values = ev.detail + setOptions([ + ...options.map((option) => ({ + ...option, + selected: values.includes(option.value), + })), + ]) + }} + > + {options.map((option) => ( + + {option.title} + + ))} + + +) +{% endexample %} + +You can hide created options in the popper element by simply adding the `hidden` attribute to the newly created option. + +{% example %} + + Apple + Banana + Strawberry + + + + + + +const [options, setOptions] = useState([ + { value: 'apple', title: 'Apple', selected: false, hidden: false }, + { value: 'banana', title: 'Banana', selected: false, hidden: false }, + { value: 'strawberry', title: 'Strawberry', selected: false, hidden: false }, +]) + +return ( + <> + { + const value = ev.detail + setOptions([ + { + value: value.toLowerCase(), + title: value, + selected: true, + hidden: true, + }, + ...options, + ]) + }} + onLdchange={(ev) => { + const values = ev.detail + setOptions([ + ...options.map((option) => ({ + ...option, + selected: values.includes(option.value), + })), + ]) + }} + > + {options.map((option) => ( + + ))} + + +) +{% endexample %} + ### With label {% example %} @@ -1845,36 +2085,40 @@ The `ld-select` Web Component provides a low level API for integrating it with t ## Properties -| Property | Attribute | Description | Type | Default | -| -------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ | -| `autofocus` | `autofocus` | This Boolean attribute lets you specify that a form control should have input focus when the page loads. Only one form element in a document can have the autofocus attribute. | `boolean` | `undefined` | -| `disabled` | `disabled` | Disabled state of the component. | `boolean` | `undefined` | -| `filter` | `filter` | Set this property to `true` in order to enable an input field for filtering options. | `boolean` | `undefined` | -| `filterPlaceholder` | `filter-placeholder` | The filter input placeholder. | `string` | `'Filter options'` | -| `form` | `form` | The form element to associate the select with (its form owner). | `string` | `undefined` | -| `invalid` | `invalid` | Set this property to `true` in order to mark the select visually as invalid. | `boolean` | `undefined` | -| `key` | `key` | for tracking the node's identity when working with lists | `string \| number` | `undefined` | -| `ldTabindex` | `ld-tabindex` | Tab index of the trigger button. | `number` | `0` | -| `maxRows` | `max-rows` | Constrains the height of the trigger button by replacing overflowing selection with a "+X more" indicator. | `number` | `undefined` | -| `mode` | `mode` | Display mode. | `"detached" \| "ghost" \| "inline"` | `undefined` | -| `multiple` | `multiple` | Multiselect mode. | `boolean` | `undefined` | -| `name` | `name` | Used to specify the name of the control. | `string` | `undefined` | -| `placeholder` | `placeholder` | Used as trigger button label in multiselect mode and in single select mode if nothing is selected. | `string` | `undefined` | -| `popperClass` | `popper-class` | Attached as CSS class to the select popper element. | `string` | `undefined` | -| `preventDeselection` | `prevent-deselection` | Prevents a state with no options selected after initial selection in single select mode. | `boolean` | `undefined` | -| `ref` | `ref` | reference to component | `any` | `undefined` | -| `required` | `required` | A Boolean attribute indicating that an option with a non-empty string value must be selected. | `boolean` | `undefined` | -| `selected` | -- | Currently selected option(s) (read only!) | `SelectOption[]` | `[]` | -| `size` | `size` | Size of the select trigger button. | `"lg" \| "sm"` | `undefined` | -| `tetherOptions` | `tether-options` | Tether options object to be merged with the default options (optionally stringified). | `string \| { attachment?: string; bodyElement?: HTMLElement; classes?: { [className: string]: string \| boolean; }; classPrefix?: string; constraints?: ITetherConstraint[]; element?: any; enabled?: boolean; offset?: string; optimizations?: any; target?: any; targetAttachment?: string; targetOffset?: string; targetModifier?: string; }` | `undefined` | +| Property | Attribute | Description | Type | Default | +| -------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- | +| `autofocus` | `autofocus` | This Boolean attribute lets you specify that a form control should have input focus when the page loads. Only one form element in a document can have the autofocus attribute. | `boolean` | `undefined` | +| `creatable` | `creatable` | Creatable mode can be enabled when the filter prop is set to true. This mode allows the user to create new options using the filter input field. | `boolean` | `undefined` | +| `createButtonLabel` | `create-button-label` | The "create" button label (creatable mode). | `string` | `'Create option'` | +| `createInputLabel` | `create-input-label` | The "create" input label (creatable mode). | `string` | `'Press Enter to create option'` | +| `disabled` | `disabled` | Disabled state of the component. | `boolean` | `undefined` | +| `filter` | `filter` | Set this property to `true` in order to enable an input field for filtering options. | `boolean` | `undefined` | +| `filterPlaceholder` | `filter-placeholder` | The filter input placeholder. | `string` | `'Filter options'` | +| `form` | `form` | The form element to associate the select with (its form owner). | `string` | `undefined` | +| `invalid` | `invalid` | Set this property to `true` in order to mark the select visually as invalid. | `boolean` | `undefined` | +| `key` | `key` | for tracking the node's identity when working with lists | `string \| number` | `undefined` | +| `ldTabindex` | `ld-tabindex` | Tab index of the trigger button. | `number` | `0` | +| `maxRows` | `max-rows` | Constrains the height of the trigger button by replacing overflowing selection with a "+X more" indicator. | `number` | `undefined` | +| `mode` | `mode` | Display mode. | `"detached" \| "ghost" \| "inline"` | `undefined` | +| `multiple` | `multiple` | Multiselect mode. | `boolean` | `undefined` | +| `name` | `name` | Used to specify the name of the control. | `string` | `undefined` | +| `placeholder` | `placeholder` | Used as trigger button label in multiselect mode and in single select mode if nothing is selected. | `string` | `undefined` | +| `popperClass` | `popper-class` | Attached as CSS class to the select popper element. | `string` | `undefined` | +| `preventDeselection` | `prevent-deselection` | Prevents a state with no options selected after initial selection in single select mode. | `boolean` | `undefined` | +| `ref` | `ref` | reference to component | `any` | `undefined` | +| `required` | `required` | A Boolean attribute indicating that an option with a non-empty string value must be selected. | `boolean` | `undefined` | +| `selected` | -- | Currently selected option(s) (read only!) | `SelectOption[]` | `[]` | +| `size` | `size` | Size of the select trigger button. | `"lg" \| "sm"` | `undefined` | +| `tetherOptions` | `tether-options` | Tether options object to be merged with the default options (optionally stringified). | `string \| { attachment?: string; bodyElement?: HTMLElement; classes?: { [className: string]: string \| boolean; }; classPrefix?: string; constraints?: ITetherConstraint[]; element?: any; enabled?: boolean; offset?: string; optimizations?: any; target?: any; targetAttachment?: string; targetOffset?: string; targetModifier?: string; }` | `undefined` | ## Events -| Event | Description | Type | -| ---------- | ------------------------------------------------------------------------------------------------------ | ----------------------- | -| `ldchange` | Emitted with an array of selected values when an alteration to the selection is committed by the user. | `CustomEvent` | -| `ldinput` | Emitted with an array of selected values when an alteration to the selection is committed by the user. | `CustomEvent` | +| Event | Description | Type | +| ---------------- | ------------------------------------------------------------------------------------------ | ----------------------- | +| `ldchange` | Emitted with an array of selected values when an alteration to the selection is committed. | `CustomEvent` | +| `ldinput` | Emitted with an array of selected values when an alteration to the selection is committed. | `CustomEvent` | +| `ldoptioncreate` | Emitted when an option is created in create mode with the filter input value. | `CustomEvent` | ## Methods @@ -1932,6 +2176,8 @@ Type: `Promise` ```mermaid graph TD; ld-select --> ld-select-popper + ld-select-popper --> ld-button + ld-select-popper --> ld-icon style ld-select fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/src/liquid/components/ld-select/test/__snapshots__/ld-select.spec.ts.snap b/src/liquid/components/ld-select/test/__snapshots__/ld-select.spec.ts.snap index f34371730b..4f0e54919f 100644 --- a/src/liquid/components/ld-select/test/__snapshots__/ld-select.spec.ts.snap +++ b/src/liquid/components/ld-select/test/__snapshots__/ld-select.spec.ts.snap @@ -1167,7 +1167,7 @@ exports[`ld-select places the popper inside a given element 1`] = ` -
+
diff --git a/src/liquid/components/ld-select/test/ld-select.e2e.ts b/src/liquid/components/ld-select/test/ld-select.e2e.ts index 6cf1b94ed3..7246d162f3 100644 --- a/src/liquid/components/ld-select/test/ld-select.e2e.ts +++ b/src/liquid/components/ld-select/test/ld-select.e2e.ts @@ -573,6 +573,25 @@ describe('ld-select', () => { const results = await page.compareScreenshot() expect(results).toMatchScreenshot() }) + + fit('with create button', async () => { + const page = await getPageWithContent(` + + Apple + Pear + Pineapple + Banana + Plum + `) + await page.keyboard.press('Tab') + await page.waitForChanges() + await page.keyboard.press('ArrowDown') + await page.waitForChanges() + await page.keyboard.sendCharacter('Kiwi') + await page.waitForChanges() + const results = await page.compareScreenshot() + expect(results).toMatchScreenshot() + }) }) describe('css component', () => { diff --git a/src/liquid/components/ld-select/test/ld-select.spec.ts b/src/liquid/components/ld-select/test/ld-select.spec.ts index 6716ed3e3b..17456a3887 100644 --- a/src/liquid/components/ld-select/test/ld-select.spec.ts +++ b/src/liquid/components/ld-select/test/ld-select.spec.ts @@ -2291,7 +2291,7 @@ describe('ld-select', () => { await page.waitForChanges() expect( - ldInternalOptions.filter((option) => option.hidden).length + ldInternalOptions.filter((option) => option.filtered).length ).toEqual(1) const ev = new FocusEvent('focusout', { @@ -2303,7 +2303,7 @@ describe('ld-select', () => { expect(btnTrigger.getAttribute('aria-expanded')).toEqual('false') expect( - ldInternalOptions.filter((option) => option.hidden).length + ldInternalOptions.filter((option) => option.filtered).length ).toEqual(0) }) @@ -2338,7 +2338,7 @@ describe('ld-select', () => { await page.waitForChanges() expect( - ldInternalOptions.filter((option) => option.hidden).length + ldInternalOptions.filter((option) => option.filtered).length ).toEqual(1) internalOptions[1].click() @@ -2347,7 +2347,7 @@ describe('ld-select', () => { expect(btnTrigger.getAttribute('aria-expanded')).toEqual('false') expect( - ldInternalOptions.filter((option) => option.hidden).length + ldInternalOptions.filter((option) => option.filtered).length ).toEqual(0) }) @@ -2405,10 +2405,10 @@ describe('ld-select', () => { await page.waitForChanges() - expect(ldInternalOptions[0]).not.toHaveAttribute('hidden') - expect(ldInternalOptions[1]).toHaveAttribute('hidden') - expect(ldInternalOptions[2]).not.toHaveAttribute('hidden') - expect(ldInternalOptions[3]).not.toHaveAttribute('hidden') + expect(ldInternalOptions[0].filtered).toBeFalsy() + expect(ldInternalOptions[1].filtered).toBeTruthy() + expect(ldInternalOptions[2].filtered).toBeFalsy() + expect(ldInternalOptions[3].filtered).toBeFalsy() const spyFocus0 = jest.spyOn(internalOptions[0], 'focus') const spyFocus1 = jest.spyOn(internalOptions[1], 'focus') @@ -2499,10 +2499,10 @@ describe('ld-select', () => { await page.waitForChanges() - expect(ldInternalOptions[0]).not.toHaveAttribute('hidden') - expect(ldInternalOptions[1]).toHaveAttribute('hidden') - expect(ldInternalOptions[2]).not.toHaveAttribute('hidden') - expect(ldInternalOptions[3]).not.toHaveAttribute('hidden') + expect(ldInternalOptions[0].filtered).toBeFalsy() + expect(ldInternalOptions[1].filtered).toBeTruthy() + expect(ldInternalOptions[2].filtered).toBeFalsy() + expect(ldInternalOptions[3].filtered).toBeFalsy() const spyFocus0 = jest.spyOn(internalOptions[0], 'focus') const spyFocus1 = jest.spyOn(internalOptions[1], 'focus') @@ -2697,10 +2697,230 @@ describe('ld-select', () => { await page.waitForChanges() - expect(ldInternalOptions[0]).not.toHaveAttribute('hidden') - expect(ldInternalOptions[1]).toHaveAttribute('hidden') - expect(ldInternalOptions[2]).not.toHaveAttribute('hidden') - expect(ldInternalOptions[3]).not.toHaveAttribute('hidden') + expect(ldInternalOptions[0].filtered).toBeFalsy() + expect(ldInternalOptions[1].filtered).toBeTruthy() + expect(ldInternalOptions[2].filtered).toBeFalsy() + expect(ldInternalOptions[3].filtered).toBeFalsy() + }) + }) + + describe('creatable', () => { + it('emits the ldoptioncreate which allows to create options', async () => { + const page = await newSpecPage({ + components, + html: ` + + Apple + Banana + Cherry + + `, + }) + + const ldSelect = page.root + const btnTrigger = ldSelect.shadowRoot.querySelector( + '.ld-select__btn-trigger' + ) + btnTrigger['focus'] = jest.fn() + + const filterInput = getFilterInput(page) + filterInput.focus = jest.fn() + + const ldoptioncreateHandler = jest.fn() + ldSelect.addEventListener('ldoptioncreate', ldoptioncreateHandler) + + await triggerPopperWithClick(page) + expect(btnTrigger.getAttribute('aria-expanded')).toEqual('true') + + jest.advanceTimersByTime(0) + expect(filterInput.focus).toHaveBeenCalledTimes(1) + + const { ldInternalOptions } = getInternalOptions(page) + expect(ldInternalOptions.length).toEqual(3) + + filterInput.value = 'e' + filterInput.dispatchEvent(new InputEvent('input')) + + await page.waitForChanges() + + expect(ldInternalOptions[0].filtered).toBeFalsy() + expect(ldInternalOptions[1].filtered).toBeTruthy() + expect(ldInternalOptions[2].filtered).toBeFalsy() + + filterInput.value = 'banana' + filterInput.dispatchEvent(new InputEvent('input')) + + await page.waitForChanges() + + expect(ldInternalOptions[0].filtered).toBeTruthy() + expect(ldInternalOptions[1].filtered).toBeFalsy() + expect(ldInternalOptions[2].filtered).toBeTruthy() + + const { doc, shadowDoc, popperShadowDoc } = getShadow(page) + doc.activeElement = ldSelect + shadowDoc.activeElement = page.body.querySelector('ld-select-popper') + + popperShadowDoc.activeElement = filterInput + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })) + await page.waitForChanges() + + expect(filterInput.value).toEqual('banana') + expect(ldInternalOptions[0].filtered).toBeTruthy() + expect(ldInternalOptions[1].filtered).toBeFalsy() + expect(ldInternalOptions[2].filtered).toBeTruthy() + expect(ldoptioncreateHandler).not.toHaveBeenCalled() + + filterInput.value = 'Kiwi' + filterInput.dispatchEvent(new InputEvent('input')) + + await page.waitForChanges() + + expect(ldInternalOptions[0].filtered).toBeTruthy() + expect(ldInternalOptions[1].filtered).toBeTruthy() + expect(ldInternalOptions[2].filtered).toBeTruthy() + + expect(ldoptioncreateHandler).not.toHaveBeenCalled() + + popperShadowDoc.activeElement = filterInput + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })) + await page.waitForChanges() + + expect(filterInput.value).toEqual('') + expect(ldInternalOptions[0].filtered).toBeFalsy() + expect(ldInternalOptions[1].filtered).toBeFalsy() + expect(ldInternalOptions[2].filtered).toBeFalsy() + expect(ldoptioncreateHandler).toHaveBeenCalledTimes(1) + + popperShadowDoc.activeElement = filterInput + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })) + await page.waitForChanges() + + expect(ldoptioncreateHandler).toHaveBeenCalledTimes(1) + + filterInput.value = 'Orange' + filterInput.dispatchEvent(new InputEvent('input')) + await page.waitForChanges() + + expect(ldInternalOptions[0].filtered).toBeTruthy() + expect(ldInternalOptions[1].filtered).toBeTruthy() + expect(ldInternalOptions[2].filtered).toBeTruthy() + + const filterCreateButton = page.body + .querySelector('ld-select-popper') + ?.shadowRoot.querySelector( + '.ld-select-popper__create-button' + ) + filterCreateButton.click() + + expect(filterInput.value).toEqual('') + expect(ldInternalOptions[0].filtered).toBeFalsy() + expect(ldInternalOptions[1].filtered).toBeFalsy() + expect(ldInternalOptions[2].filtered).toBeFalsy() + expect(ldoptioncreateHandler).toHaveBeenCalledTimes(2) + }) + + it('removes created but hidden options on de-selection', async () => { + const page = await newSpecPage({ + components, + html: ` + + Apple + Banana + Cherry + + `, + }) + + const ldSelect = page.root + const btnTrigger = ldSelect.shadowRoot.querySelector( + '.ld-select__btn-trigger' + ) + btnTrigger['focus'] = jest.fn() + + const filterInput = getFilterInput(page) + filterInput.focus = jest.fn() + + const ldoptioncreateHandler = jest.fn() + ldSelect.addEventListener('ldoptioncreate', ldoptioncreateHandler) + + await triggerPopperWithClick(page) + expect(btnTrigger.getAttribute('aria-expanded')).toEqual('true') + + let { internalOptions, ldInternalOptions } = getInternalOptions(page) + expect(ldInternalOptions.length).toEqual(3) + expect(ldInternalOptions[0].selected).toBeTruthy() + expect(ldInternalOptions[1].selected).toBeFalsy() + expect(ldInternalOptions[2].selected).toBeTruthy() + + filterInput.value = 'Kiwi' + filterInput.dispatchEvent(new InputEvent('input')) + + await page.waitForChanges() + + expect(ldInternalOptions[0].filtered).toBeTruthy() + expect(ldInternalOptions[1].filtered).toBeTruthy() + expect(ldInternalOptions[2].filtered).toBeTruthy() + + expect(ldoptioncreateHandler).not.toHaveBeenCalled() + + const { doc, shadowDoc, popperShadowDoc } = getShadow(page) + doc.activeElement = ldSelect + shadowDoc.activeElement = page.body.querySelector('ld-select-popper') + + popperShadowDoc.activeElement = filterInput + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })) + await page.waitForChanges() + + expect(filterInput.value).toEqual('') + expect(ldInternalOptions[0].filtered).toBeFalsy() + expect(ldInternalOptions[1].filtered).toBeFalsy() + expect(ldInternalOptions[2].filtered).toBeFalsy() + expect(ldInternalOptions[0].selected).toBeTruthy() + expect(ldInternalOptions[1].selected).toBeFalsy() + expect(ldInternalOptions[2].selected).toBeTruthy() + expect(ldoptioncreateHandler).toHaveBeenCalledTimes(1) + + // Deselect Apple. + internalOptions[0].click() + await page.waitForChanges() + + expect(ldInternalOptions[0].selected).toBeFalsy() + expect(ldInternalOptions[1].selected).toBeFalsy() + expect(ldInternalOptions[2].selected).toBeTruthy() + + // Prepend the Kiwi to the option list. + const option = document.createElement('ld-option') + option.value = 'kiwi' + option.innerText = 'Kiwi' + option.setAttribute('selected', 'true') + option.setAttribute('hidden', '') + ldSelect.prepend(option) + + await page.waitForChanges() + getTriggerableMutationObserver().trigger([{ target: option }]) + await page.waitForChanges() + + ldInternalOptions = getInternalOptions(page).ldInternalOptions + expect(ldInternalOptions.length).toEqual(4) + + expect(ldInternalOptions[0].selected).toBeTruthy() + expect(ldInternalOptions[1].selected).toBeFalsy() + expect(ldInternalOptions[2].selected).toBeFalsy() + expect(ldInternalOptions[3].selected).toBeTruthy() + + const btnClearSingle = + ldSelect.shadowRoot.querySelectorAll( + '.ld-select__btn-clear-single' + ) + expect(btnClearSingle.length).toEqual(2) + + btnClearSingle[0].click() + await page.waitForChanges() + + ldInternalOptions = getInternalOptions(page).ldInternalOptions + internalOptions = getInternalOptions(page).internalOptions + expect(ldInternalOptions.length).toEqual(3) + expect(internalOptions.length).toEqual(3) }) })