From e94ddc9fd2bee068395ed6f8f65e36486f64771e Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Mon, 26 Jan 2026 20:07:54 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(Select)=20add=20classic=20variant=20w?= =?UTF-8?q?ith=20placeholder=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/components/Forms/Select/_index.scss | 94 +++++++++++- .../src/components/Forms/Select/index.tsx | 3 + .../components/Forms/Select/mono-common.tsx | 137 ++++++++++------- .../src/components/Forms/Select/mono.spec.tsx | 118 ++++++++++++++ .../components/Forms/Select/mono.stories.tsx | 66 ++++++++ .../components/Forms/Select/multi-common.tsx | 145 +++++++++++------- .../components/Forms/Select/multi.spec.tsx | 101 ++++++++++++ .../components/Forms/Select/multi.stories.tsx | 49 ++++++ .../src/components/Forms/Select/tokens.ts | 1 + 9 files changed, 601 insertions(+), 113 deletions(-) diff --git a/packages/react/src/components/Forms/Select/_index.scss b/packages/react/src/components/Forms/Select/_index.scss index b9dfd97..3d114e1 100644 --- a/packages/react/src/components/Forms/Select/_index.scss +++ b/packages/react/src/components/Forms/Select/_index.scss @@ -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 + ); } } diff --git a/packages/react/src/components/Forms/Select/index.tsx b/packages/react/src/components/Forms/Select/index.tsx index 35791df..57fb6d9 100644 --- a/packages/react/src/components/Forms/Select/index.tsx +++ b/packages/react/src/components/Forms/Select/index.tsx @@ -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; diff --git a/packages/react/src/components/Forms/Select/mono-common.tsx b/packages/react/src/components/Forms/Select/mono-common.tsx index d374595..a237537 100644 --- a/packages/react/src/components/Forms/Select/mono-common.tsx +++ b/packages/react/src/components/Forms/Select/mono-common.tsx @@ -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(null); + const isClassic = variant === "classic"; + const showPlaceholder = + isClassic && !downshiftReturn.selectedItem && placeholder; + + const selectInner = ( +
+
+ {showPlaceholder ? ( + {placeholder} + ) : ( + children + )} +
+
+ {clearable && !disabled && downshiftReturn.selectedItem && ( + <> +
+
+ ); 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 && ( + + )} {/* 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 = ({ /> )} - -
-
{children}
-
- {clearable && !disabled && downshiftReturn.selectedItem && ( - <> -
-
-
+ {isClassic ? ( + selectInner + ) : ( + + {selectInner} + + )} diff --git a/packages/react/src/components/Forms/Select/mono.spec.tsx b/packages/react/src/components/Forms/Select/mono.spec.tsx index fea220a..6cede6e 100644 --- a/packages/react/src/components/Forms/Select/mono.spec.tsx +++ b/packages/react/src/components/Forms/Select/mono.spec.tsx @@ -2133,4 +2133,122 @@ describe(" + , + ); + // 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( + + + , + ); + + // 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( + + + , + ); + // In floating variant, placeholder prop is ignored + expect(screen.queryByText("Select a city...")).not.toBeInTheDocument(); + expect( + document.querySelector(".c__select--classic"), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/react/src/components/Forms/Select/mono.stories.tsx b/packages/react/src/components/Forms/Select/mono.stories.tsx index f1871b9..be72f01 100644 --- a/packages/react/src/components/Forms/Select/mono.stories.tsx +++ b/packages/react/src/components/Forms/Select/mono.stories.tsx @@ -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; const Template: StoryFn = (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(null); diff --git a/packages/react/src/components/Forms/Select/multi-common.tsx b/packages/react/src/components/Forms/Select/multi-common.tsx index a3b9551..bdafe33 100644 --- a/packages/react/src/components/Forms/Select/multi-common.tsx +++ b/packages/react/src/components/Forms/Select/multi-common.tsx @@ -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(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 = ( +
+
+ {props.clearable && + !props.disabled && + props.selectedItems.length > 0 && ( + <> +
+
+ {showPlaceholder ? ( + {props.placeholder} + ) : ( + <> + + {children} + + )} +
+
+ ); + return ( <> @@ -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 && ( + + )}
{ value={optionToValue(selectedItem)} /> ))} - -
-
- {props.clearable && - !props.disabled && - props.selectedItems.length > 0 && ( - <> -
-
- - {children} -
-
-
+ {isClassic ? ( + selectInner + ) : ( + + {selectInner} + + )}
diff --git a/packages/react/src/components/Forms/Select/multi.spec.tsx b/packages/react/src/components/Forms/Select/multi.spec.tsx index ee77008..e88335d 100644 --- a/packages/react/src/components/Forms/Select/multi.spec.tsx +++ b/packages/react/src/components/Forms/Select/multi.spec.tsx @@ -1895,4 +1895,105 @@ describe(" + , + ); + // 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( + + + , + ); + + // 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( + +