(DatePicker) add classic variant and hideLabel props

- Add variant prop to DatePicker and DateRangePicker
- Add hideLabel prop for accessible hidden labels
- Label(s) rendered outside wrapper in classic mode
- DateRangePicker shows both labels above fields in classic mode
- Compact height in classic mode
- Add unit tests and Storybook stories

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Panchout
2026-01-26 20:08:04 +01:00
parent e94ddc9fd2
commit 2a39c8e72c
8 changed files with 457 additions and 10 deletions

View File

@@ -13,6 +13,7 @@ import {
import { createCalendar, DateValue } from "@internationalized/date";
import classNames from "classnames";
import { LabelledBox, Props } from ":/components/Forms/LabelledBox";
import type { FieldVariant } from ":/components/Forms/types";
interface DateSegmentProps {
currentSegment: DateSegment;
@@ -68,10 +69,15 @@ const DateField = (props: AriaDatePickerProps<DateValue>) => {
interface DateFieldBoxProps
extends Props,
Omit<AriaDatePickerProps<DateValue>, "label"> {}
Omit<AriaDatePickerProps<DateValue>, "label"> {
variant?: FieldVariant;
}
const DateFieldBox = ({ ...props }: DateFieldBoxProps) => (
<LabelledBox {...props}>
const DateFieldBox = ({
variant = "floating",
...props
}: DateFieldBoxProps) => (
<LabelledBox {...props} variant={variant}>
<div
className={classNames("c__date-picker__inner", {
"c__date-picker__inner--collapsed": props.labelAsPlaceholder,

View File

@@ -1459,4 +1459,82 @@ describe("<DatePicker/>", () => {
document.querySelector(".c__field.my-custom-class"),
).toBeInTheDocument();
});
describe("classic variant", () => {
it("renders with classic variant", async () => {
render(
<CunninghamProvider>
<DatePicker label="Pick a date" variant="classic" />
</CunninghamProvider>,
);
// In classic mode, label is rendered outside the wrapper with its own class
expect(
document.querySelector(".c__date-picker__label"),
).toBeInTheDocument();
expect(
document.querySelector(".c__date-picker--classic"),
).toBeInTheDocument();
});
it("label is always static in classic variant", async () => {
const user = userEvent.setup();
render(
<CunninghamProvider>
<DatePicker label="Pick a date" variant="classic" />
</CunninghamProvider>,
);
const label = screen.getByText("Pick a date");
// In classic variant, label is outside the wrapper with c__date-picker__label class
expect(label.classList.contains("c__date-picker__label")).toBe(true);
// Open calendar
const toggleButton = (await screen.findAllByRole("button"))![1];
await user.click(toggleButton);
// Label should still have the same class
expect(label.classList.contains("c__date-picker__label")).toBe(true);
});
it("defaults to floating variant", () => {
render(
<CunninghamProvider>
<DatePicker label="Pick a date" />
</CunninghamProvider>,
);
expect(
document.querySelector(".c__date-picker--classic"),
).not.toBeInTheDocument();
});
});
describe("hideLabel", () => {
it("hides label visually but keeps it accessible in floating variant", () => {
render(
<CunninghamProvider>
<DatePicker label="Pick a date" hideLabel />
</CunninghamProvider>,
);
// Label should be visually hidden via LabelledBox
const label = screen.getByText("Pick a date");
expect(label.closest("label")).toHaveClass("c__offscreen");
});
it("hides label visually but keeps it accessible in classic variant", () => {
render(
<CunninghamProvider>
<DatePicker label="Pick a date" variant="classic" hideLabel />
</CunninghamProvider>,
);
// Label should be visually hidden with c__offscreen class
const label = screen.getByText("Pick a date");
expect(label).toHaveClass("c__offscreen");
// The visible label class should not be present
expect(
document.querySelector(".c__date-picker__label:not(.c__offscreen)"),
).not.toBeInTheDocument();
});
});
});

View File

@@ -51,6 +51,7 @@ export const DatePicker = (props: DatePickerProps) => {
ref,
);
const isClassic = props.variant === "classic";
const labelAsPlaceholder = useMemo(
() => !isFocused && !pickerState.isOpen && !pickerState.value,
[pickerState.value, pickerState.isOpen, isFocused],
@@ -74,8 +75,12 @@ export const DatePicker = (props: DatePickerProps) => {
<DateFieldBox
{...{
...fieldProps,
label: props.label,
labelAsPlaceholder,
// In classic mode, label is rendered outside by DatePickerAux
label: isClassic ? undefined : props.label,
variant: props.variant,
hideLabel: isClassic ? undefined : props.hideLabel,
// In classic mode, always show date segments (never collapse them)
labelAsPlaceholder: isClassic ? false : labelAsPlaceholder,
onFocusChange: setIsFocused,
disabled: props.disabled,
}}

View File

@@ -14,17 +14,23 @@ import { I18nProvider } from "@react-aria/i18n";
import { Button } from ":/components/Button";
import { Popover } from ":/components/Popover";
import { Field, FieldProps } from ":/components/Forms/Field";
import { ClassicLabel } from ":/components/Forms/ClassicLabel";
import { useCunningham } from ":/components/Provider";
import {
Calendar,
CalendarRange,
} from ":/components/Forms/DatePicker/Calendar";
import { convertDateValueToString } from ":/components/Forms/DatePicker/utils";
import type { FieldVariant } from ":/components/Forms/types";
export type DatePickerAuxSubProps = FieldProps & {
// eslint-disable-next-line react/no-unused-prop-types
label?: string;
// eslint-disable-next-line react/no-unused-prop-types
variant?: FieldVariant;
// eslint-disable-next-line react/no-unused-prop-types
hideLabel?: boolean;
// eslint-disable-next-line react/no-unused-prop-types
minValue?: string;
// eslint-disable-next-line react/no-unused-prop-types
maxValue?: string;
@@ -48,6 +54,13 @@ export type DatePickerAuxProps = PropsWithChildren &
optionalClassName?: string;
isRange?: boolean;
onClear: () => void;
// For classic range mode: render labels above the wrapper
rangeLabels?: {
startLabel: string;
endLabel: string;
disabled?: boolean;
hideLabel?: boolean;
};
};
/**
@@ -68,17 +81,21 @@ const DatePickerAux = ({
disabled = false,
optionalClassName,
isRange,
rangeLabels,
ref,
...props
}: DatePickerAuxProps) => {
const { t, currentLocale } = useCunningham();
const pickerRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const isDateInvalid = useMemo(
() => pickerState.validationState === "invalid" || props.state === "error",
[pickerState.validationState, props.state],
);
const isClassic = props.variant === "classic";
return (
<I18nProvider locale={locale || currentLocale}>
<Field
@@ -95,13 +112,91 @@ const DatePickerAux = ({
"c__date-picker--success": props.state === "success",
"c__date-picker--focused":
!isDateInvalid && !disabled && (pickerState.isOpen || isFocused),
"c__date-picker--classic": isClassic,
})}
>
{isClassic && !isRange && (
<ClassicLabel
label={props.label}
hideLabel={props.hideLabel}
disabled={disabled}
className="c__date-picker__label"
disabledClassName="c__date-picker__label--disabled"
onClick={() => {
wrapperRef.current?.focus();
if (!pickerState.isOpen) {
pickerState.open();
}
}}
/>
)}
{/* Classic variant: range labels above the wrapper */}
{isClassic && rangeLabels && !rangeLabels.hideLabel && (
<div className="c__date-picker__range__labels">
<ClassicLabel
label={rangeLabels.startLabel}
disabled={rangeLabels.disabled}
className="c__date-picker__label"
disabledClassName="c__date-picker__label--disabled"
onClick={() => {
wrapperRef.current?.focus();
if (!pickerState.isOpen) {
pickerState.open();
}
}}
/>
<div className="c__date-picker__range__labels__spacer" />
<ClassicLabel
label={rangeLabels.endLabel}
disabled={rangeLabels.disabled}
className="c__date-picker__label"
disabledClassName="c__date-picker__label--disabled"
onClick={() => {
wrapperRef.current?.focus();
if (!pickerState.isOpen) {
pickerState.open();
}
}}
/>
</div>
)}
{/* Hidden range labels for accessibility when hideLabel is true */}
{isClassic && rangeLabels && rangeLabels.hideLabel && (
<>
<ClassicLabel
label={rangeLabels.startLabel}
hideLabel
onClick={() => {
wrapperRef.current?.focus();
if (!pickerState.isOpen) {
pickerState.open();
}
}}
/>
<ClassicLabel
label={rangeLabels.endLabel}
hideLabel
onClick={() => {
wrapperRef.current?.focus();
if (!pickerState.isOpen) {
pickerState.open();
}
}}
/>
</>
)}
<div
className={classNames("c__date-picker__wrapper", {
"c__date-picker__wrapper--clickable": labelAsPlaceholder,
})}
ref={ref}
ref={(node) => {
wrapperRef.current = node;
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
}}
{...pickerProps.groupProps}
role="button"
tabIndex={0}

View File

@@ -1212,4 +1212,116 @@ describe("<DateRangePicker/>", () => {
document.querySelector(".c__field.my-custom-class"),
).toBeInTheDocument();
});
describe("classic variant", () => {
it("renders with classic variant", async () => {
render(
<CunninghamProvider>
<DateRangePicker
label="Pick a date"
startLabel="Start date"
endLabel="End date"
variant="classic"
/>
</CunninghamProvider>,
);
// In classic mode, both labels are rendered in a row above the wrapper
expect(
document.querySelector(".c__date-picker--classic"),
).toBeInTheDocument();
expect(
document.querySelector(".c__date-picker__range__labels"),
).toBeInTheDocument();
// Should have two labels in the labels row
const labels = document.querySelectorAll(".c__date-picker__label");
expect(labels.length).toBe(2);
});
it("labels are always static in classic variant", async () => {
const user = userEvent.setup();
render(
<CunninghamProvider>
<DateRangePicker
label="Pick a date"
startLabel="Start date"
endLabel="End date"
variant="classic"
/>
</CunninghamProvider>,
);
const startLabel = screen.getByText("Start date");
const endLabel = screen.getByText("End date");
// In classic variant, labels are outside the wrapper with c__date-picker__label class
expect(startLabel.classList.contains("c__date-picker__label")).toBe(true);
expect(endLabel.classList.contains("c__date-picker__label")).toBe(true);
// Open calendar
const toggleButton = (await screen.findAllByRole("button"))![1];
await user.click(toggleButton);
// Labels should still have the same class
expect(startLabel.classList.contains("c__date-picker__label")).toBe(true);
expect(endLabel.classList.contains("c__date-picker__label")).toBe(true);
});
it("defaults to floating variant", () => {
render(
<CunninghamProvider>
<DateRangePicker
label="Pick a date"
startLabel="Start date"
endLabel="End date"
/>
</CunninghamProvider>,
);
expect(
document.querySelector(".c__date-picker--classic"),
).not.toBeInTheDocument();
});
});
describe("hideLabel", () => {
it("hides labels visually but keeps them accessible in floating variant", () => {
render(
<CunninghamProvider>
<DateRangePicker
label="Pick a date"
startLabel="Start date"
endLabel="End date"
hideLabel
/>
</CunninghamProvider>,
);
// Labels should be visually hidden via LabelledBox
const startLabel = screen.getByText("Start date");
const endLabel = screen.getByText("End date");
expect(startLabel.closest("label")).toHaveClass("c__offscreen");
expect(endLabel.closest("label")).toHaveClass("c__offscreen");
});
it("hides labels visually but keeps them accessible in classic variant", () => {
render(
<CunninghamProvider>
<DateRangePicker
label="Pick a date"
startLabel="Start date"
endLabel="End date"
variant="classic"
hideLabel
/>
</CunninghamProvider>,
);
// Labels should be visually hidden with c__offscreen class
const startLabel = screen.getByText("Start date");
const endLabel = screen.getByText("End date");
expect(startLabel).toHaveClass("c__offscreen");
expect(endLabel).toHaveClass("c__offscreen");
// The visible labels row should not be present
expect(
document.querySelector(".c__date-picker__range__labels"),
).not.toBeInTheDocument();
});
});
});

View File

@@ -60,6 +60,7 @@ export const DateRangePicker = ({
const { startFieldProps, endFieldProps, calendarProps, ...pickerProps } =
useDateRangePicker(options, pickerState, ref);
const isClassic = props.variant === "classic";
const labelAsPlaceholder = useMemo(
() =>
!isFocused &&
@@ -88,14 +89,27 @@ export const DateRangePicker = ({
});
},
calendar,
// Pass labels for classic range mode
rangeLabels: isClassic
? {
startLabel,
endLabel,
disabled: props.disabled,
hideLabel: props.hideLabel,
}
: undefined,
}}
ref={ref}
>
<DateFieldBox
{...{
...startFieldProps,
label: startLabel,
labelAsPlaceholder,
// In classic mode, label is rendered outside by DatePickerAux
label: isClassic ? undefined : startLabel,
variant: props.variant,
hideLabel: isClassic ? undefined : props.hideLabel,
// In classic mode, always show date segments (never collapse them)
labelAsPlaceholder: isClassic ? false : labelAsPlaceholder,
onFocusChange: setIsFocused,
disabled: props.disabled,
}}
@@ -104,8 +118,12 @@ export const DateRangePicker = ({
<DateFieldBox
{...{
...endFieldProps,
label: endLabel,
labelAsPlaceholder,
// In classic mode, label is rendered outside by DatePickerAux
label: isClassic ? undefined : endLabel,
variant: props.variant,
hideLabel: isClassic ? undefined : props.hideLabel,
// In classic mode, always show date segments (never collapse them)
labelAsPlaceholder: isClassic ? false : labelAsPlaceholder,
onFocusChange: setIsFocused,
disabled: props.disabled,
}}

View File

@@ -4,6 +4,33 @@
.c__date-picker {
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);
&--disabled {
color: var(--c--components--forms-labelledbox--label-color--small--disabled);
}
}
&--classic {
.c__date-picker__wrapper {
align-items: center;
height: 2.75rem;
}
.c__date-picker__inner__action {
margin-top: 0;
}
.c__date-picker__range__separator {
align-self: center;
flex-shrink: 0;
}
}
&__wrapper {
border-radius: var(--c--components--forms-datepicker--border-radius);
border-width: var(--c--components--forms-datepicker--border-width);
@@ -202,6 +229,22 @@
&__range {
$component-min-width: px-to-rem(350px);
&__labels {
display: flex;
margin-bottom: var(--c--components--forms-labelledbox--classic-label-margin-bottom);
.c__date-picker__label {
flex: 1;
margin-bottom: 0;
}
&__spacer {
// Space for the separator
width: 1rem;
flex-shrink: 0;
}
}
// MUST READ:
// We can only use @container property for full-width fields, as the container-type: inline-size property
// should not be based on the children's width. We cannot at the same time use container-type and a default

View File

@@ -13,6 +13,19 @@ import { RhfDatePicker } from ":/components/Forms/DatePicker/stories-utils";
export default {
title: "Components/Forms/DatePicker",
component: DatePicker,
argTypes: {
disabled: {
control: "boolean",
},
state: {
control: "select",
options: ["default", "success", "error"],
},
variant: {
control: "select",
options: ["floating", "classic"],
},
},
} as Meta<typeof DatePicker>;
const Template: StoryFn<typeof DatePicker> = (args) => (
@@ -250,3 +263,80 @@ export const RangeControlledFull = () => {
</>
);
};
export const ClassicVariant = () => (
<div style={{ minHeight: "400px" }}>
<DatePicker label="Pick a date" variant="classic" />
</div>
);
export const ClassicVariantWithValue = () => (
<div style={{ minHeight: "400px" }}>
<DatePicker
label="Pick a date"
variant="classic"
defaultValue="2023-05-24T00:00:00.000+00:00"
/>
</div>
);
export const RangeClassicVariant = () => (
<div style={{ minHeight: "400px" }}>
<DateRangePicker
label="Pick a date range"
startLabel="Start date"
endLabel="End date"
variant="classic"
/>
</div>
);
export const RangeClassicVariantWithValue = () => (
<div style={{ minHeight: "400px" }}>
<DateRangePicker
label="Pick a date range"
startLabel="Start date"
endLabel="End date"
variant="classic"
defaultValue={[
"2023-05-23T00:00:00.000+00:00",
"2023-06-23T00:00:00.000+00:00",
]}
/>
</div>
);
export const HiddenLabel = () => (
<div style={{ minHeight: "400px" }}>
<DatePicker label="Pick a date" hideLabel />
</div>
);
export const HiddenLabelClassic = () => (
<div style={{ minHeight: "400px" }}>
<DatePicker label="Pick a date" variant="classic" hideLabel />
</div>
);
export const RangeHiddenLabel = () => (
<div style={{ minHeight: "400px" }}>
<DateRangePicker
label="Pick a date range"
startLabel="Start date"
endLabel="End date"
hideLabel
/>
</div>
);
export const RangeHiddenLabelClassic = () => (
<div style={{ minHeight: "400px" }}>
<DateRangePicker
label="Pick a date range"
startLabel="Start date"
endLabel="End date"
variant="classic"
hideLabel
/>
</div>
);