From 401916d55bc4b8d1f59eb9e2c02a001a9df4ed8b Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Tue, 14 Apr 2026 18:13:15 +0100 Subject: [PATCH] feat(ui): add DateRangePicker component Adds a new DateRangePicker component to @backstage/ui built on React Aria, featuring a custom field group with two DateInput fields and a calendar trigger, a RangeCalendar popover with proper range selection visuals (solid circles for start/end, transparent solid fill for in-range cells with row-boundary rounding), and full BUI token usage including bg consumer auto-increment. Includes Storybook stories and a docs-ui page. Signed-off-by: Charles de Dreuille Made-with: Cursor --- .changeset/ui-date-range-picker.md | 5 + .../date-range-picker/components.tsx | 51 +++ .../app/components/date-range-picker/page.mdx | 82 ++++ .../date-range-picker/props-definition.tsx | 103 +++++ .../components/date-range-picker/snippets.ts | 28 ++ docs-ui/src/utils/data.ts | 4 + packages/ui/package.json | 1 + .../DateRangePicker.module.css | 398 ++++++++++++++++++ .../DateRangePicker.stories.tsx | 130 ++++++ .../DateRangePicker/DateRangePicker.tsx | 86 ++++ .../DateRangePickerCalendar.tsx | 66 +++ .../DateRangePicker/DateRangePickerGroup.tsx | 61 +++ .../components/DateRangePicker/definition.ts | 80 ++++ .../src/components/DateRangePicker/index.ts | 19 + .../src/components/DateRangePicker/types.ts | 40 ++ packages/ui/src/definitions.ts | 1 + packages/ui/src/index.ts | 1 + yarn.lock | 31 +- 18 files changed, 1180 insertions(+), 7 deletions(-) create mode 100644 .changeset/ui-date-range-picker.md create mode 100644 docs-ui/src/app/components/date-range-picker/components.tsx create mode 100644 docs-ui/src/app/components/date-range-picker/page.mdx create mode 100644 docs-ui/src/app/components/date-range-picker/props-definition.tsx create mode 100644 docs-ui/src/app/components/date-range-picker/snippets.ts create mode 100644 packages/ui/src/components/DateRangePicker/DateRangePicker.module.css create mode 100644 packages/ui/src/components/DateRangePicker/DateRangePicker.stories.tsx create mode 100644 packages/ui/src/components/DateRangePicker/DateRangePicker.tsx create mode 100644 packages/ui/src/components/DateRangePicker/DateRangePickerCalendar.tsx create mode 100644 packages/ui/src/components/DateRangePicker/DateRangePickerGroup.tsx create mode 100644 packages/ui/src/components/DateRangePicker/definition.ts create mode 100644 packages/ui/src/components/DateRangePicker/index.ts create mode 100644 packages/ui/src/components/DateRangePicker/types.ts diff --git a/.changeset/ui-date-range-picker.md b/.changeset/ui-date-range-picker.md new file mode 100644 index 0000000000..e69da8d7f9 --- /dev/null +++ b/.changeset/ui-date-range-picker.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Added new `DateRangePicker` component — combines two date fields and a calendar popover for selecting a date range, built on React Aria with full keyboard and screen reader accessibility. Uses BUI design tokens throughout, including auto-incremented backgrounds via the bg consumer pattern. diff --git a/docs-ui/src/app/components/date-range-picker/components.tsx b/docs-ui/src/app/components/date-range-picker/components.tsx new file mode 100644 index 0000000000..3ae30a16ad --- /dev/null +++ b/docs-ui/src/app/components/date-range-picker/components.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { DateRangePicker } from '../../../../../packages/ui/src/components/DateRangePicker/DateRangePicker'; +import { Flex } from '../../../../../packages/ui/src/components/Flex/Flex'; +import { parseDate } from '@internationalized/date'; + +export const WithLabel = () => { + return ( + + ); +}; + +export const Sizes = () => { + return ( + + + + + ); +}; + +export const WithDefaultValue = () => { + return ( + + ); +}; + +export const Disabled = () => { + return ( + + ); +}; diff --git a/docs-ui/src/app/components/date-range-picker/page.mdx b/docs-ui/src/app/components/date-range-picker/page.mdx new file mode 100644 index 0000000000..53ff5690fb --- /dev/null +++ b/docs-ui/src/app/components/date-range-picker/page.mdx @@ -0,0 +1,82 @@ +import { PropsTable } from '@/components/PropsTable'; +import { Snippet } from '@/components/Snippet'; +import { dateRangePickerPropDefs } from './props-definition'; +import { + dateRangePickerUsageSnippet, + withLabelSnippet, + sizesSnippet, + withDefaultValueSnippet, + disabledSnippet, +} from './snippets'; +import { WithLabel, Sizes, WithDefaultValue, Disabled } from './components'; +import { PageTitle } from '@/components/PageTitle'; +import { Theming } from '@/components/Theming'; +import { DateRangePickerDefinition } from '../../../utils/definitions'; +import { ChangelogComponent } from '@/components/ChangelogComponent'; +import { CodeBlock } from '@/components/CodeBlock'; +import { ReactAriaLink } from '@/components/ReactAriaLink'; + +export const reactAriaUrls = { + dateRangePicker: 'https://react-spectrum.adobe.com/react-aria/DateRangePicker.html', +}; + + + +} + code={withLabelSnippet} +/> + +## Usage + + + +## API reference + + + + + +## Examples + +### Sizes + +} + code={sizesSnippet} + layout="side-by-side" +/> + +### With default value + +} + code={withDefaultValueSnippet} + layout="side-by-side" +/> + +### Disabled + +} + code={disabledSnippet} + layout="side-by-side" +/> + + + + diff --git a/docs-ui/src/app/components/date-range-picker/props-definition.tsx b/docs-ui/src/app/components/date-range-picker/props-definition.tsx new file mode 100644 index 0000000000..b7200f868e --- /dev/null +++ b/docs-ui/src/app/components/date-range-picker/props-definition.tsx @@ -0,0 +1,103 @@ +import { + classNamePropDefs, + stylePropDefs, + type PropDef, +} from '@/utils/propDefs'; +import { Chip } from '@/components/Chip'; + +export const dateRangePickerPropDefs: Record = { + size: { + type: 'enum', + values: ['small', 'medium'], + default: 'small', + responsive: true, + description: ( + <> + Visual size of the picker. Use small for dense layouts,{' '} + medium for prominent fields. + + ), + }, + label: { + type: 'string', + description: 'Visible label displayed above the picker.', + }, + secondaryLabel: { + type: 'string', + description: ( + <> + Secondary text shown next to the label. If not provided and isRequired + is true, displays Required. + + ), + }, + description: { + type: 'string', + description: 'Help text displayed below the label.', + }, + value: { + type: 'enum', + values: ['RangeValue'], + description: 'Controlled value of the date range.', + }, + defaultValue: { + type: 'enum', + values: ['RangeValue'], + description: 'Default value for uncontrolled usage.', + }, + onChange: { + type: 'enum', + values: ['(value: RangeValue | null) => void'], + description: 'Handler called when the selected range changes.', + }, + granularity: { + type: 'enum', + values: ['day', 'hour', 'minute', 'second'], + default: 'day', + description: + 'Smallest unit displayed. Defaults to "day" for dates and "minute" for times.', + }, + minValue: { + type: 'enum', + values: ['DateValue'], + description: 'Minimum allowed date. Dates before this are disabled.', + }, + maxValue: { + type: 'enum', + values: ['DateValue'], + description: 'Maximum allowed date. Dates after this are disabled.', + }, + isDateUnavailable: { + type: 'enum', + values: ['(date: DateValue) => boolean'], + description: + 'Callback invoked for each calendar date. Return true to mark a date as unavailable.', + }, + allowsNonContiguousRanges: { + type: 'boolean', + description: + 'When combined with isDateUnavailable, allows selecting ranges that contain unavailable dates.', + }, + startName: { + type: 'string', + description: 'Form field name for the start date, submitted as ISO 8601.', + }, + endName: { + type: 'string', + description: 'Form field name for the end date, submitted as ISO 8601.', + }, + isRequired: { + type: 'boolean', + description: 'Whether the field is required for form submission.', + }, + isDisabled: { + type: 'boolean', + description: 'Whether the picker is disabled.', + }, + isReadOnly: { + type: 'boolean', + description: 'Whether the picker is read-only.', + }, + ...classNamePropDefs, + ...stylePropDefs, +}; diff --git a/docs-ui/src/app/components/date-range-picker/snippets.ts b/docs-ui/src/app/components/date-range-picker/snippets.ts new file mode 100644 index 0000000000..3faab626ed --- /dev/null +++ b/docs-ui/src/app/components/date-range-picker/snippets.ts @@ -0,0 +1,28 @@ +export const dateRangePickerUsageSnippet = `import { DateRangePicker } from '@backstage/ui'; + +`; + +export const withLabelSnippet = ``; + +export const sizesSnippet = ` + + +`; + +export const withDefaultValueSnippet = `import { parseDate } from '@internationalized/date'; + +`; + +export const disabledSnippet = ``; diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts index 2a16065930..55f10eba95 100644 --- a/docs-ui/src/utils/data.ts +++ b/docs-ui/src/utils/data.ts @@ -53,6 +53,10 @@ export const components: Page[] = [ title: 'Container', slug: 'container', }, + { + title: 'DateRangePicker', + slug: 'date-range-picker', + }, { title: 'Dialog', slug: 'dialog', diff --git a/packages/ui/package.json b/packages/ui/package.json index 1b17bc8744..dce45e578b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@backstage/version-bridge": "workspace:^", + "@internationalized/date": "^3.12.0", "@remixicon/react": "^4.6.0", "@tanstack/react-table": "^8.21.3", "clsx": "^2.1.1", diff --git a/packages/ui/src/components/DateRangePicker/DateRangePicker.module.css b/packages/ui/src/components/DateRangePicker/DateRangePicker.module.css new file mode 100644 index 0000000000..71f7b6d61e --- /dev/null +++ b/packages/ui/src/components/DateRangePicker/DateRangePicker.module.css @@ -0,0 +1,398 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@layer tokens, base, components, utilities; + +@layer components { + /* ============================================================ + Root + ============================================================ */ + + .bui-DateRangePicker { + display: flex; + flex-direction: column; + font-family: var(--bui-font-regular); + width: 100%; + flex-shrink: 0; + } + + /* ============================================================ + Field group — custom container (not reusing any BUI field) + ============================================================ */ + + .bui-DateRangePickerGroup { + display: flex; + align-items: center; + background-color: var(--bui-bg-neutral-1); + border-radius: var(--bui-radius-2); + padding: 0 var(--bui-space-1) 0 var(--bui-space-3); + width: fit-content; + min-width: 280px; + max-width: 100%; + overflow: clip; + transition: box-shadow 0.2s ease-in-out; + cursor: text; + + /* bg consumer — auto-increment background based on parent context */ + &[data-on-bg='neutral-1'] { + background-color: var(--bui-bg-neutral-2); + } + + &[data-on-bg='neutral-2'] { + background-color: var(--bui-bg-neutral-3); + } + + &[data-on-bg='neutral-3'] { + background-color: var(--bui-bg-neutral-4); + } + + &[data-focus-within] { + outline: none; + box-shadow: inset 0 0 0 1px var(--bui-ring); + } + + &[data-invalid] { + box-shadow: inset 0 0 0 1px var(--bui-border-danger); + } + + &[data-disabled] { + opacity: 0.5; + cursor: not-allowed; + } + + /* Sizes */ + &[data-size='small'] { + height: 2rem; + padding-inline-start: var(--bui-space-2); + } + + &[data-size='medium'] { + height: 2.5rem; + min-width: 290px; + padding-inline-end: var(--bui-space-2); + } + } + + /* ============================================================ + Date fields wrapper — scrollable row of inputs + separator + ============================================================ */ + + .bui-DateRangePickerDateFields { + flex: 1; + display: flex; + align-items: center; + width: fit-content; + overflow-x: auto; + overflow-y: clip; + scrollbar-width: none; + } + + /* ============================================================ + Date inputs + ============================================================ */ + + .bui-DateRangePickerDateInput { + display: inline-flex; + align-items: center; + width: unset; + min-width: unset; + padding: unset; + border: unset; + box-shadow: none; + background: none; + height: auto; + + &[slot='end'] { + flex: 1; + } + } + + /* ============================================================ + Date segments (month, day, year literals and placeholders) + ============================================================ */ + + .bui-DateRangePickerSegment { + display: inline-block; + padding: var(--bui-space-0_5) var(--bui-space-1); + border-radius: var(--bui-radius-1); + font-size: var(--bui-font-size-3); + font-family: var(--bui-font-regular); + font-weight: var(--bui-font-weight-regular); + color: var(--bui-fg-primary); + caret-color: transparent; + outline: none; + tabular-nums: unset; + font-variant-numeric: tabular-nums; + + &[data-placeholder] { + color: var(--bui-fg-secondary); + } + + &[data-type='literal'] { + color: var(--bui-fg-secondary); + padding: 0; + } + + &[data-focused] { + background-color: var(--bui-bg-solid); + color: var(--bui-fg-solid); + border-radius: var(--bui-radius-1); + + &[data-placeholder] { + color: var(--bui-fg-solid); + } + } + + &[data-disabled] { + color: var(--bui-fg-disabled); + } + } + + /* ============================================================ + Separator between start and end + ============================================================ */ + + .bui-DateRangePickerSeparator { + color: var(--bui-fg-secondary); + font-size: var(--bui-font-size-3); + flex-shrink: 0; + user-select: none; + padding: 0 var(--bui-space-1); + } + + /* ============================================================ + Calendar trigger button + ============================================================ */ + + .bui-DateRangePickerButton { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background: none; + border: none; + padding: var(--bui-space-1); + border-radius: var(--bui-radius-1); + color: var(--bui-fg-secondary); + cursor: pointer; + margin-left: auto; + transition: color 0.15s ease-in-out; + + &[data-hovered] { + color: var(--bui-fg-primary); + } + + &[data-focus-visible] { + outline: 2px solid var(--bui-ring); + outline-offset: 1px; + } + + &[data-pressed] { + color: var(--bui-fg-primary); + } + } + + /* ============================================================ + Calendar (inside Popover) + ============================================================ */ + + .bui-DateRangePickerCalendar { + padding: var(--bui-space-3); + width: fit-content; + outline: none; + } + + .bui-DateRangePickerCalendarHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--bui-space-3); + } + + .bui-DateRangePickerCalendarHeading { + font-size: var(--bui-font-size-3); + font-weight: var(--bui-font-weight-bold); + font-family: var(--bui-font-regular); + color: var(--bui-fg-primary); + flex: 1; + text-align: center; + } + + .bui-DateRangePickerCalendarNavButton { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: var(--bui-space-8); + height: var(--bui-space-8); + background: none; + border: none; + border-radius: var(--bui-radius-2); + color: var(--bui-fg-secondary); + cursor: pointer; + transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out; + + &[data-hovered] { + background-color: var(--bui-bg-neutral-2); + color: var(--bui-fg-primary); + } + + &[data-focus-visible] { + outline: 2px solid var(--bui-ring); + outline-offset: 1px; + } + + &[data-pressed] { + background-color: var(--bui-bg-neutral-2); + } + } + + /* ============================================================ + Calendar grid + ============================================================ */ + + .bui-DateRangePickerCalendarGrid { + width: 100%; + border-collapse: separate; + border-spacing: 0 2px; + } + + .bui-DateRangePickerCalendarHeaderCell { + font-size: var(--bui-font-size-2); + font-weight: var(--bui-font-weight-regular); + color: var(--bui-fg-secondary); + text-align: center; + padding-bottom: var(--bui-space-2); + width: 40px; + } + + /* ============================================================ + Calendar cells + + Technique: isolation: isolate creates a stacking context so + that ::before (range fill, z-index: -2) and ::after (circle, + z-index: -1) paint behind the cell's text content while still + rendering on top of the page background. + ============================================================ */ + + .bui-DateRangePickerCalendarCell { + isolation: isolate; + position: relative; + width: 40px; + height: 40px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + outline: none; + cursor: default; + font-size: var(--bui-font-size-3); + font-family: var(--bui-font-regular); + color: var(--bui-fg-primary); + + &[data-outside-month] { + color: var(--bui-fg-disabled); + } + + &[data-disabled] { + color: var(--bui-fg-disabled); + cursor: not-allowed; + } + + &[data-unavailable] { + color: var(--bui-fg-disabled); + cursor: not-allowed; + text-decoration: line-through; + } + + &[data-hovered]:not([data-disabled]):not([data-unavailable]):not([data-selected]) { + background-color: var(--bui-bg-neutral-2); + border-radius: var(--bui-radius-full); + } + + &[data-focus-visible] { + outline: 2px solid var(--bui-ring); + outline-offset: -2px; + border-radius: var(--bui-radius-full); + } + + /* Today marker */ + &[data-today]:not([data-selected]):not([data-selection-start]):not([data-selection-end]) { + font-weight: var(--bui-font-weight-bold); + } + + /* ── Range fill via ::before ─────────────────────────────── */ + + &[data-selected]::before { + content: ''; + position: absolute; + inset: 0; + background: color-mix(in srgb, var(--bui-bg-solid) 15%, transparent); + z-index: -2; + } + + /* Start cell: fill only the right half to connect forward */ + &[data-selection-start]::before { + left: 50%; + } + + /* End cell: fill only the left half to connect backward */ + &[data-selection-end]::before { + right: 50%; + } + + /* Same-day selection: no range fill needed */ + &[data-selection-start][data-selection-end]::before { + display: none; + } + + /* ── Solid circle for start / end via ::after ────────────── */ + + &[data-selection-start]::after, + &[data-selection-end]::after { + content: ''; + position: absolute; + inset: 0; + background: var(--bui-bg-solid); + border-radius: var(--bui-radius-full); + z-index: -1; + } + + /* Text color on top of the solid circle */ + &[data-selection-start], + &[data-selection-end] { + color: var(--bui-fg-solid); + } + } + + /* Round the left edge of ::before when the cell is the first in its row + (our class is on an inner element inside , hence td:first-child > *) */ + .bui-DateRangePickerCalendarCell[data-selected]:not([data-selection-start]):is( + td:first-child > *, + [aria-disabled] + td > * + )::before { + border-start-start-radius: var(--bui-radius-full); + border-end-start-radius: var(--bui-radius-full); + } + + /* Round the right edge of ::before when the cell is the last in its row */ + .bui-DateRangePickerCalendarCell[data-selected]:not([data-selection-end]):is( + td:last-child > * + )::before { + border-start-end-radius: var(--bui-radius-full); + border-end-end-radius: var(--bui-radius-full); + } +} diff --git a/packages/ui/src/components/DateRangePicker/DateRangePicker.stories.tsx b/packages/ui/src/components/DateRangePicker/DateRangePicker.stories.tsx new file mode 100644 index 0000000000..3a596c2b91 --- /dev/null +++ b/packages/ui/src/components/DateRangePicker/DateRangePicker.stories.tsx @@ -0,0 +1,130 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import preview from '../../../../../.storybook/preview'; +import { DateRangePicker } from './DateRangePicker'; +import { parseDate, today, getLocalTimeZone } from '@internationalized/date'; +import { Form } from 'react-aria-components'; +import { Button } from '../Button'; + +const meta = preview.meta({ + title: 'Backstage UI/DateRangePicker', + component: DateRangePicker, + args: { + style: { width: 360 }, + }, +}); + +export const Default = meta.story({ + args: {}, +}); + +export const WithLabel = meta.story({ + args: { + label: 'Date range', + }, +}); + +export const WithDescription = meta.story({ + args: { + label: 'Date range', + description: 'Select a start and end date for your event.', + }, +}); + +export const WithDefaultValue = meta.story({ + args: { + label: 'Booking period', + defaultValue: { + start: parseDate('2025-02-03'), + end: parseDate('2025-02-14'), + }, + }, +}); + +export const Sizes = meta.story({ + args: { + label: 'Date range', + }, + render: args => ( +
+ + +
+ ), +}); + +export const Required = meta.story({ + args: { + label: 'Trip dates', + isRequired: true, + }, + render: args => ( +
{ + e.preventDefault(); + }} + style={{ + display: 'flex', + flexDirection: 'column', + gap: '1rem', + width: 360, + }} + > + + + + ), +}); + +export const Disabled = meta.story({ + args: { + label: 'Date range', + isDisabled: true, + defaultValue: { + start: parseDate('2025-03-01'), + end: parseDate('2025-03-15'), + }, + }, +}); + +export const Invalid = meta.story({ + args: { + label: 'Date range', + isInvalid: true, + errorMessage: 'The selected range is not available.', + defaultValue: { + start: parseDate('2025-04-01'), + end: parseDate('2025-04-10'), + }, + }, +}); + +export const WithMinMaxValue = meta.story({ + args: { + label: 'Date range', + description: 'You can only select dates within the next 30 days.', + minValue: today(getLocalTimeZone()), + maxValue: today(getLocalTimeZone()).add({ days: 30 }), + }, +}); diff --git a/packages/ui/src/components/DateRangePicker/DateRangePicker.tsx b/packages/ui/src/components/DateRangePicker/DateRangePicker.tsx new file mode 100644 index 0000000000..0daec0fb56 --- /dev/null +++ b/packages/ui/src/components/DateRangePicker/DateRangePicker.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { forwardRef, useEffect } from 'react'; +import { + DateRangePicker as AriaDateRangePicker, + Popover, +} from 'react-aria-components'; +import clsx from 'clsx'; +import { FieldLabel } from '../FieldLabel'; +import { FieldError } from '../FieldError'; +import { DateRangePickerGroup } from './DateRangePickerGroup'; +import { DateRangePickerCalendar } from './DateRangePickerCalendar'; +import { useDefinition } from '../../hooks/useDefinition'; +import { DateRangePickerDefinition } from './definition'; +import { PopoverDefinition } from '../Popover/definition'; +import type { DateRangePickerProps } from './types'; + +/** + * A date range picker that combines two date fields and a calendar popover, + * allowing users to enter or select a date range with full keyboard and + * screen reader accessibility. + * + * @public + */ +export const DateRangePicker = forwardRef( + (props, ref) => { + const { ownProps, restProps, dataAttributes } = useDefinition( + DateRangePickerDefinition, + props, + ); + const { ownProps: popoverOwnProps } = useDefinition(PopoverDefinition, {}); + + const { classes, label, description, secondaryLabel } = ownProps; + + const ariaLabel = restProps['aria-label']; + const ariaLabelledBy = restProps['aria-labelledby']; + + useEffect(() => { + if (!label && !ariaLabel && !ariaLabelledBy) { + console.warn( + 'DateRangePicker requires either a visible label, aria-label, or aria-labelledby for accessibility', + ); + } + }, [label, ariaLabel, ariaLabelledBy]); + + const secondaryLabelText = + secondaryLabel || (restProps.isRequired ? 'Required' : null); + + return ( + + + + + + + + + ); + }, +); + +DateRangePicker.displayName = 'DateRangePicker'; diff --git a/packages/ui/src/components/DateRangePicker/DateRangePickerCalendar.tsx b/packages/ui/src/components/DateRangePicker/DateRangePickerCalendar.tsx new file mode 100644 index 0000000000..dbfdc4d1d3 --- /dev/null +++ b/packages/ui/src/components/DateRangePicker/DateRangePickerCalendar.tsx @@ -0,0 +1,66 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + RangeCalendar, + CalendarGrid, + CalendarGridHeader, + CalendarHeaderCell, + CalendarGridBody, + CalendarCell, + Heading, + Button, +} from 'react-aria-components'; +import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'; +import { useDefinition } from '../../hooks/useDefinition'; +import { DateRangePickerCalendarDefinition } from './definition'; + +/** + * Calendar popover content for DateRangePicker — renders the RangeCalendar + * with navigation and a full calendar grid. + * + * @internal + */ +export const DateRangePickerCalendar = () => { + const { ownProps } = useDefinition(DateRangePickerCalendarDefinition, {}); + const { classes } = ownProps; + + return ( + +
+ + + +
+ + + {day => ( + + {day} + + )} + + + {date => } + + +
+ ); +}; diff --git a/packages/ui/src/components/DateRangePicker/DateRangePickerGroup.tsx b/packages/ui/src/components/DateRangePicker/DateRangePickerGroup.tsx new file mode 100644 index 0000000000..a5039563b1 --- /dev/null +++ b/packages/ui/src/components/DateRangePicker/DateRangePickerGroup.tsx @@ -0,0 +1,61 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Group, DateInput, DateSegment, Button } from 'react-aria-components'; +import { RiCalendarLine } from '@remixicon/react'; +import { useDefinition } from '../../hooks/useDefinition'; +import { DateRangePickerGroupDefinition } from './definition'; + +/** + * Custom field group for DateRangePicker — renders two DateInput fields, + * a separator, and a calendar trigger button. + * + * @internal + */ +export const DateRangePickerGroup = ({ dataSize }: { dataSize?: string }) => { + const { ownProps, dataAttributes } = useDefinition( + DateRangePickerGroupDefinition, + {}, + ); + const { classes } = ownProps; + + return ( + +
+ + {segment => ( + + )} + + + + {segment => ( + + )} + +
+ +
+ ); +}; diff --git a/packages/ui/src/components/DateRangePicker/definition.ts b/packages/ui/src/components/DateRangePicker/definition.ts new file mode 100644 index 0000000000..f6c72aedf8 --- /dev/null +++ b/packages/ui/src/components/DateRangePicker/definition.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { defineComponent } from '../../hooks/useDefinition'; +import type { DateRangePickerOwnProps } from './types'; +import styles from './DateRangePicker.module.css'; + +/** + * Component definition for DateRangePicker + * @public + */ +export const DateRangePickerDefinition = + defineComponent()({ + styles, + classNames: { + root: 'bui-DateRangePicker', + }, + propDefs: { + size: { dataAttribute: true, default: 'small' }, + className: {}, + label: {}, + description: {}, + secondaryLabel: {}, + }, + }); + +/** + * Component definition for DateRangePickerGroup + * @internal + */ +export const DateRangePickerGroupDefinition = defineComponent< + Record +>()({ + styles, + classNames: { + root: 'bui-DateRangePickerGroup', + dateFields: 'bui-DateRangePickerDateFields', + dateInput: 'bui-DateRangePickerDateInput', + segment: 'bui-DateRangePickerSegment', + separator: 'bui-DateRangePickerSeparator', + button: 'bui-DateRangePickerButton', + }, + bg: 'consumer', + propDefs: {}, +}); + +/** + * Component definition for DateRangePickerCalendar + * @internal + */ +export const DateRangePickerCalendarDefinition = defineComponent< + Record +>()({ + styles, + classNames: { + root: 'bui-DateRangePickerCalendar', + header: 'bui-DateRangePickerCalendarHeader', + heading: 'bui-DateRangePickerCalendarHeading', + navButton: 'bui-DateRangePickerCalendarNavButton', + grid: 'bui-DateRangePickerCalendarGrid', + gridHeader: 'bui-DateRangePickerCalendarGridHeader', + headerCell: 'bui-DateRangePickerCalendarHeaderCell', + gridBody: 'bui-DateRangePickerCalendarGridBody', + cell: 'bui-DateRangePickerCalendarCell', + }, + propDefs: {}, +}); diff --git a/packages/ui/src/components/DateRangePicker/index.ts b/packages/ui/src/components/DateRangePicker/index.ts new file mode 100644 index 0000000000..89364291c8 --- /dev/null +++ b/packages/ui/src/components/DateRangePicker/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './DateRangePicker'; +export * from './types'; +export { DateRangePickerDefinition } from './definition'; diff --git a/packages/ui/src/components/DateRangePicker/types.ts b/packages/ui/src/components/DateRangePicker/types.ts new file mode 100644 index 0000000000..2cc3c53214 --- /dev/null +++ b/packages/ui/src/components/DateRangePicker/types.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { DateRangePickerProps as AriaDateRangePickerProps } from 'react-aria-components'; +import type { DateValue } from '@internationalized/date'; +import type { Breakpoint } from '../../types'; +import type { FieldLabelProps } from '../FieldLabel/types'; + +/** @public */ +export type DateRangePickerOwnProps = { + /** + * The size of the date range picker + * @defaultValue 'small' + */ + size?: 'small' | 'medium' | Partial>; + + className?: string; + + label?: FieldLabelProps['label']; + description?: FieldLabelProps['description']; + secondaryLabel?: FieldLabelProps['secondaryLabel']; +}; + +/** @public */ +export interface DateRangePickerProps + extends Omit, 'className' | 'children'>, + DateRangePickerOwnProps {} diff --git a/packages/ui/src/definitions.ts b/packages/ui/src/definitions.ts index 1d16c67f2a..85599a3b23 100644 --- a/packages/ui/src/definitions.ts +++ b/packages/ui/src/definitions.ts @@ -36,6 +36,7 @@ export { CardDefinition } from './components/Card/definition'; export { CheckboxDefinition } from './components/Checkbox/definition'; export { CheckboxGroupDefinition } from './components/CheckboxGroup/definition'; export { ContainerDefinition } from './components/Container/definition'; +export { DateRangePickerDefinition } from './components/DateRangePicker/definition'; export { DialogDefinition } from './components/Dialog/definition'; export { FieldErrorDefinition } from './components/FieldError/definition'; export { FieldLabelDefinition } from './components/FieldLabel/definition'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 708e816349..a1584c1718 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -34,6 +34,7 @@ export * from './components/Avatar'; export * from './components/Badge'; export * from './components/Button'; export * from './components/Card'; +export * from './components/DateRangePicker'; export * from './components/Dialog'; export * from './components/FieldLabel'; export * from './components/PluginHeader'; diff --git a/yarn.lock b/yarn.lock index 6802845bac..e946bd26cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7973,6 +7973,7 @@ __metadata: dependencies: "@backstage/cli": "workspace:^" "@backstage/version-bridge": "workspace:^" + "@internationalized/date": "npm:^3.12.0" "@remixicon/react": "npm:^4.6.0" "@storybook/react-vite": "npm:^10.3.3" "@tanstack/react-table": "npm:^8.21.3" @@ -10127,6 +10128,15 @@ __metadata: languageName: unknown linkType: soft +"@internationalized/date@npm:^3.12.0": + version: 3.12.0 + resolution: "@internationalized/date@npm:3.12.0" + dependencies: + "@swc/helpers": "npm:^0.5.0" + checksum: 10/0bc2e95385e156362a07a8788e93b4b380b43170cca75c19a9a72cb1840c1c701bb0d1fa67d9ba5e2fbc1e44b5bea29bca02b325d0daa2f960b92a71898ede92 + languageName: node + linkType: hard + "@internationalized/date@npm:^3.12.1": version: 3.12.1 resolution: "@internationalized/date@npm:3.12.1" @@ -11885,8 +11895,8 @@ __metadata: linkType: hard "@mswjs/interceptors@npm:^0.39.1": - version: 0.39.8 - resolution: "@mswjs/interceptors@npm:0.39.8" + version: 0.39.6 + resolution: "@mswjs/interceptors@npm:0.39.6" dependencies: "@open-draft/deferred-promise": "npm:^2.2.0" "@open-draft/logger": "npm:^0.3.0" @@ -11894,7 +11904,7 @@ __metadata: is-node-process: "npm:^1.2.0" outvariant: "npm:^1.4.3" strict-event-emitter: "npm:^0.5.1" - checksum: 10/d92546cf9bf670ddb927c53f5fa19f0554b7475a264ead4e1ae2339874f4312fe4ada5d42588f27eea3577bee29fa8f46889d398f0e7ecb3f7a4c1d3e0b71bdc + checksum: 10/c87d3edf08353bde825c87b151b24d538070540ab419206cef1774c932e888af0f920183182fb7c94c3eee42068da5a0a5855853fded8514f33c870921ef37ec languageName: node linkType: hard @@ -27474,14 +27484,14 @@ __metadata: linkType: hard "dompurify@npm:^3.1.7, dompurify@npm:^3.3.2": - version: 3.4.0 - resolution: "dompurify@npm:3.4.0" + version: 3.3.3 + resolution: "dompurify@npm:3.3.3" dependencies: "@types/trusted-types": "npm:^2.0.7" dependenciesMeta: "@types/trusted-types": optional: true - checksum: 10/ead40b78ec51cd451f2c74fada4233ee0afeafdbab54af2f4a4bd5d4d138ac04d0d85140e79f533803ecfd1c3758edc1176087039c1e7217824f9794a9d34d2c + checksum: 10/4cc9c539ed7136d46c6577613b8e20871c2b6165db01dfbd2a3c11c75f9e339c496ac6519a1c3190115def8cadae3720bef0417fc43fa28802c7407bab174da9 languageName: node linkType: hard @@ -47215,7 +47225,7 @@ __metadata: languageName: node linkType: hard -"undici@npm:7.24.7, undici@npm:^7.24.3, undici@npm:^7.24.5": +"undici@npm:7.24.7": version: 7.24.7 resolution: "undici@npm:7.24.7" checksum: 10/bce7b75fe2656bbd1f9c9d5d1b6b89670773281343be25d0b1f4d808dcce97d81509987d1f3183d37a63d3a57f5f217ed8ed15ee3e103384c54e190f4e360c48 @@ -47231,6 +47241,13 @@ __metadata: languageName: node linkType: hard +"undici@npm:^7.24.3, undici@npm:^7.24.5": + version: 7.24.6 + resolution: "undici@npm:7.24.6" + checksum: 10/9c8df674284b1e9b8c3fee543883ad71c20d5fb3b6f6595330342f3dad30de944c2d55ff15aa59e7a088af272ce73be6e93baad9ab817b1b82e5c83298c23d8e + languageName: node + linkType: hard + "uni-global@npm:^1.0.0": version: 1.0.0 resolution: "uni-global@npm:1.0.0"