diff --git a/packages/overlays/src/OverlayController.js b/packages/overlays/src/OverlayController.js index bdc9b74c8..a7a6f3afb 100644 --- a/packages/overlays/src/OverlayController.js +++ b/packages/overlays/src/OverlayController.js @@ -591,6 +591,7 @@ export class OverlayController { break; case 'min': this._contentNodeWrapper.style.minWidth = referenceWidth; + this._contentNodeWrapper.style.width = 'auto'; break; /* no default */ } diff --git a/packages/select-rich/src/LionSelectInvoker.js b/packages/select-rich/src/LionSelectInvoker.js index 190ed14be..46fa71b4b 100644 --- a/packages/select-rich/src/LionSelectInvoker.js +++ b/packages/select-rich/src/LionSelectInvoker.js @@ -68,7 +68,15 @@ export class LionSelectInvoker extends LionButton { } return this.selectedElement.textContent; } - return ``; + return this._noSelectionTemplate(); + } + + /** + * To be overriden for a placeholder, used when `hasNoDefaultSelected` is true on the select rich + */ + // eslint-disable-next-line class-methods-use-this + _noSelectionTemplate() { + return html``; } _beforeTemplate() { diff --git a/packages/select-rich/src/LionSelectRich.js b/packages/select-rich/src/LionSelectRich.js index 02c2bfc96..36eac1b2b 100644 --- a/packages/select-rich/src/LionSelectRich.js +++ b/packages/select-rich/src/LionSelectRich.js @@ -79,6 +79,17 @@ export class LionSelectRich extends ScopedElementsMixin( type: String, attribute: 'interaction-mode', }, + + /** + * When setting this to true, on initial render, no option will be selected. + * It it advisable to override `_noSelectionTemplate` method in the select-invoker + * to render some kind of placeholder initially + */ + hasNoDefaultSelected: { + type: Boolean, + reflect: true, + attribute: 'has-no-default-selected', + }, }; } @@ -184,6 +195,7 @@ export class LionSelectRich extends ScopedElementsMixin( // for interaction states this._listboxActiveDescendant = null; this.__hasInitialSelectedFormElement = false; + this.hasNoDefaultSelected = false; this._repropagationRole = 'choice-group'; // configures FormControlMixin this.__setupEventListeners(); this.__initInteractionStates(); @@ -335,7 +347,11 @@ export class LionSelectRich extends ScopedElementsMixin( } // the first elements checked by default - if (!this.__hasInitialSelectedFormElement && (!child.disabled || this.disabled)) { + if ( + !this.hasNoDefaultSelected && + !this.__hasInitialSelectedFormElement && + (!child.disabled || this.disabled) + ) { child.active = true; child.checked = true; this.__hasInitialSelectedFormElement = true; @@ -619,13 +635,34 @@ export class LionSelectRich extends ScopedElementsMixin( }; } + /** + * With no selected element, we should override the inheritsReferenceWidth in most cases. + * By default, we will set it to 'min', and then set it back to what it was initially when + * something is selected. + * As a subclasser you can override this behavior. + */ + _noDefaultSelectedInheritsWidth() { + if (this.checkedIndex === -1) { + this._overlayCtrl.inheritsReferenceWidth = 'min'; + } else { + this._overlayCtrl.inheritsReferenceWidth = this._initialInheritsReferenceWidth; + } + } + __setupOverlay() { + this._initialInheritsReferenceWidth = this._overlayCtrl.inheritsReferenceWidth; + this.__overlayBeforeShow = () => { + if (this.hasNoDefaultSelected) { + this._noDefaultSelectedInheritsWidth(); + } + }; this.__overlayOnShow = () => { if (this.checkedIndex != null) { this.activeIndex = this.checkedIndex; } this._listboxNode.focus(); }; + this._overlayCtrl.addEventListener('before-show', this.__overlayBeforeShow); this._overlayCtrl.addEventListener('show', this.__overlayOnShow); this.__overlayOnHide = () => { @@ -639,6 +676,7 @@ export class LionSelectRich extends ScopedElementsMixin( __teardownOverlay() { this._overlayCtrl.removeEventListener('show', this.__overlayOnShow); + this._overlayCtrl.removeEventListener('before-show', this.__overlayBeforeShow); this._overlayCtrl.removeEventListener('hide', this.__overlayOnHide); this._scrollTargetNode.removeEventListener('keydown', this.__overlayOnHide); } diff --git a/packages/select-rich/stories/index.stories.mdx b/packages/select-rich/stories/index.stories.mdx index 9b0d8c7bc..46b94bc4b 100644 --- a/packages/select-rich/stories/index.stories.mdx +++ b/packages/select-rich/stories/index.stories.mdx @@ -462,6 +462,51 @@ console.log(`checkedIndex: ${selectEl.checkedIndex}`); // 0 console.log(`checkedValue: ${selectEl.checkedValue}`); // 'red' ``` +### No default selection + +If you want to set a placeholder option with something like 'Please select', you can of course do this, the same way you would do it in a native select. + +Simply put an option with a modelValue that is `null`. + +```html +select a color +``` + +However, this allows the user to explicitly select this option. + +Often, you may want a placeholder that appears initially, but cannot be selected explicitly by the user. +For this you can use `has-no-default-selected` attribute. + +Both methods work with the `Required` validator. + + + {html` + + + Red + Hotpink + Teal + + + `} + + + +```html + + + Red + Hotpink + Teal + + +``` + +> By default, the placeholder is completely empty in the `LionSelectInvoker`, +> but subclassers can easily override this in their extension, by the overriding `_noSelectionTemplate()` method. + + + ### Custom Invoker You can provide a custom invoker using the invoker slot. diff --git a/packages/select-rich/test/lion-select-rich.test.js b/packages/select-rich/test/lion-select-rich.test.js index 8483d8e55..a57694c58 100644 --- a/packages/select-rich/test/lion-select-rich.test.js +++ b/packages/select-rich/test/lion-select-rich.test.js @@ -3,7 +3,7 @@ import { formFixture as fixture } from '@lion/field/test-helpers.js'; import { OverlayController } from '@lion/overlays'; import { Required } from '@lion/validate'; import { aTimeout, defineCE, expect, html, nextFrame, unsafeStatic } from '@open-wc/testing'; -import { LionSelectRich } from '../index.js'; +import { LionSelectInvoker, LionSelectRich } from '../index.js'; import '../lion-option.js'; import '../lion-options.js'; import '../lion-select-rich.js'; @@ -204,6 +204,21 @@ describe('lion-select-rich', () => { expect(el.showsFeedbackFor.includes('error')).to.be.true; }); + it('supports having no default selection initially', async () => { + const el = await fixture(html` + + + Red + Hotpink + Teal + + + `); + + expect(el.selectedElement).to.be.undefined; + expect(el.modelValue).to.equal(''); + }); + describe('Invoker', () => { it('generates an lion-select-invoker if no invoker is provided', async () => { const el = await fixture(html` @@ -363,6 +378,32 @@ describe('lion-select-rich', () => { await elDisabled.updateComplete; expect(elDisabled.opened).to.be.false; }); + + it('should override the inheritsWidth prop when no default selected feature is used', async () => { + const el = await fixture(html` + + + Red + Hotpink + Teal + + + `); + + expect(el._overlayCtrl.inheritsReferenceWidth).to.equal('full'); + el.opened = true; + await el.updateComplete; + expect(el._overlayCtrl.inheritsReferenceWidth).to.equal('min'); + + // Emulate selecting hotpink, it closing, and opening it again + el.modelValue = 'hotpink'; + el.opened = false; + await el.updateComplete; // necessary for overlay controller to actually close and re-open + el.opened = true; + await el.updateComplete; + + expect(el._overlayCtrl.inheritsReferenceWidth).to.equal('full'); + }); }); describe('interaction-mode', () => { @@ -711,5 +752,45 @@ describe('lion-select-rich', () => { el.dispatchEvent(new Event('switch')); expect(el._overlayCtrl.placementMode).to.equal('local'); }); + + it('supports putting a placeholder template when there is no default selection initially', async () => { + const invokerTagName = defineCE( + class extends LionSelectInvoker { + _noSelectionTemplate() { + return html` + Please select an option.. + `; + } + }, + ); + const invokerTag = unsafeStatic(invokerTagName); + + const selectTagName = defineCE( + class extends LionSelectRich { + get slots() { + return { + ...super.slots, + invoker: () => document.createElement(invokerTag.d), + }; + } + }, + ); + const selectTag = unsafeStatic(selectTagName); + + const el = await fixture(html` + <${selectTag} id="color" name="color" label="Favorite color" has-no-default-selected> + + Red + Hotpink + Teal + + + `); + + expect(el._invokerNode.shadowRoot.getElementById('content-wrapper')).dom.to.equal( + `
Please select an option..
`, + ); + expect(el.modelValue).to.equal(''); + }); }); });