✨(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:
@@ -3,6 +3,81 @@
|
|||||||
.c__select {
|
.c__select {
|
||||||
position: relative;
|
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 {
|
&__wrapper {
|
||||||
border-radius: var(--c--components--forms-select--border-radius);
|
border-radius: var(--c--components--forms-select--border-radius);
|
||||||
border-width: var(--c--components--forms-select--border-width);
|
border-width: var(--c--components--forms-select--border-width);
|
||||||
@@ -34,14 +109,16 @@
|
|||||||
&:hover {
|
&:hover {
|
||||||
border-radius: var(--c--components--forms-select--border-radius--hover);
|
border-radius: var(--c--components--forms-select--border-radius--hover);
|
||||||
border-color: var(--c--components--forms-select--border-color--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-within,
|
||||||
&--focus {
|
&--focus {
|
||||||
border-radius: var(--c--components--forms-select--border-radius--focus);
|
border-radius: var(--c--components--forms-select--border-radius--focus);
|
||||||
border-color: var(--c--components--forms-select--border-color--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 {
|
label {
|
||||||
color: var(--c--components--forms-select--label-color--focus);
|
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 {
|
&__inner {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -103,7 +185,9 @@
|
|||||||
color: var(--c--contextuals--content--semantic--neutral--secondary);
|
color: var(--c--contextuals--content--semantic--neutral--secondary);
|
||||||
}
|
}
|
||||||
&__separator {
|
&__separator {
|
||||||
background-color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
background-color: var(
|
||||||
|
--c--contextuals--content--semantic--neutral--tertiary
|
||||||
|
);
|
||||||
height: 24px;
|
height: 24px;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
}
|
}
|
||||||
@@ -210,7 +294,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: var(--c--contextuals--border--semantic--neutral--tertiary-hover);
|
border-color: var(
|
||||||
|
--c--contextuals--border--semantic--neutral--tertiary-hover
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { PropsWithChildren, ReactNode, RefAttributes } from "react";
|
|||||||
import { SelectMulti } from ":/components/Forms/Select/multi";
|
import { SelectMulti } from ":/components/Forms/Select/multi";
|
||||||
import { SelectMono } from ":/components/Forms/Select/mono";
|
import { SelectMono } from ":/components/Forms/Select/mono";
|
||||||
import { FieldProps } from ":/components/Forms/Field";
|
import { FieldProps } from ":/components/Forms/Field";
|
||||||
|
import type { FieldVariant } from ":/components/Forms/types";
|
||||||
|
|
||||||
export * from ":/components/Forms/Select/mono";
|
export * from ":/components/Forms/Select/mono";
|
||||||
export * from ":/components/Forms/Select/multi";
|
export * from ":/components/Forms/Select/multi";
|
||||||
@@ -32,6 +33,8 @@ export type SelectProps = PropsWithChildren &
|
|||||||
FieldProps & {
|
FieldProps & {
|
||||||
label: string;
|
label: string;
|
||||||
hideLabel?: boolean;
|
hideLabel?: boolean;
|
||||||
|
variant?: FieldVariant;
|
||||||
|
placeholder?: string;
|
||||||
options: Option[];
|
options: Option[];
|
||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Field } from ":/components/Forms/Field";
|
|||||||
import { LabelledBox } from ":/components/Forms/LabelledBox";
|
import { LabelledBox } from ":/components/Forms/LabelledBox";
|
||||||
import { Button } from ":/components/Button";
|
import { Button } from ":/components/Button";
|
||||||
import { Option, SelectProps } from ":/components/Forms/Select";
|
import { Option, SelectProps } from ":/components/Forms/Select";
|
||||||
|
import { ClassicLabel } from ":/components/Forms/ClassicLabel";
|
||||||
import { isOptionWithRender } from ":/components/Forms/Select/utils";
|
import { isOptionWithRender } from ":/components/Forms/Select/utils";
|
||||||
import { SelectMenu } from ":/components/Forms/Select/select-menu";
|
import { SelectMenu } from ":/components/Forms/Select/select-menu";
|
||||||
|
|
||||||
@@ -77,6 +78,8 @@ export const SelectMonoAux = ({
|
|||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
hideLabel,
|
hideLabel,
|
||||||
|
variant = "floating",
|
||||||
|
placeholder,
|
||||||
labelAsPlaceholder,
|
labelAsPlaceholder,
|
||||||
downshiftProps,
|
downshiftProps,
|
||||||
downshiftReturn,
|
downshiftReturn,
|
||||||
@@ -89,6 +92,60 @@ export const SelectMonoAux = ({
|
|||||||
const { t } = useCunningham();
|
const { t } = useCunningham();
|
||||||
const labelProps = downshiftReturn.getLabelProps();
|
const labelProps = downshiftReturn.getLabelProps();
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -101,12 +158,24 @@ export const SelectMonoAux = ({
|
|||||||
"c__select--" + state,
|
"c__select--" + state,
|
||||||
{
|
{
|
||||||
"c__select--disabled": disabled,
|
"c__select--disabled": disabled,
|
||||||
|
"c__select--classic": isClassic,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
onBlur={() =>
|
onBlur={() =>
|
||||||
onBlur?.({ target: { value: downshiftReturn.selectedItem?.value } })
|
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 */}
|
{/* 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. */}
|
{/* mouse users, so this do not engender any issue for accessibility. */}
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||||
@@ -124,59 +193,21 @@ export const SelectMonoAux = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<LabelledBox
|
{isClassic ? (
|
||||||
label={label}
|
selectInner
|
||||||
hideLabel={hideLabel}
|
) : (
|
||||||
labelAsPlaceholder={labelAsPlaceholder}
|
<LabelledBox
|
||||||
htmlFor={labelProps.htmlFor}
|
label={label}
|
||||||
labelId={labelProps.id}
|
hideLabel={hideLabel}
|
||||||
disabled={disabled}
|
variant={variant}
|
||||||
>
|
labelAsPlaceholder={labelAsPlaceholder}
|
||||||
<div className="c__select__inner">
|
htmlFor={labelProps.htmlFor}
|
||||||
<div className="c__select__inner__value">{children}</div>
|
labelId={labelProps.id}
|
||||||
<div className="c__select__inner__actions">
|
disabled={disabled}
|
||||||
{clearable && !disabled && downshiftReturn.selectedItem && (
|
>
|
||||||
<>
|
{selectInner}
|
||||||
<Button
|
</LabelledBox>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -2133,4 +2133,122 @@ describe("<Select/>", () => {
|
|||||||
document.querySelector(".c__field.my-custom-class"),
|
document.querySelector(".c__field.my-custom-class"),
|
||||||
).toBeInTheDocument();
|
).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ import { Input } from ":/components/Forms/Input";
|
|||||||
export default {
|
export default {
|
||||||
title: "Components/Forms/Select/Mono",
|
title: "Components/Forms/Select/Mono",
|
||||||
component: Select,
|
component: Select,
|
||||||
|
argTypes: {
|
||||||
|
variant: {
|
||||||
|
control: "select",
|
||||||
|
options: ["floating", "classic"],
|
||||||
|
},
|
||||||
|
},
|
||||||
} as Meta<typeof Select>;
|
} as Meta<typeof Select>;
|
||||||
|
|
||||||
const Template: StoryFn<typeof Select> = (args) => (
|
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 = () => {
|
export const Ref = () => {
|
||||||
const ref = useRef<SelectHandle>(null);
|
const ref = useRef<SelectHandle>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getOptionsFilter,
|
getOptionsFilter,
|
||||||
optionToValue,
|
optionToValue,
|
||||||
} from ":/components/Forms/Select/mono-common";
|
} from ":/components/Forms/Select/mono-common";
|
||||||
|
import { ClassicLabel } from ":/components/Forms/ClassicLabel";
|
||||||
import { SelectedItems } from ":/components/Forms/Select/multi-selected-items";
|
import { SelectedItems } from ":/components/Forms/Select/multi-selected-items";
|
||||||
import { SelectMultiMenu } from ":/components/Forms/Select/multi-menu";
|
import { SelectMultiMenu } from ":/components/Forms/Select/multi-menu";
|
||||||
|
|
||||||
@@ -64,10 +65,71 @@ export const SelectMultiAux = ({ children, ...props }: SelectMultiAuxProps) => {
|
|||||||
const { t } = useCunningham();
|
const { t } = useCunningham();
|
||||||
const labelProps = props.downshiftReturn.getLabelProps();
|
const labelProps = props.downshiftReturn.getLabelProps();
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
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 need to remove onBlur from toggleButtonProps because it triggers a menu closing each time
|
||||||
// we tick a checkbox using the monoline style.
|
// we tick a checkbox using the monoline style.
|
||||||
const { onBlur, ...toggleProps } = props.downshiftReturn.toggleButtonProps;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Field {...props}>
|
<Field {...props}>
|
||||||
@@ -83,9 +145,21 @@ export const SelectMultiAux = ({ children, ...props }: SelectMultiAuxProps) => {
|
|||||||
"c__select--populated": props.selectedItems.length > 0,
|
"c__select--populated": props.selectedItems.length > 0,
|
||||||
"c__select--monoline": props.monoline,
|
"c__select--monoline": props.monoline,
|
||||||
"c__select--multiline": !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
|
<div
|
||||||
className={classNames("c__select__wrapper", {
|
className={classNames("c__select__wrapper", {
|
||||||
"c__select__wrapper--focus":
|
"c__select__wrapper--focus":
|
||||||
@@ -102,62 +176,21 @@ export const SelectMultiAux = ({ children, ...props }: SelectMultiAuxProps) => {
|
|||||||
value={optionToValue(selectedItem)}
|
value={optionToValue(selectedItem)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<LabelledBox
|
{isClassic ? (
|
||||||
label={props.label}
|
selectInner
|
||||||
labelAsPlaceholder={props.labelAsPlaceholder}
|
) : (
|
||||||
htmlFor={labelProps.htmlFor}
|
<LabelledBox
|
||||||
labelId={labelProps.id}
|
label={props.label}
|
||||||
hideLabel={props.hideLabel}
|
variant={variant}
|
||||||
disabled={props.disabled}
|
labelAsPlaceholder={props.labelAsPlaceholder}
|
||||||
>
|
htmlFor={labelProps.htmlFor}
|
||||||
<div className="c__select__inner">
|
labelId={labelProps.id}
|
||||||
<div className="c__select__inner__actions">
|
hideLabel={props.hideLabel}
|
||||||
{props.clearable &&
|
disabled={props.disabled}
|
||||||
!props.disabled &&
|
>
|
||||||
props.selectedItems.length > 0 && (
|
{selectInner}
|
||||||
<>
|
</LabelledBox>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -1895,4 +1895,105 @@ describe("<Select multi={true} />", () => {
|
|||||||
document.querySelector(".c__field.my-custom-class"),
|
document.querySelector(".c__field.my-custom-class"),
|
||||||
).toBeInTheDocument();
|
).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ import { Input } from ":/components/Forms/Input";
|
|||||||
export default {
|
export default {
|
||||||
title: "Components/Forms/Select/Multi",
|
title: "Components/Forms/Select/Multi",
|
||||||
component: Select,
|
component: Select,
|
||||||
|
argTypes: {
|
||||||
|
variant: {
|
||||||
|
control: "select",
|
||||||
|
options: ["floating", "classic"],
|
||||||
|
},
|
||||||
|
},
|
||||||
} as Meta<typeof Select>;
|
} as Meta<typeof Select>;
|
||||||
|
|
||||||
const Template: StoryFn<typeof Select> = (args) => {
|
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 = {
|
export const WithText = {
|
||||||
render: Template,
|
render: Template,
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@@ -33,4 +33,5 @@ export const tokens = (defaults: DefaultTokens) => ({
|
|||||||
"multi-pill-border-radius": "2px",
|
"multi-pill-border-radius": "2px",
|
||||||
"multi-pill-max-width": "68%",
|
"multi-pill-max-width": "68%",
|
||||||
"multi-pill-font-size": defaults.globals.font.sizes.md,
|
"multi-pill-font-size": defaults.globals.font.sizes.md,
|
||||||
|
"placeholder-color": defaults.contextuals.content.semantic.neutral.secondary,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user