(Select) add classic variant with placeholder support

- Use FieldVariant enum for variant prop
- Add styled placeholder for classic mode (.c__select__placeholder)
- Label rendered outside wrapper in classic mode
- Compact height in classic mode
- Add unit tests for mono and multi Select
- Add Storybook stories

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Panchout
2026-01-26 20:07:54 +01:00
parent fb0c8c0f10
commit e94ddc9fd2
9 changed files with 601 additions and 113 deletions

View File

@@ -3,6 +3,81 @@
.c__select {
position: relative;
&__label {
display: block;
font-size: var(--c--components--forms-labelledbox--classic-label-font-size);
color: var(--c--components--forms-labelledbox--label-color--small);
margin-bottom: var(
--c--components--forms-labelledbox--classic-label-margin-bottom
);
cursor: pointer;
&--disabled {
color: var(
--c--components--forms-labelledbox--label-color--small--disabled
);
cursor: default;
}
}
&--classic {
.c__select__wrapper {
align-items: center;
height: 2.75rem;
}
.c__select__inner {
align-items: center;
&__actions {
top: 0;
}
}
// When not populated, keep flex layout so actions are vertically centered
// alongside the placeholder text (single line).
&.c__select--multiline:not(.c__select--populated) {
.c__select__wrapper {
min-height: 2.75rem;
}
.c__select__inner {
display: flex;
&__actions {
float: none;
height: auto;
top: 0;
order: 2;
}
}
}
// When populated, keep flex layout and use fixed padding instead of
// centering so that the first row of pills stays at a consistent
// vertical position regardless of how many rows wrap below.
&.c__select--multiline.c__select--populated {
.c__select__wrapper {
align-items: flex-start;
min-height: 2.75rem;
padding-top: 9px;
padding-bottom: 0.25rem;
}
.c__select__inner {
display: flex;
align-items: flex-start;
&__actions {
float: none;
height: auto;
top: 0;
order: 2;
}
}
}
}
&__wrapper {
border-radius: var(--c--components--forms-select--border-radius);
border-width: var(--c--components--forms-select--border-width);
@@ -34,14 +109,16 @@
&:hover {
border-radius: var(--c--components--forms-select--border-radius--hover);
border-color: var(--c--components--forms-select--border-color--hover);
box-shadow: 0 0 0 var(--c--components--forms-select--border-width--hover) var(--c--components--forms-select--border-color--hover);
box-shadow: 0 0 0 var(--c--components--forms-select--border-width--hover)
var(--c--components--forms-select--border-color--hover);
}
&:focus-within,
&--focus {
border-radius: var(--c--components--forms-select--border-radius--focus);
border-color: var(--c--components--forms-select--border-color--focus);
box-shadow: 0 0 0 var(--c--components--forms-select--border-width--focus) var(--c--components--forms-select--border-color--focus);
box-shadow: 0 0 0 var(--c--components--forms-select--border-width--focus)
var(--c--components--forms-select--border-color--focus);
label {
color: var(--c--components--forms-select--label-color--focus);
@@ -49,6 +126,11 @@
}
}
&__placeholder {
color: var(--c--components--forms-select--placeholder-color);
font-size: var(--c--components--forms-select--font-size);
}
&__inner {
flex-grow: 1;
display: flex;
@@ -103,7 +185,9 @@
color: var(--c--contextuals--content--semantic--neutral--secondary);
}
&__separator {
background-color: var(--c--contextuals--content--semantic--neutral--tertiary);
background-color: var(
--c--contextuals--content--semantic--neutral--tertiary
);
height: 24px;
width: 1px;
}
@@ -210,7 +294,9 @@
}
&:hover {
border-color: var(--c--contextuals--border--semantic--neutral--tertiary-hover);
border-color: var(
--c--contextuals--border--semantic--neutral--tertiary-hover
);
}
}

View File

@@ -2,6 +2,7 @@ import React, { PropsWithChildren, ReactNode, RefAttributes } from "react";
import { SelectMulti } from ":/components/Forms/Select/multi";
import { SelectMono } from ":/components/Forms/Select/mono";
import { FieldProps } from ":/components/Forms/Field";
import type { FieldVariant } from ":/components/Forms/types";
export * from ":/components/Forms/Select/mono";
export * from ":/components/Forms/Select/multi";
@@ -32,6 +33,8 @@ export type SelectProps = PropsWithChildren &
FieldProps & {
label: string;
hideLabel?: boolean;
variant?: FieldVariant;
placeholder?: string;
options: Option[];
searchable?: boolean;
name?: string;

View File

@@ -6,6 +6,7 @@ import { Field } from ":/components/Forms/Field";
import { LabelledBox } from ":/components/Forms/LabelledBox";
import { Button } from ":/components/Button";
import { Option, SelectProps } from ":/components/Forms/Select";
import { ClassicLabel } from ":/components/Forms/ClassicLabel";
import { isOptionWithRender } from ":/components/Forms/Select/utils";
import { SelectMenu } from ":/components/Forms/Select/select-menu";
@@ -77,6 +78,8 @@ export const SelectMonoAux = ({
name,
label,
hideLabel,
variant = "floating",
placeholder,
labelAsPlaceholder,
downshiftProps,
downshiftReturn,
@@ -89,6 +92,60 @@ export const SelectMonoAux = ({
const { t } = useCunningham();
const labelProps = downshiftReturn.getLabelProps();
const ref = useRef<HTMLDivElement>(null);
const isClassic = variant === "classic";
const showPlaceholder =
isClassic && !downshiftReturn.selectedItem && placeholder;
const selectInner = (
<div className="c__select__inner">
<div className="c__select__inner__value">
{showPlaceholder ? (
<span className="c__select__placeholder">{placeholder}</span>
) : (
children
)}
</div>
<div className="c__select__inner__actions">
{clearable && !disabled && downshiftReturn.selectedItem && (
<>
<Button
variant="tertiary"
color="neutral"
size="nano"
aria-label={t("components.forms.select.clear_button_aria_label")}
className="c__select__inner__actions__clear"
onClick={(e) => {
downshiftReturn.selectItem(null);
e.stopPropagation();
}}
icon={<span className="material-icons">close</span>}
type="button"
/>
<div className="c__select__inner__actions__separator" />
</>
)}
<Button
variant="tertiary"
color="neutral"
size="nano"
className="c__select__inner__actions__open"
icon={
<span
className={classNames("material-icons", {
opened: downshiftReturn.isOpen,
})}
>
arrow_drop_down
</span>
}
disabled={disabled}
type="button"
{...downshiftReturn.toggleButtonProps}
/>
</div>
</div>
);
return (
<>
@@ -101,12 +158,24 @@ export const SelectMonoAux = ({
"c__select--" + state,
{
"c__select--disabled": disabled,
"c__select--classic": isClassic,
},
)}
onBlur={() =>
onBlur?.({ target: { value: downshiftReturn.selectedItem?.value } })
}
>
{isClassic && (
<ClassicLabel
label={label}
hideLabel={hideLabel}
disabled={disabled}
className="c__select__label"
disabledClassName="c__select__label--disabled"
htmlFor={labelProps.htmlFor}
id={labelProps.id}
/>
)}
{/* We disabled linting for this specific line because we consider that the onClick props is only used for */}
{/* mouse users, so this do not engender any issue for accessibility. */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
@@ -124,59 +193,21 @@ export const SelectMonoAux = ({
/>
)}
<LabelledBox
label={label}
hideLabel={hideLabel}
labelAsPlaceholder={labelAsPlaceholder}
htmlFor={labelProps.htmlFor}
labelId={labelProps.id}
disabled={disabled}
>
<div className="c__select__inner">
<div className="c__select__inner__value">{children}</div>
<div className="c__select__inner__actions">
{clearable && !disabled && downshiftReturn.selectedItem && (
<>
<Button
variant="tertiary"
color="neutral"
size="nano"
aria-label={t(
"components.forms.select.clear_button_aria_label",
)}
className="c__select__inner__actions__clear"
onClick={(e) => {
downshiftReturn.selectItem(null);
e.stopPropagation();
}}
icon={<span className="material-icons">close</span>}
type="button"
/>
<div className="c__select__inner__actions__separator" />
</>
)}
<Button
variant="tertiary"
color="neutral"
size="nano"
className="c__select__inner__actions__open"
icon={
<span
className={classNames("material-icons", {
opened: downshiftReturn.isOpen,
})}
>
arrow_drop_down
</span>
}
disabled={disabled}
type="button"
{...downshiftReturn.toggleButtonProps}
/>
</div>
</div>
</LabelledBox>
{isClassic ? (
selectInner
) : (
<LabelledBox
label={label}
hideLabel={hideLabel}
variant={variant}
labelAsPlaceholder={labelAsPlaceholder}
htmlFor={labelProps.htmlFor}
labelId={labelProps.id}
disabled={disabled}
>
{selectInner}
</LabelledBox>
)}
</div>
</div>
</Field>

View File

@@ -2133,4 +2133,122 @@ describe("<Select/>", () => {
document.querySelector(".c__field.my-custom-class"),
).toBeInTheDocument();
});
describe("classic variant", () => {
it("renders with classic variant", async () => {
render(
<CunninghamProvider>
<Select
label="City"
variant="classic"
options={[
{ label: "Paris", value: "paris" },
{ label: "London", value: "london" },
]}
/>
</CunninghamProvider>,
);
// In classic mode, label is rendered outside the wrapper with its own class
expect(document.querySelector(".c__select__label")).toBeInTheDocument();
expect(document.querySelector(".c__select--classic")).toBeInTheDocument();
});
it("shows placeholder in classic variant when no selection", async () => {
render(
<CunninghamProvider>
<Select
label="City"
variant="classic"
placeholder="Select a city..."
options={[
{ label: "Paris", value: "paris" },
{ label: "London", value: "london" },
]}
/>
</CunninghamProvider>,
);
expect(screen.getByText("Select a city...")).toBeInTheDocument();
expect(
document.querySelector(".c__select__placeholder"),
).toBeInTheDocument();
});
it("hides placeholder after selection in classic variant", async () => {
const user = userEvent.setup();
render(
<CunninghamProvider>
<Select
label="City"
variant="classic"
placeholder="Select a city..."
options={[
{ label: "Paris", value: "paris" },
{ label: "London", value: "london" },
]}
/>
</CunninghamProvider>,
);
// Placeholder should be visible initially
expect(screen.getByText("Select a city...")).toBeInTheDocument();
// Open menu and select an option
const input = screen.getByRole("combobox", { name: "City" });
await user.click(input);
await user.click(screen.getByRole("option", { name: "Paris" }));
// Placeholder should be hidden, value should be shown
expect(screen.queryByText("Select a city...")).not.toBeInTheDocument();
const valueRendered = document.querySelector(".c__select__inner__value");
expect(valueRendered).toHaveTextContent("Paris");
});
it("label is always static in classic variant", async () => {
const user = userEvent.setup();
render(
<CunninghamProvider>
<Select
label="City"
variant="classic"
options={[
{ label: "Paris", value: "paris" },
{ label: "London", value: "london" },
]}
/>
</CunninghamProvider>,
);
const label = screen.getByText("City");
// In classic variant, label is outside the wrapper with c__select__label class
expect(label.classList.contains("c__select__label")).toBe(true);
// Open menu
const input = screen.getByRole("combobox", { name: "City" });
await user.click(input);
// Label should still have the same class
expect(label.classList.contains("c__select__label")).toBe(true);
});
it("defaults to floating variant (no placeholder shown)", () => {
render(
<CunninghamProvider>
<Select
label="City"
placeholder="Select a city..."
options={[
{ label: "Paris", value: "paris" },
{ label: "London", value: "london" },
]}
/>
</CunninghamProvider>,
);
// In floating variant, placeholder prop is ignored
expect(screen.queryByText("Select a city...")).not.toBeInTheDocument();
expect(
document.querySelector(".c__select--classic"),
).not.toBeInTheDocument();
});
});
});

View File

@@ -16,6 +16,12 @@ import { Input } from ":/components/Forms/Input";
export default {
title: "Components/Forms/Select/Mono",
component: Select,
argTypes: {
variant: {
control: "select",
options: ["floating", "classic"],
},
},
} as Meta<typeof Select>;
const Template: StoryFn<typeof Select> = (args) => (
@@ -303,6 +309,66 @@ export const SearchableCustomRender = {
},
};
export const ClassicVariant = {
render: Template,
args: {
label: "Select a city",
variant: "classic",
placeholder: "Choose a city...",
options: OPTIONS,
},
};
export const ClassicVariantFilled = {
render: Template,
args: {
label: "Select a city",
variant: "classic",
placeholder: "Choose a city...",
options: OPTIONS,
defaultValue: OPTIONS[4].value,
},
};
export const ClassicVariantSearchable = {
render: Template,
args: {
label: "Select a city",
variant: "classic",
placeholder: "Search for a city...",
options: OPTIONS,
searchable: true,
},
};
export const ClassicVariantDisabled = {
render: Template,
args: {
label: "Select a city",
variant: "classic",
placeholder: "Choose a city...",
options: OPTIONS,
disabled: true,
},
};
export const ClassicVariantError = {
render: Template,
args: {
label: "Select a city",
variant: "classic",
placeholder: "Choose a city...",
options: OPTIONS,
state: "error",
text: "Please select a city",
},
};
export const Ref = () => {
const ref = useRef<SelectHandle>(null);

View File

@@ -10,6 +10,7 @@ import {
getOptionsFilter,
optionToValue,
} from ":/components/Forms/Select/mono-common";
import { ClassicLabel } from ":/components/Forms/ClassicLabel";
import { SelectedItems } from ":/components/Forms/Select/multi-selected-items";
import { SelectMultiMenu } from ":/components/Forms/Select/multi-menu";
@@ -64,10 +65,71 @@ export const SelectMultiAux = ({ children, ...props }: SelectMultiAuxProps) => {
const { t } = useCunningham();
const labelProps = props.downshiftReturn.getLabelProps();
const ref = useRef<HTMLDivElement>(null);
const variant = props.variant ?? "floating";
const isClassic = variant === "classic";
const showPlaceholder =
isClassic && props.selectedItems.length === 0 && props.placeholder;
// We need to remove onBlur from toggleButtonProps because it triggers a menu closing each time
// we tick a checkbox using the monoline style.
const { onBlur, ...toggleProps } = props.downshiftReturn.toggleButtonProps;
const selectInner = (
<div className="c__select__inner">
<div className="c__select__inner__actions">
{props.clearable &&
!props.disabled &&
props.selectedItems.length > 0 && (
<>
<Button
variant="tertiary"
color="neutral"
size="nano"
aria-label={t(
"components.forms.select.clear_all_button_aria_label",
)}
className="c__select__inner__actions__clear"
onClick={(e) => {
e.stopPropagation();
props.onSelectedItemsChange([]);
}}
icon={<span className="material-icons">close</span>}
type="button"
/>
<div className="c__select__inner__actions__separator" />
</>
)}
<Button
variant="tertiary"
color="neutral"
size="nano"
className="c__select__inner__actions__open"
icon={
<span
className={classNames("material-icons", {
opened: props.downshiftReturn.isOpen,
})}
>
arrow_drop_down
</span>
}
disabled={props.disabled}
type="button"
/>
</div>
<div className="c__select__inner__value">
{showPlaceholder ? (
<span className="c__select__placeholder">{props.placeholder}</span>
) : (
<>
<SelectedItems {...props} />
{children}
</>
)}
</div>
</div>
);
return (
<>
<Field {...props}>
@@ -83,9 +145,21 @@ export const SelectMultiAux = ({ children, ...props }: SelectMultiAuxProps) => {
"c__select--populated": props.selectedItems.length > 0,
"c__select--monoline": props.monoline,
"c__select--multiline": !props.monoline,
"c__select--classic": isClassic,
},
)}
>
{isClassic && (
<ClassicLabel
label={props.label}
hideLabel={props.hideLabel}
disabled={props.disabled}
className="c__select__label"
disabledClassName="c__select__label--disabled"
htmlFor={labelProps.htmlFor}
id={labelProps.id}
/>
)}
<div
className={classNames("c__select__wrapper", {
"c__select__wrapper--focus":
@@ -102,62 +176,21 @@ export const SelectMultiAux = ({ children, ...props }: SelectMultiAuxProps) => {
value={optionToValue(selectedItem)}
/>
))}
<LabelledBox
label={props.label}
labelAsPlaceholder={props.labelAsPlaceholder}
htmlFor={labelProps.htmlFor}
labelId={labelProps.id}
hideLabel={props.hideLabel}
disabled={props.disabled}
>
<div className="c__select__inner">
<div className="c__select__inner__actions">
{props.clearable &&
!props.disabled &&
props.selectedItems.length > 0 && (
<>
<Button
variant="tertiary"
color="neutral"
size="nano"
aria-label={t(
"components.forms.select.clear_all_button_aria_label",
)}
className="c__select__inner__actions__clear"
onClick={(e) => {
e.stopPropagation();
props.onSelectedItemsChange([]);
}}
icon={<span className="material-icons">close</span>}
type="button"
/>
<div className="c__select__inner__actions__separator" />
</>
)}
<Button
variant="tertiary"
color="neutral"
size="nano"
className="c__select__inner__actions__open"
icon={
<span
className={classNames("material-icons", {
opened: props.downshiftReturn.isOpen,
})}
>
arrow_drop_down
</span>
}
disabled={props.disabled}
type="button"
/>
</div>
<div className="c__select__inner__value">
<SelectedItems {...props} />
{children}
</div>
</div>
</LabelledBox>
{isClassic ? (
selectInner
) : (
<LabelledBox
label={props.label}
variant={variant}
labelAsPlaceholder={props.labelAsPlaceholder}
htmlFor={labelProps.htmlFor}
labelId={labelProps.id}
hideLabel={props.hideLabel}
disabled={props.disabled}
>
{selectInner}
</LabelledBox>
)}
</div>
</div>
</Field>

View File

@@ -1895,4 +1895,105 @@ describe("<Select multi={true} />", () => {
document.querySelector(".c__field.my-custom-class"),
).toBeInTheDocument();
});
describe("classic variant", () => {
it("renders with classic variant", async () => {
render(
<CunninghamProvider>
<Select
label="Cities"
variant="classic"
multi={true}
options={[
{ label: "Paris", value: "paris" },
{ label: "London", value: "london" },
]}
/>
</CunninghamProvider>,
);
// In classic mode, label is rendered outside the wrapper with its own class
expect(document.querySelector(".c__select__label")).toBeInTheDocument();
expect(document.querySelector(".c__select--classic")).toBeInTheDocument();
});
it("shows placeholder in classic variant when no selection", async () => {
render(
<CunninghamProvider>
<Select
label="Cities"
variant="classic"
placeholder="Select cities..."
multi={true}
options={[
{ label: "Paris", value: "paris" },
{ label: "London", value: "london" },
]}
/>
</CunninghamProvider>,
);
expect(screen.getByText("Select cities...")).toBeInTheDocument();
expect(
document.querySelector(".c__select__placeholder"),
).toBeInTheDocument();
});
it("hides placeholder after selection in classic variant", async () => {
const user = userEvent.setup();
render(
<CunninghamProvider>
<Select
label="Cities"
variant="classic"
placeholder="Select cities..."
multi={true}
options={[
{ label: "Paris", value: "paris" },
{ label: "London", value: "london" },
]}
/>
</CunninghamProvider>,
);
// Placeholder should be visible initially
expect(screen.getByText("Select cities...")).toBeInTheDocument();
// Open menu and select an option
const input = screen.getByRole("combobox", { name: "Cities" });
await user.click(input);
await user.click(screen.getByRole("option", { name: "Paris" }));
// Placeholder should be hidden, selection should be shown
expect(screen.queryByText("Select cities...")).not.toBeInTheDocument();
expectSelectedOptions(["Paris"]);
});
it("label is always static in classic variant", async () => {
const user = userEvent.setup();
render(
<CunninghamProvider>
<Select
label="Cities"
variant="classic"
multi={true}
options={[
{ label: "Paris", value: "paris" },
{ label: "London", value: "london" },
]}
/>
</CunninghamProvider>,
);
const label = screen.getByText("Cities");
// In classic variant, label is outside the wrapper with c__select__label class
expect(label.classList.contains("c__select__label")).toBe(true);
// Open menu
const input = screen.getByRole("combobox", { name: "Cities" });
await user.click(input);
// Label should still have the same class
expect(label.classList.contains("c__select__label")).toBe(true);
});
});
});

View File

@@ -16,6 +16,12 @@ import { Input } from ":/components/Forms/Input";
export default {
title: "Components/Forms/Select/Multi",
component: Select,
argTypes: {
variant: {
control: "select",
options: ["floating", "classic"],
},
},
} as Meta<typeof Select>;
const Template: StoryFn<typeof Select> = (args) => {
@@ -73,6 +79,49 @@ export const Monoline = {
},
};
export const ClassicVariant = {
render: Template,
args: {
label: "Select cities",
variant: "classic",
placeholder: "Choose cities...",
options: OPTIONS,
},
};
export const ClassicVariantFilled = {
render: Template,
args: {
label: "Select cities",
variant: "classic",
placeholder: "Choose cities...",
options: OPTIONS,
defaultValue: [OPTIONS[4].value, OPTIONS[2].value],
},
};
export const ClassicVariantSearchable = {
render: Template,
args: {
label: "Select cities",
variant: "classic",
placeholder: "Search for cities...",
options: OPTIONS,
searchable: true,
},
};
export const ClassicVariantDisabled = {
render: Template,
args: {
label: "Select cities",
variant: "classic",
placeholder: "Choose cities...",
options: OPTIONS,
disabled: true,
},
};
export const WithText = {
render: Template,
args: {

View File

@@ -33,4 +33,5 @@ export const tokens = (defaults: DefaultTokens) => ({
"multi-pill-border-radius": "2px",
"multi-pill-max-width": "68%",
"multi-pill-font-size": defaults.globals.font.sizes.md,
"placeholder-color": defaults.contextuals.content.semantic.neutral.secondary,
});