From 2a39c8e72cd6c176d159f4ede942105359d0ca88 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Mon, 26 Jan 2026 20:08:04 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(DatePicker)=20add=20classic=20variant?= =?UTF-8?q?=20and=20hideLabel=20props?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../components/Forms/DatePicker/DateField.tsx | 12 +- .../Forms/DatePicker/DatePicker.spec.tsx | 78 ++++++++++++ .../Forms/DatePicker/DatePicker.tsx | 9 +- .../Forms/DatePicker/DatePickerAux.tsx | 97 ++++++++++++++- .../Forms/DatePicker/DateRangePicker.spec.tsx | 112 ++++++++++++++++++ .../Forms/DatePicker/DateRangePicker.tsx | 26 +++- .../components/Forms/DatePicker/_index.scss | 43 +++++++ .../Forms/DatePicker/index.stories.tsx | 90 ++++++++++++++ 8 files changed, 457 insertions(+), 10 deletions(-) diff --git a/packages/react/src/components/Forms/DatePicker/DateField.tsx b/packages/react/src/components/Forms/DatePicker/DateField.tsx index cab1016..98caf58 100644 --- a/packages/react/src/components/Forms/DatePicker/DateField.tsx +++ b/packages/react/src/components/Forms/DatePicker/DateField.tsx @@ -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) => { interface DateFieldBoxProps extends Props, - Omit, "label"> {} + Omit, "label"> { + variant?: FieldVariant; +} -const DateFieldBox = ({ ...props }: DateFieldBoxProps) => ( - +const DateFieldBox = ({ + variant = "floating", + ...props +}: DateFieldBoxProps) => ( +
", () => { document.querySelector(".c__field.my-custom-class"), ).toBeInTheDocument(); }); + + describe("classic variant", () => { + it("renders with classic variant", async () => { + render( + + + , + ); + // 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( + + + , + ); + + 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( + + + , + ); + expect( + document.querySelector(".c__date-picker--classic"), + ).not.toBeInTheDocument(); + }); + }); + + describe("hideLabel", () => { + it("hides label visually but keeps it accessible in floating variant", () => { + render( + + + , + ); + // 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( + + + , + ); + // 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(); + }); + }); }); diff --git a/packages/react/src/components/Forms/DatePicker/DatePicker.tsx b/packages/react/src/components/Forms/DatePicker/DatePicker.tsx index 75755cc..f3c6a3e 100644 --- a/packages/react/src/components/Forms/DatePicker/DatePicker.tsx +++ b/packages/react/src/components/Forms/DatePicker/DatePicker.tsx @@ -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) => { 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(null); + const wrapperRef = useRef(null); const isDateInvalid = useMemo( () => pickerState.validationState === "invalid" || props.state === "error", [pickerState.validationState, props.state], ); + const isClassic = props.variant === "classic"; + return ( + {isClassic && !isRange && ( + { + wrapperRef.current?.focus(); + if (!pickerState.isOpen) { + pickerState.open(); + } + }} + /> + )} + {/* Classic variant: range labels above the wrapper */} + {isClassic && rangeLabels && !rangeLabels.hideLabel && ( +
+ { + wrapperRef.current?.focus(); + if (!pickerState.isOpen) { + pickerState.open(); + } + }} + /> +
+ { + wrapperRef.current?.focus(); + if (!pickerState.isOpen) { + pickerState.open(); + } + }} + /> +
+ )} + {/* Hidden range labels for accessibility when hideLabel is true */} + {isClassic && rangeLabels && rangeLabels.hideLabel && ( + <> + { + wrapperRef.current?.focus(); + if (!pickerState.isOpen) { + pickerState.open(); + } + }} + /> + { + wrapperRef.current?.focus(); + if (!pickerState.isOpen) { + pickerState.open(); + } + }} + /> + + )}
{ + wrapperRef.current = node; + if (typeof ref === "function") { + ref(node); + } else if (ref) { + ref.current = node; + } + }} {...pickerProps.groupProps} role="button" tabIndex={0} diff --git a/packages/react/src/components/Forms/DatePicker/DateRangePicker.spec.tsx b/packages/react/src/components/Forms/DatePicker/DateRangePicker.spec.tsx index aba0d89..f12d5e1 100644 --- a/packages/react/src/components/Forms/DatePicker/DateRangePicker.spec.tsx +++ b/packages/react/src/components/Forms/DatePicker/DateRangePicker.spec.tsx @@ -1212,4 +1212,116 @@ describe("", () => { document.querySelector(".c__field.my-custom-class"), ).toBeInTheDocument(); }); + + describe("classic variant", () => { + it("renders with classic variant", async () => { + render( + + + , + ); + // 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( + + + , + ); + + 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( + + + , + ); + expect( + document.querySelector(".c__date-picker--classic"), + ).not.toBeInTheDocument(); + }); + }); + + describe("hideLabel", () => { + it("hides labels visually but keeps them accessible in floating variant", () => { + render( + + + , + ); + // 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( + + + , + ); + // 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(); + }); + }); }); diff --git a/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx b/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx index 6ca1e92..e5e3eae 100644 --- a/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx +++ b/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx @@ -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} > ; const Template: StoryFn = (args) => ( @@ -250,3 +263,80 @@ export const RangeControlledFull = () => { ); }; + +export const ClassicVariant = () => ( +
+ +
+); + +export const ClassicVariantWithValue = () => ( +
+ +
+); + +export const RangeClassicVariant = () => ( +
+ +
+); + +export const RangeClassicVariantWithValue = () => ( +
+ +
+); + +export const HiddenLabel = () => ( +
+ +
+); + +export const HiddenLabelClassic = () => ( +
+ +
+); + +export const RangeHiddenLabel = () => ( +
+ +
+); + +export const RangeHiddenLabelClassic = () => ( +
+ +
+);