diff --git a/change/@microsoft-fast-foundation-1ae84860-8dff-4c1d-ae22-0a066b9f5f48.json b/change/@microsoft-fast-foundation-1ae84860-8dff-4c1d-ae22-0a066b9f5f48.json new file mode 100644 index 00000000000..c7b65f77e8f --- /dev/null +++ b/change/@microsoft-fast-foundation-1ae84860-8dff-4c1d-ae22-0a066b9f5f48.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "select: removes prescriptive rendering of select as listbox", + "packageName": "@microsoft/fast-foundation", + "email": "brianbrady@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/web-components/fast-foundation/src/select/README.md b/packages/web-components/fast-foundation/src/select/README.md index a892497f2be..91e095ab729 100644 --- a/packages/web-components/fast-foundation/src/select/README.md +++ b/packages/web-components/fast-foundation/src/select/README.md @@ -115,9 +115,9 @@ See [listbox-option](/docs/components/listbox-option) for more information. #### Attributes -| Name | Field | Inherited From | -| ---- | -------- | -------------- | -| | multiple | FASTListbox | +| Name | Field | Inherited From | +| ---- | ------------ | -------------- | +| | multiple | FASTListbox |
@@ -148,6 +148,7 @@ See [listbox-option](/docs/components/listbox-option) for more information. | `disabled` | public | `boolean` | | The disabled state of the listbox. | FASTListbox | | `selectedIndex` | public | `number` | `-1` | The index of the selected option. | FASTListbox | | `selectedOptions` | public | `FASTListboxOption[]` | `[]` | A collection of the selected options. | FASTListbox | +| `listboxMode` | public | `boolean` | `false` | Indicates if the select renders as a listbox only | FASTSelect | #### Methods @@ -167,10 +168,12 @@ See [listbox-option](/docs/components/listbox-option) for more information. #### Attributes -| Name | Field | Inherited From | -| ------ | -------- | -------------- | -| `open` | open | | -| | multiple | FASTListbox | +| Name | Field | Inherited From | +| -------------- | ----------- | -------------- | +| `open` | open | | +| `multiple` | multiple | FASTListbox | +| `listbox-mode` | listboxMode | FASTSelect | + #### CSS Parts diff --git a/packages/web-components/fast-foundation/src/select/select.pw.spec.ts b/packages/web-components/fast-foundation/src/select/select.pw.spec.ts index e4990480401..91822e70c12 100644 --- a/packages/web-components/fast-foundation/src/select/select.pw.spec.ts +++ b/packages/web-components/fast-foundation/src/select/select.pw.spec.ts @@ -283,6 +283,68 @@ test.describe("Select", () => { await expect(listbox).toBeVisible(); }); + test("pressing Enter key while closed should open the dropdown", async () => { + await root.evaluate(node => { + node.innerHTML = /* html */ ` + + Option 1 + Option 2 + Option 3 + + `; + }); + + await element.evaluate((node: FASTSelect) => { + const event = new KeyboardEvent("keydown", { key: "Enter" }); + node.dispatchEvent(event); + }); + + await expect(element).toHaveBooleanAttribute("open"); + }); + + test("should select an option when the Enter key is pressed and the select is open", async () => { + await root.evaluate(node => { + node.innerHTML = /* html */ ` + + Option 1 + Option 2 + Option 3 + + `; + }); + + // Open the select + await element.evaluate((node: FASTSelect) => { + node.open = true; + }); + + await element.evaluate((node: FASTSelect) => { + const event = new KeyboardEvent("keydown", { key: "Enter" }); + node.dispatchEvent(event); + }); + + await expect(element).toHaveJSProperty("selectedIndex", 0); + }); + + test("pressing Escape key while open should close the dropdown", async () => { + await root.evaluate(node => { + node.innerHTML = /* html */ ` + + Option 1 + Option 2 + Option 3 + + `; + }); + + await element.evaluate((node: FASTSelect) => { + const event = new KeyboardEvent("keydown", { key: "Escape" }); + node.dispatchEvent(event); + }); + + await expect(element).not.toHaveBooleanAttribute("open"); + }); + ["input", "change"].forEach(eventName => { [ { expectedValue: "Option 2", key: "ArrowDown" }, @@ -513,4 +575,81 @@ test.describe("Select", () => { await expect(element).toHaveJSProperty("displayValue", "textContent value"); }); + + test("should set the displayValue to the selected options separated by commas when the multiple attribute is present", async () => { + await root.evaluate(node => { + node.innerHTML = /* html */ ` + + Option 1 + Option 2 + Option 3 + + `; + }); + + await expect(element).toHaveJSProperty("displayValue", "Option 1, Option 2"); + }); + + test("should not close on click when the multiple attribute is present", async () => { + await root.evaluate(node => { + node.innerHTML = /* html */ ` + + Option 1 + Option 2 + Option 3 + + `; + }); + + const listbox = element.locator(".listbox"); + + // Open the select + await element.evaluate((node: FASTSelect) => { + node.open = true; + }); + + // Simulate a click on the select + await element.click(); + + await expect(element).toHaveBooleanAttribute("open"); + await expect(listbox).toBeVisible(); + }); + + test("when listbox-mode attribute is present, the select renders the listbox only without control", async () => { + await root.evaluate(node => { + node.innerHTML = /* html */ ` + + Option 1 + Option 2 + Option 3 + + `; + }); + + const listbox = element.locator(".listbox"); + const control = element.locator(".control"); + + await expect(listbox).toBeVisible(); + await expect(control).not.toBeVisible(); + }); + + test("should display the placeholder when no option is selected", async () => { + await root.evaluate(node => { + node.innerHTML = /* html */ ` + + Option 1 + Option 2 + Option 3 + + `; + }); + + await expect(element).toHaveJSProperty("displayValue", "Select an option"); + + await element.evaluate(node => { + node.value = "2"; + }); + + await expect(element).toHaveJSProperty("displayValue", "Option 2"); + }); }); diff --git a/packages/web-components/fast-foundation/src/select/select.template.ts b/packages/web-components/fast-foundation/src/select/select.template.ts index 0b703b2e22d..033998161ea 100644 --- a/packages/web-components/fast-foundation/src/select/select.template.ts +++ b/packages/web-components/fast-foundation/src/select/select.template.ts @@ -19,6 +19,7 @@ export function selectTemplate( aria-expanded="${x => x.ariaExpanded}" aria-haspopup="${x => (x.collapsible ? "listbox" : null)}" aria-multiselectable="${x => x.ariaMultiSelectable}" + ?listbox-mode="${x => x.listboxMode}" ?open="${x => x.open}" role="combobox" tabindex="${x => (!x.disabled ? "0" : null)}" @@ -29,7 +30,7 @@ export function selectTemplate( @mousedown="${(x, c) => x.mousedownHandler(c.event as MouseEvent)}" > ${when( - x => x.collapsible, + x => !x.listboxMode && x.collapsible, html`
( part="listbox" role="listbox" ?disabled="${x => x.disabled}" - ?hidden="${x => (x.collapsible ? !x.open : false)}" + ?hidden="${x => + !x.listboxMode ? (x.collapsible ? !x.open : false) : false}" ${ref("listbox")} > + ${when( + x => x.placeholder, + html` + + ` + )} & { * @public */ export class FASTSelect extends FormAssociatedSelect { + /** + * The listbox mode attribute. + * + * @public + * @remarks + * HTML Attribute: listbox-mode + */ + @attr({ attribute: "listbox-mode", mode: "boolean" }) + public listboxMode: boolean = false; + /** * The open attribute. * @@ -57,6 +67,16 @@ export class FASTSelect extends FormAssociatedSelect { @attr({ attribute: "open", mode: "boolean" }) public open: boolean = false; + /** + * The placeholder attribute. + * + * @public + * @remarks + * HTML Attribute: placeholder + */ + @attr + public placeholder: string; + /** * Sets focus and synchronizes ARIA attributes when the open property changes. * @@ -105,13 +125,13 @@ export class FASTSelect extends FormAssociatedSelect { private _value: string; /** - * The component is collapsible when in single-selection mode with no size attribute. + * The component is collapsible when not in listbox mode. * * @internal */ @volatile public get collapsible(): boolean { - return !(this.multiple || typeof this.size === "number"); + return !this.listboxMode; } /** @@ -122,6 +142,14 @@ export class FASTSelect extends FormAssociatedSelect { @observable public control: HTMLElement; + /** + * The ref to the internal `.control` element. + * + * @internal + */ + @observable + public placeholderOption: HTMLOptionElement | null = null; + /** * The value property. * @@ -262,7 +290,12 @@ export class FASTSelect extends FormAssociatedSelect { */ public get displayValue(): string { Observable.track(this, "displayValue"); - return this.firstSelectedOption?.text ?? ""; + if (this.multiple) { + const selectedOptionsText = this.selectedOptions.map(option => option.text); + this.currentValue = this.firstSelectedOption?.text; + return selectedOptionsText.join(", ") || this.placeholderOption?.text || ""; + } + return this.firstSelectedOption?.text ?? this.placeholderOption?.text ?? ""; } /** @@ -319,7 +352,7 @@ export class FASTSelect extends FormAssociatedSelect { super.clickHandler(e); - this.open = this.collapsible && !this.open; + this.open = this.multiple || (this.collapsible && !this.open); if (!this.open && this.indexWhenOpened !== this.selectedIndex) { this.updateValue(true); @@ -396,7 +429,7 @@ export class FASTSelect extends FormAssociatedSelect { } /** - * Prevents focus when size is set and a scrollbar is clicked. + * Prevents focus when listbox mode is set and a scrollbar is clicked. * * @param e - the mouse event object * @@ -449,7 +482,8 @@ export class FASTSelect extends FormAssociatedSelect { /** * Sets the selected index to match the first option with the selected attribute, or - * the first selectable option. + * the first selectable option when in single select mode and no placeholder is present. + * When in multiple select mode or a placeholder is present, the selected index is set to -1. * * @override * @internal @@ -463,7 +497,7 @@ export class FASTSelect extends FormAssociatedSelect { el => el.hasAttribute("selected") || el.selected || el.value === this.value ); - if (selectedIndex !== -1) { + if (selectedIndex !== -1 || this.placeholder !== "") { this.selectedIndex = selectedIndex; return; } @@ -498,44 +532,60 @@ export class FASTSelect extends FormAssociatedSelect { * @internal */ public keydownHandler(e: KeyboardEvent): boolean | void { + if (e.defaultPrevented) { + return; + } super.keydownHandler(e); + const key = e.key || e.key.charCodeAt(0); + let preventDefault = false; switch (key) { case keySpace: { - e.preventDefault(); - if (this.collapsible && this.typeAheadExpired) { + if (this.multiple || this.listboxMode) { + this.open = true; + } else if (this.collapsible && this.typeAheadExpired) { this.open = !this.open; } + preventDefault = true; break; } case keyHome: case keyEnd: { - e.preventDefault(); + preventDefault = true; break; } case keyEnter: { - e.preventDefault(); - this.open = !this.open; + if (this.multiple || this.listboxMode) { + if (!this.open) { + this.open = true; + break; + } + const option = this._options[this.activeIndex]; + option.selected = !option.selected; + preventDefault = true; + break; + } else { + this.open = this.collapsible && !this.open; + } + preventDefault = true; break; } - case keyEscape: { if (this.collapsible && this.open) { - e.preventDefault(); this.open = false; + preventDefault = true; } break; } case keyTab: { if (this.collapsible && this.open) { - e.preventDefault(); this.open = false; + preventDefault = true; } - return true; } } @@ -545,7 +595,7 @@ export class FASTSelect extends FormAssociatedSelect { this.indexWhenOpened = this.selectedIndex; } - return !(key === keyArrowDown || key === keyArrowUp); + return !preventDefault; } public connectedCallback() { @@ -581,7 +631,7 @@ export class FASTSelect extends FormAssociatedSelect { * @internal */ private updateDisplayValue(): void { - if (this.collapsible) { + if (this.$fastController.isConnected && this.collapsible) { Observable.notify(this, "displayValue"); } } diff --git a/packages/web-components/fast-foundation/src/select/stories/select.register.ts b/packages/web-components/fast-foundation/src/select/stories/select.register.ts index 9056d41958e..d8e4fc2e5fc 100644 --- a/packages/web-components/fast-foundation/src/select/stories/select.register.ts +++ b/packages/web-components/fast-foundation/src/select/stories/select.register.ts @@ -7,7 +7,6 @@ const styles = css` :host { display: inline-flex; --elevation: 14; - background: var(--neutral-fill-input-rest); border-radius: calc(var(--control-corner-radius) * 1px); border: calc(var(--stroke-width) * 1px) solid var(--accent-fill-rest); box-sizing: border-box; @@ -37,12 +36,14 @@ const styles = css` display: flex; flex-direction: column; padding: calc(var(--design-unit) * 1px) 0; - max-height: calc( + --max-height: calc( ( - var(--size, 0) * var(--height-number) + + var(--size) * var(--height-number) + (var(--design-unit) * var(--stroke-width) * 2) ) * 1px ); + + height: var(--max-height, fit-content); overflow-y: auto; position: fixed; top: 0; @@ -54,12 +55,6 @@ const styles = css` max-height: none; } - .control + .listbox { - --stroke-size: calc(var(--design-unit) * var(--stroke-width) * 2); - max-height: calc( - (var(--listbox-max-height) * var(--height-number) + var(--stroke-size)) * 1px - ); - } :host(:not([aria-haspopup])) .listbox { left: auto; position: static; @@ -79,6 +74,7 @@ const styles = css` min-height: 100%; padding: 0 calc(var(--design-unit) * 2.25px); width: 100%; + max-width: 250px; } :host(:not([disabled]):hover) { background: var(--neutral-fill-input-hover); @@ -142,6 +138,7 @@ const styles = css` .indicator { flex: 0 0 auto; margin-inline-start: 1em; + fill: var(--neutral-foreground-rest); } slot[name="listbox"] { display: none; @@ -155,6 +152,7 @@ const styles = css` ::slotted([slot="start"]), ::slotted([slot="end"]) { display: flex; + fill: var(--neutral-foreground-rest); } ::slotted([slot="start"]) { margin-inline-end: 11px; @@ -189,13 +187,9 @@ export class Select extends FASTSelect { protected updateComputedStylesheet(): void { this.$fastController.removeStyles(this.computedStylesheet); - if (this.collapsible) { - return; - } - this.computedStylesheet = css` :host { - --size: ${`${this.size ?? (this.multiple ? 4 : 0)}`}; + --size: ${`${this.size ? this.size : undefined}`}; } `; diff --git a/packages/web-components/fast-foundation/src/select/stories/select.stories.ts b/packages/web-components/fast-foundation/src/select/stories/select.stories.ts index 837b1b570a4..b0b42ba408d 100644 --- a/packages/web-components/fast-foundation/src/select/stories/select.stories.ts +++ b/packages/web-components/fast-foundation/src/select/stories/select.stories.ts @@ -11,6 +11,8 @@ const storyTemplate = html>` ?multiple="${x => x.multiple}" size="${x => x.size}" value="${x => x.value}" + listbox-mode="${x => x.listboxMode}" + placeholder="${x => x.placeholder}" > ${x => x.storyContent} @@ -22,6 +24,8 @@ export default { disabled: false, multiple: false, open: false, + listboxMode: false, + placeholder: undefined, storyContent: html>` ${repeat(x => x.storyItems, listboxOptionTemplate)} `, @@ -43,10 +47,12 @@ export default { ], }, argTypes: { + listboxMode: { control: "boolean" }, disabled: { control: "boolean" }, name: { control: "text" }, multiple: { control: "boolean" }, open: { control: "boolean" }, + placeholder: { control: "text" }, size: { control: "number" }, storyContent: { table: { disable: true } }, storyItems: { control: "object" }, @@ -59,6 +65,7 @@ export const Select: Story = renderComponent(storyTemplate).bind({}) export const SelectMultiple: Story = Select.bind({}); SelectMultiple.args = { multiple: true, + placeholder: "Select an option", }; export const SelectWithSize: Story = Select.bind({}); @@ -71,6 +78,16 @@ SelectDisabled.args = { disabled: true, }; +export const SelectListboxMode: Story = Select.bind({}); +SelectListboxMode.args = { + listboxMode: true, +}; + +export const SelectPlaceholder: Story = Select.bind({}); +SelectPlaceholder.args = { + placeholder: "Select an option", +}; + export const SelectWithSlottedStartEnd: Story = Select.bind({}); SelectWithSlottedStartEnd.args = { storyContent: html`