feat(ui): add DatePicker component (#34184)
* feat(ui): add DatePicker component Add a single-date picker built on React Aria's DatePicker, mirroring the existing DateRangePicker implementation. Includes the date field with segmented input, calendar popover, BUI design tokens, bg consumer pattern, and full keyboard/screen reader accessibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Erik Hughes <erikh@spotify.com> * fix(ui): address DatePicker PR feedback - Remove unused dataAttributes spread from DatePickerGroup - Mark DatePickerGroupDefinition and DatePickerCalendarDefinition as public so CSS class name changes appear in API reports - Add Affected components line to changeset Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Erik Hughes <erikh@spotify.com> * fix(ui): restore dataAttributes spread in DatePickerGroup The bg: 'consumer' config on DatePickerGroupDefinition emits data-on-bg attributes via useDefinition. Without spreading dataAttributes onto <Group>, the CSS [data-on-bg] selectors never match and background auto-increment doesn't work. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Erik Hughes <erikh@spotify.com> --------- Signed-off-by: Erik Hughes <erikh@spotify.com> Co-authored-by: Erik Hughes <erikh@spotify.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/ui': patch
|
||||
---
|
||||
|
||||
Added new `DatePicker` component — combines a date field and a calendar popover for selecting a date, 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.
|
||||
|
||||
**Affected components:** DatePicker
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { DatePicker } from '../../../../../packages/ui/src/components/DatePicker/DatePicker';
|
||||
import { Flex } from '../../../../../packages/ui/src/components/Flex/Flex';
|
||||
import { parseDate } from '@internationalized/date';
|
||||
|
||||
export const WithLabel = () => {
|
||||
return <DatePicker label="Date" style={{ maxWidth: '220px' }} />;
|
||||
};
|
||||
|
||||
export const Sizes = () => {
|
||||
return (
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="4"
|
||||
style={{ width: '100%', maxWidth: '280px' }}
|
||||
>
|
||||
<DatePicker label="Small" size="small" />
|
||||
<DatePicker label="Medium" size="medium" />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithDefaultValue = () => {
|
||||
return (
|
||||
<DatePicker
|
||||
label="Booking date"
|
||||
defaultValue={parseDate('2025-02-03')}
|
||||
style={{ maxWidth: '280px' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Disabled = () => {
|
||||
return <DatePicker label="Date" isDisabled style={{ maxWidth: '280px' }} />;
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { PropsTable } from '@/components/PropsTable';
|
||||
import { Snippet } from '@/components/Snippet';
|
||||
import { datePickerPropDefs } from './props-definition';
|
||||
import {
|
||||
datePickerUsageSnippet,
|
||||
withLabelSnippet,
|
||||
sizesSnippet,
|
||||
withDefaultValueSnippet,
|
||||
disabledSnippet,
|
||||
} from './snippets';
|
||||
import { WithLabel, Sizes, WithDefaultValue, Disabled } from './components';
|
||||
import { PageTitle } from '@/components/PageTitle';
|
||||
import { Theming } from '@/components/Theming';
|
||||
import { DatePickerDefinition } from '../../../utils/definitions';
|
||||
import { ChangelogComponent } from '@/components/ChangelogComponent';
|
||||
import { CodeBlock } from '@/components/CodeBlock';
|
||||
import { ReactAriaLink } from '@/components/ReactAriaLink';
|
||||
|
||||
export const reactAriaUrls = {
|
||||
datePicker: 'https://react-aria.adobe.com/DatePicker',
|
||||
};
|
||||
|
||||
<PageTitle
|
||||
title="DatePicker"
|
||||
description="A date picker that combines a date field and a calendar popover for selecting a date."
|
||||
/>
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
preview={<WithLabel />}
|
||||
code={withLabelSnippet}
|
||||
/>
|
||||
|
||||
## Usage
|
||||
|
||||
<CodeBlock code={datePickerUsageSnippet} />
|
||||
|
||||
## API reference
|
||||
|
||||
<PropsTable data={datePickerPropDefs} />
|
||||
|
||||
<ReactAriaLink component="DatePicker" href={reactAriaUrls.datePicker} />
|
||||
|
||||
## 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={DatePickerDefinition} />
|
||||
|
||||
<ChangelogComponent component="date-picker" />
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
classNamePropDefs,
|
||||
stylePropDefs,
|
||||
type PropDef,
|
||||
} from '@/utils/propDefs';
|
||||
import { Chip } from '@/components/Chip';
|
||||
|
||||
export const datePickerPropDefs: 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: ['DateValue'],
|
||||
description: 'Controlled value of the date.',
|
||||
},
|
||||
defaultValue: {
|
||||
type: 'enum',
|
||||
values: ['DateValue'],
|
||||
description: 'Default value for uncontrolled usage.',
|
||||
},
|
||||
onChange: {
|
||||
type: 'enum',
|
||||
values: ['(value: DateValue | null) => void'],
|
||||
description: 'Handler called when the selected date 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.',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Form field name for the 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,25 @@
|
||||
export const datePickerUsageSnippet = `import { DatePicker } from '@backstage/ui';
|
||||
|
||||
<DatePicker label="Date" />`;
|
||||
|
||||
export const withLabelSnippet = `<DatePicker
|
||||
label="Date"
|
||||
description="Select the date of your event."
|
||||
/>`;
|
||||
|
||||
export const sizesSnippet = `<Flex direction="column" gap="4">
|
||||
<DatePicker label="Small" size="small" />
|
||||
<DatePicker label="Medium" size="medium" />
|
||||
</Flex>`;
|
||||
|
||||
export const withDefaultValueSnippet = `import { parseDate } from '@internationalized/date';
|
||||
|
||||
<DatePicker
|
||||
label="Booking date"
|
||||
defaultValue={parseDate('2025-02-03')}
|
||||
/>`;
|
||||
|
||||
export const disabledSnippet = `<DatePicker
|
||||
label="Date"
|
||||
isDisabled
|
||||
/>`;
|
||||
@@ -58,6 +58,10 @@ export const components: Page[] = [
|
||||
title: 'Container',
|
||||
slug: 'container',
|
||||
},
|
||||
{
|
||||
title: 'DatePicker',
|
||||
slug: 'date-picker',
|
||||
},
|
||||
{
|
||||
title: 'DateRangePicker',
|
||||
slug: 'date-range-picker',
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { ComponentProps } from 'react';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import type { ComponentPropsWithRef } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { DatePickerProps as DatePickerProps_2 } from 'react-aria-components';
|
||||
import type { DateRangePickerProps as DateRangePickerProps_2 } from 'react-aria-components';
|
||||
import type { DateValue } from '@internationalized/date';
|
||||
import type { DialogTriggerProps as DialogTriggerProps_2 } from 'react-aria-components';
|
||||
@@ -1211,6 +1212,79 @@ export interface CursorResponse<T> {
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const DatePicker: ForwardRefExoticComponent<
|
||||
DatePickerProps & RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
// @public
|
||||
export const DatePickerCalendarDefinition: {
|
||||
readonly styles: {
|
||||
readonly [key: string]: string;
|
||||
};
|
||||
readonly classNames: {
|
||||
readonly root: 'bui-DatePickerCalendar';
|
||||
readonly header: 'bui-DatePickerCalendarHeader';
|
||||
readonly heading: 'bui-DatePickerCalendarHeading';
|
||||
readonly navButton: 'bui-DatePickerCalendarNavButton';
|
||||
readonly grid: 'bui-DatePickerCalendarGrid';
|
||||
readonly gridHeader: 'bui-DatePickerCalendarGridHeader';
|
||||
readonly headerCell: 'bui-DatePickerCalendarHeaderCell';
|
||||
readonly gridBody: 'bui-DatePickerCalendarGridBody';
|
||||
readonly cell: 'bui-DatePickerCalendarCell';
|
||||
};
|
||||
readonly propDefs: {};
|
||||
};
|
||||
|
||||
// @public
|
||||
export const DatePickerDefinition: {
|
||||
readonly styles: {
|
||||
readonly [key: string]: string;
|
||||
};
|
||||
readonly classNames: {
|
||||
readonly root: 'bui-DatePicker';
|
||||
};
|
||||
readonly propDefs: {
|
||||
readonly size: {
|
||||
readonly dataAttribute: true;
|
||||
readonly default: 'small';
|
||||
};
|
||||
readonly className: {};
|
||||
readonly label: {};
|
||||
readonly description: {};
|
||||
readonly secondaryLabel: {};
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export const DatePickerGroupDefinition: {
|
||||
readonly styles: {
|
||||
readonly [key: string]: string;
|
||||
};
|
||||
readonly classNames: {
|
||||
readonly root: 'bui-DatePickerGroup';
|
||||
readonly dateInput: 'bui-DatePickerDateInput';
|
||||
readonly segment: 'bui-DatePickerSegment';
|
||||
readonly button: 'bui-DatePickerButton';
|
||||
};
|
||||
readonly bg: 'consumer';
|
||||
readonly propDefs: {};
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type DatePickerOwnProps = {
|
||||
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
|
||||
className?: string;
|
||||
label?: FieldLabelProps['label'];
|
||||
description?: FieldLabelProps['description'];
|
||||
secondaryLabel?: FieldLabelProps['secondaryLabel'];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface DatePickerProps
|
||||
extends Omit<DatePickerProps_2<DateValue>, 'className' | 'children'>,
|
||||
DatePickerOwnProps {}
|
||||
|
||||
// @public
|
||||
export const DateRangePicker: ForwardRefExoticComponent<
|
||||
DateRangePickerProps & RefAttributes<HTMLDivElement>
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* 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-DatePicker {
|
||||
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-DatePickerGroup {
|
||||
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: 200px;
|
||||
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: 210px;
|
||||
padding-inline-end: var(--bui-space-1);
|
||||
|
||||
& .bui-DatePickerButton {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Date input
|
||||
============================================================ */
|
||||
|
||||
.bui-DatePickerDateInput {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: unset;
|
||||
min-width: unset;
|
||||
padding: unset;
|
||||
border: unset;
|
||||
box-shadow: none;
|
||||
background: none;
|
||||
height: auto;
|
||||
overflow-x: auto;
|
||||
overflow-y: clip;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Date segments (month, day, year literals and placeholders)
|
||||
============================================================ */
|
||||
|
||||
.bui-DatePickerSegment {
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Calendar trigger button
|
||||
============================================================ */
|
||||
|
||||
.bui-DatePickerButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--bui-radius-2);
|
||||
color: var(--bui-fg-secondary);
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
transition: color 0.15s ease-in-out;
|
||||
outline: none;
|
||||
|
||||
&[data-hovered] {
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
&[data-focus-visible] {
|
||||
box-shadow: inset 0 0 0 1px var(--bui-ring);
|
||||
}
|
||||
|
||||
&[data-pressed] {
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Calendar (inside Popover)
|
||||
============================================================ */
|
||||
|
||||
.bui-DatePickerCalendar {
|
||||
width: fit-content;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.bui-DatePickerCalendarHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--bui-space-3);
|
||||
}
|
||||
|
||||
.bui-DatePickerCalendarHeading {
|
||||
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-DatePickerCalendarNavButton {
|
||||
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-DatePickerCalendarGrid {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 2px;
|
||||
}
|
||||
|
||||
.bui-DatePickerCalendarHeaderCell {
|
||||
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
|
||||
============================================================ */
|
||||
|
||||
.bui-DatePickerCalendarCell {
|
||||
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);
|
||||
border-radius: var(--bui-radius-full);
|
||||
|
||||
&[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);
|
||||
}
|
||||
|
||||
&[data-focus-visible] {
|
||||
outline: 2px solid var(--bui-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Today marker */
|
||||
&[data-today]:not([data-selected]) {
|
||||
font-weight: var(--bui-font-weight-bold);
|
||||
}
|
||||
|
||||
/* Selected day */
|
||||
&[data-selected] {
|
||||
background-color: var(--bui-bg-solid);
|
||||
color: var(--bui-fg-solid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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 { DatePicker } from './DatePicker';
|
||||
import {
|
||||
parseDate,
|
||||
today,
|
||||
getLocalTimeZone,
|
||||
isWeekend,
|
||||
} from '@internationalized/date';
|
||||
import { useLocale, Form } from 'react-aria-components';
|
||||
import { Button } from '../Button';
|
||||
|
||||
const meta = preview.meta({
|
||||
title: 'Backstage UI/DatePicker',
|
||||
component: DatePicker,
|
||||
args: {
|
||||
style: { width: 280 },
|
||||
},
|
||||
});
|
||||
|
||||
export const Default = meta.story({
|
||||
args: {},
|
||||
});
|
||||
|
||||
export const WithLabel = meta.story({
|
||||
args: {
|
||||
label: 'Date',
|
||||
},
|
||||
});
|
||||
|
||||
export const WithDescription = meta.story({
|
||||
args: {
|
||||
label: 'Date',
|
||||
description: 'Select the date of your event.',
|
||||
},
|
||||
});
|
||||
|
||||
export const WithDefaultValue = meta.story({
|
||||
args: {
|
||||
label: 'Booking date',
|
||||
defaultValue: parseDate('2025-02-03'),
|
||||
},
|
||||
});
|
||||
|
||||
export const Sizes = meta.story({
|
||||
args: {
|
||||
label: 'Date',
|
||||
},
|
||||
render: args => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
width: 280,
|
||||
}}
|
||||
>
|
||||
<DatePicker {...args} size="small" label="Small" />
|
||||
<DatePicker {...args} size="medium" label="Medium" />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
export const Required = meta.story({
|
||||
args: {
|
||||
label: 'Trip date',
|
||||
isRequired: true,
|
||||
},
|
||||
render: args => (
|
||||
<Form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
width: 280,
|
||||
}}
|
||||
>
|
||||
<DatePicker {...args} />
|
||||
<Button type="submit">Submit</Button>
|
||||
</Form>
|
||||
),
|
||||
});
|
||||
|
||||
export const Disabled = meta.story({
|
||||
args: {
|
||||
label: 'Date',
|
||||
isDisabled: true,
|
||||
defaultValue: parseDate('2025-03-01'),
|
||||
},
|
||||
});
|
||||
|
||||
export const Invalid = meta.story({
|
||||
args: {
|
||||
label: 'Date',
|
||||
isInvalid: true,
|
||||
errorMessage: 'The selected date is not available.',
|
||||
defaultValue: parseDate('2025-04-01'),
|
||||
},
|
||||
});
|
||||
|
||||
export const WithMinMaxValue = meta.story({
|
||||
args: {
|
||||
label: 'Date',
|
||||
description: 'You can only select dates within the next 30 days.',
|
||||
minValue: today(getLocalTimeZone()),
|
||||
maxValue: today(getLocalTimeZone()).add({ days: 30 }),
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Weekends are marked unavailable and cannot be selected.
|
||||
*/
|
||||
export const WithUnavailableDates = meta.story({
|
||||
render: args => {
|
||||
const { locale } = useLocale();
|
||||
return (
|
||||
<DatePicker
|
||||
{...args}
|
||||
label="Working days only"
|
||||
description="Weekends are unavailable."
|
||||
isDateUnavailable={date => isWeekend(date, locale)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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 { DatePicker as AriaDatePicker } from 'react-aria-components';
|
||||
import { FieldLabel } from '../FieldLabel';
|
||||
import { FieldError } from '../FieldError';
|
||||
import { Popover } from '../Popover';
|
||||
import { DatePickerGroup } from './DatePickerGroup';
|
||||
import { DatePickerCalendar } from './DatePickerCalendar';
|
||||
import { useDefinition } from '../../hooks/useDefinition';
|
||||
import { DatePickerDefinition } from './definition';
|
||||
import type { DatePickerProps } from './types';
|
||||
|
||||
/**
|
||||
* A date picker that combines a date field and a calendar popover, allowing
|
||||
* users to enter or select a date with full keyboard and screen reader
|
||||
* accessibility.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
||||
(props, ref) => {
|
||||
const { ownProps, restProps, dataAttributes } = useDefinition(
|
||||
DatePickerDefinition,
|
||||
props,
|
||||
);
|
||||
|
||||
const { classes, label, description, secondaryLabel } = ownProps;
|
||||
|
||||
const ariaLabel = restProps['aria-label'];
|
||||
const ariaLabelledBy = restProps['aria-labelledby'];
|
||||
|
||||
useEffect(() => {
|
||||
if (!label && !ariaLabel && !ariaLabelledBy) {
|
||||
console.warn(
|
||||
'DatePicker requires either a visible label, aria-label, or aria-labelledby for accessibility',
|
||||
);
|
||||
}
|
||||
}, [label, ariaLabel, ariaLabelledBy]);
|
||||
|
||||
const secondaryLabelText =
|
||||
secondaryLabel || (restProps.isRequired ? 'Required' : null);
|
||||
|
||||
return (
|
||||
<AriaDatePicker
|
||||
className={classes.root}
|
||||
{...dataAttributes}
|
||||
{...restProps}
|
||||
ref={ref}
|
||||
>
|
||||
<FieldLabel
|
||||
label={label}
|
||||
secondaryLabel={secondaryLabelText}
|
||||
description={description}
|
||||
descriptionSlot="description"
|
||||
/>
|
||||
<DatePickerGroup dataSize={dataAttributes['data-size']} />
|
||||
<FieldError />
|
||||
<Popover hideArrow>
|
||||
<DatePickerCalendar />
|
||||
</Popover>
|
||||
</AriaDatePicker>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DatePicker.displayName = 'DatePicker';
|
||||
@@ -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 {
|
||||
Calendar,
|
||||
CalendarGrid,
|
||||
CalendarGridHeader,
|
||||
CalendarHeaderCell,
|
||||
CalendarGridBody,
|
||||
CalendarCell,
|
||||
Heading,
|
||||
Button,
|
||||
} from 'react-aria-components';
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react';
|
||||
import { useDefinition } from '../../hooks/useDefinition';
|
||||
import { DatePickerCalendarDefinition } from './definition';
|
||||
|
||||
/**
|
||||
* Calendar popover content for DatePicker — renders the Calendar with
|
||||
* navigation and a full calendar grid.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const DatePickerCalendar = () => {
|
||||
const { ownProps } = useDefinition(DatePickerCalendarDefinition, {});
|
||||
const { classes } = ownProps;
|
||||
|
||||
return (
|
||||
<Calendar className={classes.root}>
|
||||
<header className={classes.header}>
|
||||
<Button slot="previous" className={classes.navButton}>
|
||||
<RiArrowLeftSLine size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
<Heading className={classes.heading} />
|
||||
<Button slot="next" className={classes.navButton}>
|
||||
<RiArrowRightSLine size={16} aria-hidden="true" />
|
||||
</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>
|
||||
</Calendar>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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 { DatePickerGroupDefinition } from './definition';
|
||||
|
||||
/**
|
||||
* Custom field group for DatePicker — renders a single DateInput field
|
||||
* and a calendar trigger button.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const DatePickerGroup = ({ dataSize }: { dataSize?: string }) => {
|
||||
const { ownProps, dataAttributes } = useDefinition(
|
||||
DatePickerGroupDefinition,
|
||||
{},
|
||||
);
|
||||
const { classes } = ownProps;
|
||||
|
||||
return (
|
||||
<Group
|
||||
className={classes.root}
|
||||
{...dataAttributes}
|
||||
{...(dataSize ? { 'data-size': dataSize } : {})}
|
||||
>
|
||||
<DateInput className={classes.dateInput}>
|
||||
{segment => (
|
||||
<DateSegment segment={segment} className={classes.segment} />
|
||||
)}
|
||||
</DateInput>
|
||||
<Button className={classes.button} aria-label="Open calendar">
|
||||
<RiCalendarLine size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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 { DatePickerOwnProps } from './types';
|
||||
import styles from './DatePicker.module.css';
|
||||
|
||||
/**
|
||||
* Component definition for DatePicker
|
||||
* @public
|
||||
*/
|
||||
export const DatePickerDefinition = defineComponent<DatePickerOwnProps>()({
|
||||
styles,
|
||||
classNames: {
|
||||
root: 'bui-DatePicker',
|
||||
},
|
||||
propDefs: {
|
||||
size: { dataAttribute: true, default: 'small' },
|
||||
className: {},
|
||||
label: {},
|
||||
description: {},
|
||||
secondaryLabel: {},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Component definition for DatePickerGroup
|
||||
* @public
|
||||
*/
|
||||
export const DatePickerGroupDefinition = defineComponent<
|
||||
Record<string, never>
|
||||
>()({
|
||||
styles,
|
||||
classNames: {
|
||||
root: 'bui-DatePickerGroup',
|
||||
dateInput: 'bui-DatePickerDateInput',
|
||||
segment: 'bui-DatePickerSegment',
|
||||
button: 'bui-DatePickerButton',
|
||||
},
|
||||
bg: 'consumer',
|
||||
propDefs: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Component definition for DatePickerCalendar
|
||||
* @public
|
||||
*/
|
||||
export const DatePickerCalendarDefinition = defineComponent<
|
||||
Record<string, never>
|
||||
>()({
|
||||
styles,
|
||||
classNames: {
|
||||
root: 'bui-DatePickerCalendar',
|
||||
header: 'bui-DatePickerCalendarHeader',
|
||||
heading: 'bui-DatePickerCalendarHeading',
|
||||
navButton: 'bui-DatePickerCalendarNavButton',
|
||||
grid: 'bui-DatePickerCalendarGrid',
|
||||
gridHeader: 'bui-DatePickerCalendarGridHeader',
|
||||
headerCell: 'bui-DatePickerCalendarHeaderCell',
|
||||
gridBody: 'bui-DatePickerCalendarGridBody',
|
||||
cell: 'bui-DatePickerCalendarCell',
|
||||
},
|
||||
propDefs: {},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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 './DatePicker';
|
||||
export * from './types';
|
||||
export {
|
||||
DatePickerDefinition,
|
||||
DatePickerGroupDefinition,
|
||||
DatePickerCalendarDefinition,
|
||||
} 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 { DatePickerProps as AriaDatePickerProps } from 'react-aria-components';
|
||||
import type { DateValue } from '@internationalized/date';
|
||||
import type { Breakpoint } from '../../types';
|
||||
import type { FieldLabelProps } from '../FieldLabel/types';
|
||||
|
||||
/** @public */
|
||||
export type DatePickerOwnProps = {
|
||||
/**
|
||||
* The size of the date 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 DatePickerProps
|
||||
extends Omit<AriaDatePickerProps<DateValue>, 'className' | 'children'>,
|
||||
DatePickerOwnProps {}
|
||||
@@ -37,6 +37,11 @@ export { CheckboxDefinition } from './components/Checkbox/definition';
|
||||
export { CheckboxGroupDefinition } from './components/CheckboxGroup/definition';
|
||||
export { ComboboxDefinition } from './components/Combobox/definition';
|
||||
export { ContainerDefinition } from './components/Container/definition';
|
||||
export {
|
||||
DatePickerDefinition,
|
||||
DatePickerGroupDefinition,
|
||||
DatePickerCalendarDefinition,
|
||||
} from './components/DatePicker/definition';
|
||||
export { DateRangePickerDefinition } from './components/DateRangePicker/definition';
|
||||
export { DialogDefinition } from './components/Dialog/definition';
|
||||
export { FieldErrorDefinition } from './components/FieldError/definition';
|
||||
|
||||
@@ -34,6 +34,7 @@ export * from './components/Avatar';
|
||||
export * from './components/Badge';
|
||||
export * from './components/Button';
|
||||
export * from './components/Card';
|
||||
export * from './components/DatePicker';
|
||||
export * from './components/DateRangePicker';
|
||||
export * from './components/Dialog';
|
||||
export * from './components/FieldLabel';
|
||||
|
||||
Reference in New Issue
Block a user