Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions Calendar/Calendar.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Meta, Story, Canvas, Source, Controls, Markdown } from '@storybook/blocks'
import * as CalendarStories from './Calendar.stories'

<Meta of={CalendarStories} />

# Calendar

**`<Calendar>`** displays a date grid and allows users to select a single date.

It is used in the [DatePicker input](/docs/inputs-datepicker--docs) and the [DatePickerField](/docs/forms-fields-datepickerfield--docs).

It selects and emits the `DateValue` (the type used by [`@internationalized/date`](https://react-spectrum.adobe.com/internationalized/date/index.html))

<Canvas of={CalendarStories.Default} />

### Usage

<Source code={`import { Calendar } from '@ppl-gds/ui'`} />

<Source
code={`
<Calendar
{/* optional props */}
ariaLabel="Event date" {/* string, defaults to 'Select a date' */}
disablePastDates={disablePastDates} {/* boolean */}
disableWeekends={disableWeekends} {/* boolean */}
disableToday={disableToday} {/* boolean */}
showCalendarValue={showCalendarValue} {/* boolean - prints the value inside the calendar */}
readOnly={readOnly} {/* boolean */}
minDate={minDate} {/* Date */}
maxDate={maxDate} {/* Date */}
defaultDate={defaultDate} {/* Date */}
onChange={onChange} {/* emits the value as the DateValue type */}
/>
`}
/>

### Story props

<Controls />
101 changes: 101 additions & 0 deletions Calendar/Calendar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Calendar, CalendarProps } from './Calendar'
import { expect, within } from '@storybook/test'

const CalendarDemo = ({
disablePastDates,
disableWeekends,
disableToday,
minDate,
maxDate,
showCalendarValue,
readOnly,
}: CalendarProps) => {
const today = new Date()

return (
<Calendar
disablePastDates={disablePastDates}
disableWeekends={disableWeekends}
disableToday={disableToday}
minDate={minDate}
maxDate={maxDate}
showCalendarValue={showCalendarValue}
readOnly={readOnly}
defaultDate={today}
/>
)
}

const meta: Meta<typeof Calendar> = {
title: 'Inputs/Elements/Calendar',
render: ({ ...args }) => (
<CalendarDemo
disablePastDates={args.disablePastDates}
disableWeekends={args.disableWeekends}
disableToday={args.disableToday}
// The date control will convert the date into a UNIX timestamp when the value changes. https://storybook.js.org/docs/api/arg-types
minDate={args.minDate && new Date(args.minDate)}
maxDate={args.maxDate && new Date(args.maxDate)}
showCalendarValue={args.showCalendarValue}
readOnly={args.readOnly}
/>
),
args: {
disablePastDates: false,
disableWeekends: false,
disableToday: false,
minDate: undefined,
maxDate: undefined,
readOnly: false,
showCalendarValue: true,
},
argTypes: {
minDate: { control: 'date' },
maxDate: { control: 'date' },
},
play: async ({ canvasElement, args }) => {
const today = new Date()
const month = today.toLocaleString('default', { month: 'long' })
const year = today.getFullYear()

// these tests do not consider any disabled or unavailable dates & may fail occassionaly

const canvas = within(canvasElement)
//calendar
const calendar = canvas.getByRole('application', {
name: `Select a date, ${month} ${year}`,
})
await expect(calendar).toBeInTheDocument()
await expect(calendar).toHaveAccessibleName()

if (args.readOnly) {
await expect(within(calendar).getByTestId('calendar-table')).toHaveAttribute(
'aria-readonly',
'true'
)
return
}
//buttons & heading
const prevBtn = within(calendar).getByRole('button', {
name: 'previous month',
})
await expect(prevBtn).toBeVisible()
await expect(prevBtn).toHaveAccessibleName()
const nextBtn = within(calendar).getByRole('button', { name: 'next month' })
await expect(nextBtn).toBeVisible()
await expect(nextBtn).toHaveAccessibleName()
const calendarHeading = within(calendar).getByText(`${month} ${year}`)
await expect(calendarHeading).toBeVisible()
//selected date (text)
if (args.showCalendarValue) {
await expect(within(calendar).getByText(/Selected date/i)).toBeInTheDocument()
}
},
}

export default meta

type Story = StoryObj<typeof Calendar>

export const Default: Story = {}
128 changes: 128 additions & 0 deletions Calendar/Calendar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
'use client'
import { use } from 'react'
import { DateFormat } from '@/components/utility/DateFormat'
import { Icon } from '@/theme/Icons'
import { cn } from '@/utils/cn'
import {
Heading,
Button,
Calendar as CalendarComponent,
CalendarCell,
CalendarGrid,
CalendarGridHeader,
CalendarHeaderCell,
CalendarGridBody,
CalendarStateContext,
} from 'react-aria-components'
import { DateValue, getLocalTimeZone, isSameDay, parseDate } from '@internationalized/date'
import { today, isWeekend } from '@internationalized/date'

// uses Calendar from Adobe's react-aria-components library
// (https://react-spectrum.adobe.com/react-aria/Calendar.html)
// uses Adobe's @internationalized/date package (objects and functions for representing dates)
// (https://react-spectrum.adobe.com/internationalized/date/index.html)

// ------------------------------------- Calendar

export type CalendarProps = {
ariaLabel?: string
disablePastDates?: boolean
disableWeekends?: boolean
disableToday?: boolean
minDate?: Date
maxDate?: Date
showCalendarValue?: boolean
defaultDate?: Date
readOnly?: boolean
onChange?: (v: DateValue) => void
}

const Calendar = ({
ariaLabel,
disablePastDates,
disableWeekends,
disableToday,
minDate,
maxDate,
showCalendarValue,
readOnly,
defaultDate,
onChange,
}: CalendarProps) => {
function CalendarValue() {
const state = use(CalendarStateContext)
const date = state?.value?.toDate(getLocalTimeZone())
const formatted = date ? <DateFormat date={date} /> : 'None'
return <div className="text-sm text-gray-500 pl-3 my-1">Selected date: {formatted}</div>
}

const isDateUnavailable = (date: DateValue) => {
return (
(disableWeekends && isWeekend(date, 'en-US')) ||
(disableToday && isSameDay(date, today(getLocalTimeZone()))) || // isToday throws on timezone/locale ?
false
)
}

function convertToDateValue(date: Date | undefined) {
return date ? parseDate(date?.toISOString().split('T')[0]) : undefined
}

const minValue = disablePastDates ? today(getLocalTimeZone()) : undefined

// parses dates to DateValue type (used by @internationalized/date)
const minDateToMinValue = convertToDateValue(minDate)
const maxDateToMaxValue = convertToDateValue(maxDate)
const defaultValue = convertToDateValue(defaultDate)

return (
<CalendarComponent
aria-label={ariaLabel || 'Select a date'}
className="w-fit border rounded-md p-2 bg-white"
minValue={minValue || minDateToMinValue}
maxValue={maxDateToMaxValue}
isDateUnavailable={isDateUnavailable}
isReadOnly={readOnly}
defaultValue={defaultValue}
onChange={onChange}
>
<div className="flex justify-around items-center w-full my-3 text-gray-800">
<Button slot="previous" aria-label="previous month">
<Icon name="ChevronLeft" size="sm" />
</Button>
<Heading className="text-sm tracking-wide" />
<Button slot="next" aria-label="next month">
<Icon name="ChevronRight" size="sm" />
</Button>
</div>

<CalendarGrid className="m-1" data-testid="calendar-table">
<CalendarGridHeader className="">
{(day) => (
<CalendarHeaderCell className="font-normal text-sm text-gray-500">
{day}
</CalendarHeaderCell>
)}
</CalendarGridHeader>
<CalendarGridBody>
{(date) => (
<CalendarCell
date={date}
className={cn(
'py-1 px-2 rounded text-center text-sm text-gray-700 border border-transparent hover:border-gray-800',
'data-[disabled=true]:opacity-40 data-[disabled=true]:hover:border-none data-[disabled=true]:cursor-default',
'data-[unavailable]:opacity-40 data-[unavailable]:hover:border-none data-[unavailable]:cursor-default',
'focus:outline-black data-[selected]:bg-primary-500 data-[selected]:text-white',
readOnly && 'opacity-75 cursor-default'
)}
/>
)}
</CalendarGridBody>
</CalendarGrid>
{showCalendarValue && <CalendarValue />}
</CalendarComponent>
)
}
// ------------------------------------- Calendar export

export { Calendar }
1 change: 1 addition & 0 deletions Calendar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Calendar'
110 changes: 110 additions & 0 deletions DatePickerField/DatePickerField.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { Meta, Story, Canvas, Source, Controls, Markdown } from '@storybook/blocks'
import * as DatePickerFieldStories from './DatePickerField.stories'

<Meta of={DatePickerFieldStories} />

# DatePickerField

A **`<DatePickerField>`** component to be used in Forms

For non-form purposes, utilize the base **`<DatePicker>`** input. [Inputs > DatePicker](/docs/inputs-datepicker--docs).

<Canvas of={DatePickerFieldStories.Default} />

### Usage

Use **`<DatePickerField>`** inside the **`<Form>`** wrapper component (see [Form > Docs](/docs/forms-fields-form--docs))

**`<DatePickerField>`** uses two subcomponents, Calendar and DateInput from RAC.
Calendar accepts the disabling props, and will not let the user pick any unavailable dates.

<br /> The DateInput will do the same validation based on the disabling props, but requires extra
handling inside the `Form` component. This is because form validation will come from zod but also
from within the DatePikcerField component, and this information must be considered before the form
is submitted.

<Source code={`import { DatePickerField } from '@ppl-gds/ui'`} />

<Source code={`
{/* The below handles validation for the DateInput for unavailable dates */}
const [datepickerError, setDatepickerError] = useState<string | null>(null)

const setErrorMessage = useCallback(
(msg: string) => {
form.setError('date_picker', {
type: 'custom',
message: msg,
})
},[form]
)

useEffect(() => {
datepickerError
? setErrorMessage(datepickerError)
: form.clearErrors('date_picker')
}, [form, datepickerError, setErrorMessage])

const handleSubmit = async (data: z.infer<typeof FormSchema>) => {
if (datepickerError) {
//set the error message again because RHF removes it
setErrorMessage(datepickerError)
// focus on the first invalid field
const firstErrorField = Object.keys(form.formState.errors)[0]
const fieldRef = form.control.\_fields?.[firstErrorField]?.\_f.ref
// @ts-expect-error (ref possibly undefined)
fieldRef && fieldRef.focus()
} else {
onSubmit(data) //proceed to submission
}
}
`}/>

<Source
code={`
<Form
formMethods={form}
formName="datepicker form"
onSubmit={handleSubmit} {/* the handleSubmit method defined above */}
>
<DatePickerField
control={form.control} {/* connect to form control */}
name={name} {/* string - must match the name declared in form (useForm by RHF) */}
label={label} {/* string */}
handleFieldError={setDatepickerError} {/* () => {} - set the error msg in the local state */}
description={description} {/* string - recommended, add a description for better accessibility */}
{/* optional props */}
id={id} {/* string */}
srOnlyLabel={srOnlyLabel} {/* boolean */}
srOnlyDescription={srOnlyDescription} {/* boolean */}
disabled={disabled} {/* boolean */}
readOnly={readOnly} {/* boolean */}
required={required} {/* boolean, default true - "(optional)" shown on label if !required */}
showOptional={showOptional} {/* boolean */}
{/* optional calendar props */}
showCalendarValue={showCalendarValue} {/* boolean */}
disablePastDates={disablePastDates} {/* boolean */}
disableWeekends={disableWeekends} {/* boolean */}
disableToday={disableToday} {/* boolean */}
minDate={minDate} {/* Date, default is new Date(1900, 0, 1) */}
maxDate={maxDate} {/* Date */}
/>
</Form>
`}
/>

### Setting a default value

Settting a `defaultValue` in the `form` will populate the **DatePickerField** on initial render.

<Source
code={`const form = useForm<z.infer<typeof FormSchema2>>({
resolver: zodResolver(FormSchema2),
defaultValues: {
date_picker: new Date(),
},
})`}
/>

### Story props

<Controls />
Loading