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 <charles.dedreuille@gmail.com>
Made-with: Cursor
This commit is contained in:
Charles de Dreuille
2026-04-14 18:13:15 +01:00
parent c3ca20c864
commit 401916d55b
18 changed files with 1180 additions and 7 deletions
+5
View File
@@ -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.
@@ -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 (
<DateRangePicker
label="Date range"
description="Select a start and end date for your event."
style={{ maxWidth: '360px' }}
/>
);
};
export const Sizes = () => {
return (
<Flex
direction="column"
gap="4"
style={{ width: '100%', maxWidth: '360px' }}
>
<DateRangePicker label="Small" size="small" />
<DateRangePicker label="Medium" size="medium" />
</Flex>
);
};
export const WithDefaultValue = () => {
return (
<DateRangePicker
label="Booking period"
defaultValue={{
start: parseDate('2025-02-03'),
end: parseDate('2025-02-14'),
}}
style={{ maxWidth: '360px' }}
/>
);
};
export const Disabled = () => {
return (
<DateRangePicker
label="Date range"
isDisabled
style={{ maxWidth: '360px' }}
/>
);
};
@@ -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',
};
<PageTitle
title="DateRangePicker"
description="A date range picker that combines two date fields and a calendar popover for selecting a start and end date."
/>
<Snippet
align="center"
py={4}
preview={<WithLabel />}
code={withLabelSnippet}
/>
## Usage
<CodeBlock code={dateRangePickerUsageSnippet} />
## API reference
<PropsTable data={dateRangePickerPropDefs} />
<ReactAriaLink component="DateRangePicker" href={reactAriaUrls.dateRangePicker} />
## Examples
### Sizes
<Snippet
align="center"
py={4}
open
preview={<Sizes />}
code={sizesSnippet}
layout="side-by-side"
/>
### With default value
<Snippet
align="center"
py={4}
open
preview={<WithDefaultValue />}
code={withDefaultValueSnippet}
layout="side-by-side"
/>
### Disabled
<Snippet
align="center"
py={4}
open
preview={<Disabled />}
code={disabledSnippet}
layout="side-by-side"
/>
<Theming definition={DateRangePickerDefinition} />
<ChangelogComponent component="date-range-picker" />
@@ -0,0 +1,103 @@
import {
classNamePropDefs,
stylePropDefs,
type PropDef,
} from '@/utils/propDefs';
import { Chip } from '@/components/Chip';
export const dateRangePickerPropDefs: Record<string, PropDef> = {
size: {
type: 'enum',
values: ['small', 'medium'],
default: 'small',
responsive: true,
description: (
<>
Visual size of the picker. Use <Chip>small</Chip> for dense layouts,{' '}
<Chip>medium</Chip> 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 <Chip>Required</Chip>.
</>
),
},
description: {
type: 'string',
description: 'Help text displayed below the label.',
},
value: {
type: 'enum',
values: ['RangeValue<DateValue>'],
description: 'Controlled value of the date range.',
},
defaultValue: {
type: 'enum',
values: ['RangeValue<DateValue>'],
description: 'Default value for uncontrolled usage.',
},
onChange: {
type: 'enum',
values: ['(value: RangeValue<DateValue> | 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,
};
@@ -0,0 +1,28 @@
export const dateRangePickerUsageSnippet = `import { DateRangePicker } from '@backstage/ui';
<DateRangePicker label="Date range" />`;
export const withLabelSnippet = `<DateRangePicker
label="Date range"
description="Select a start and end date for your event."
/>`;
export const sizesSnippet = `<Flex direction="column" gap="4">
<DateRangePicker label="Small" size="small" />
<DateRangePicker label="Medium" size="medium" />
</Flex>`;
export const withDefaultValueSnippet = `import { parseDate } from '@internationalized/date';
<DateRangePicker
label="Booking period"
defaultValue={{
start: parseDate('2025-02-03'),
end: parseDate('2025-02-14'),
}}
/>`;
export const disabledSnippet = `<DateRangePicker
label="Date range"
isDisabled
/>`;
+4
View File
@@ -53,6 +53,10 @@ export const components: Page[] = [
title: 'Container',
slug: 'container',
},
{
title: 'DateRangePicker',
slug: 'date-range-picker',
},
{
title: 'Dialog',
slug: 'dialog',
+1
View File
@@ -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",
@@ -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 <td>, 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);
}
}
@@ -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 => (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
width: 360,
}}
>
<DateRangePicker {...args} size="small" label="Small" />
<DateRangePicker {...args} size="medium" label="Medium" />
</div>
),
});
export const Required = meta.story({
args: {
label: 'Trip dates',
isRequired: true,
},
render: args => (
<Form
onSubmit={e => {
e.preventDefault();
}}
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
width: 360,
}}
>
<DateRangePicker {...args} />
<Button type="submit">Submit</Button>
</Form>
),
});
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 }),
},
});
@@ -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<HTMLDivElement, DateRangePickerProps>(
(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 (
<AriaDateRangePicker
className={classes.root}
{...dataAttributes}
{...restProps}
ref={ref}
>
<FieldLabel
label={label}
secondaryLabel={secondaryLabelText}
description={description}
descriptionSlot="description"
/>
<DateRangePickerGroup dataSize={dataAttributes['data-size']} />
<FieldError />
<Popover className={clsx(popoverOwnProps.classes.root)}>
<DateRangePickerCalendar />
</Popover>
</AriaDateRangePicker>
);
},
);
DateRangePicker.displayName = 'DateRangePicker';
@@ -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 (
<RangeCalendar className={classes.root}>
<header className={classes.header}>
<Button slot="previous" className={classes.navButton}>
<RiArrowLeftSLine size={16} />
</Button>
<Heading className={classes.heading} />
<Button slot="next" className={classes.navButton}>
<RiArrowRightSLine size={16} />
</Button>
</header>
<CalendarGrid className={classes.grid}>
<CalendarGridHeader className={classes.gridHeader}>
{day => (
<CalendarHeaderCell className={classes.headerCell}>
{day}
</CalendarHeaderCell>
)}
</CalendarGridHeader>
<CalendarGridBody className={classes.gridBody}>
{date => <CalendarCell className={classes.cell} date={date} />}
</CalendarGridBody>
</CalendarGrid>
</RangeCalendar>
);
};
@@ -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 (
<Group
className={classes.root}
{...dataAttributes}
{...(dataSize ? { 'data-size': dataSize } : {})}
>
<div className={classes.dateFields}>
<DateInput slot="start" className={classes.dateInput}>
{segment => (
<DateSegment segment={segment} className={classes.segment} />
)}
</DateInput>
<span aria-hidden="true" className={classes.separator}>
</span>
<DateInput slot="end" className={classes.dateInput}>
{segment => (
<DateSegment segment={segment} className={classes.segment} />
)}
</DateInput>
</div>
<Button className={classes.button}>
<RiCalendarLine size={16} />
</Button>
</Group>
);
};
@@ -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<DateRangePickerOwnProps>()({
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<string, never>
>()({
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<string, never>
>()({
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: {},
});
@@ -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';
@@ -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<Record<Breakpoint, 'small' | 'medium'>>;
className?: string;
label?: FieldLabelProps['label'];
description?: FieldLabelProps['description'];
secondaryLabel?: FieldLabelProps['secondaryLabel'];
};
/** @public */
export interface DateRangePickerProps
extends Omit<AriaDateRangePickerProps<DateValue>, 'className' | 'children'>,
DateRangePickerOwnProps {}
+1
View File
@@ -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';
+1
View File
@@ -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';
+24 -7
View File
@@ -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"