`
(
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`