diff --git a/packages/opub-ui/src/components/Calendar/Calendar.stories.tsx b/packages/opub-ui/src/components/Calendar/Calendar.stories.tsx index 0abc2cca..97d8ce59 100644 --- a/packages/opub-ui/src/components/Calendar/Calendar.stories.tsx +++ b/packages/opub-ui/src/components/Calendar/Calendar.stories.tsx @@ -6,9 +6,10 @@ import { today, } from '@internationalized/date'; import { Meta, StoryObj } from '@storybook/react-vite'; -import { useDateFormatter, useLocale } from 'react-aria'; +import { I18nProvider, useDateFormatter, useLocale } from 'react-aria'; import { Calendar } from './Calendar'; +import { MultiSelectYearCalendar } from './MultiSelectYearCalendar'; import { RangeCalendar } from './RangeCalendar'; import { YearCalendar } from './YearCalendar'; @@ -89,3 +90,74 @@ export const YearDisabled: any = { }, args: {}, }; + +export const MultiSelectYear: any = { + render: ({ ...args }) => { + return ( + { + console.log(dates); + }} + /> + ); + }, + args: {}, +}; + +export const MultiSelectYearDisabled: any = { + render: ({ ...args }) => { + return ( + { + console.log(dates); + }} + /> + ); + }, + args: {}, +}; + +/** + * Consumers translate the calendar's aria-label strings by passing `labels`, + * the same way the Table footer accepts localized labels. Inspect a selected + * month's aria-label to see the localized "selected" suffix. + */ +export const YearLocalizedLabels: any = { + render: ({ ...args }) => { + return ( + { + console.log(date); + }} + /> + ); + }, + args: {}, +}; + +/** + * Month names are formatted from the active locale (via `useDateFormatter`), + * so wrapping the calendar in an `` localizes them with no + * consumer-supplied strings. Here the months render in French. + */ +export const YearLocalizedMonths: any = { + render: ({ ...args }) => { + return ( + + { + console.log(date); + }} + /> + + ); + }, + args: {}, +}; diff --git a/packages/opub-ui/src/components/Calendar/MultiSelectYearCalendar.tsx b/packages/opub-ui/src/components/Calendar/MultiSelectYearCalendar.tsx index 6cfe34ee..b712cf78 100644 --- a/packages/opub-ui/src/components/Calendar/MultiSelectYearCalendar.tsx +++ b/packages/opub-ui/src/components/Calendar/MultiSelectYearCalendar.tsx @@ -15,32 +15,18 @@ import { useCalendarState, } from 'react-stately'; +import { YearCalendarLabels } from '../../types/datetime'; import { cn } from '../../utils'; import { Icon } from '../Icon'; import { Text } from '../Text'; import styles from './Calendar.module.scss'; -const monthsObj: { - [key: number]: { month: string; value: number; label: string }[]; -} = { - 0: [ - { month: 'Jan', value: 1, label: 'January' }, - { month: 'Feb', value: 2, label: 'February' }, - { month: 'Mar', value: 3, label: 'March' }, - { month: 'Apr', value: 4, label: 'April' }, - ], - 1: [ - { month: 'May', value: 5, label: 'May' }, - { month: 'Jun', value: 6, label: 'June' }, - { month: 'Jul', value: 7, label: 'July' }, - { month: 'Aug', value: 8, label: 'August' }, - ], - 2: [ - { month: 'Sep', value: 9, label: 'September' }, - { month: 'Oct', value: 10, label: 'October' }, - { month: 'Nov', value: 11, label: 'November' }, - { month: 'Dec', value: 12, label: 'December' }, - ], +// Month numbers laid out as the 3x4 grid. Names are formatted per locale at +// render time (see Cell), so no month strings are hardcoded here. +const monthsObj: { [key: number]: { value: number }[] } = { + 0: [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }], + 1: [{ value: 5 }, { value: 6 }, { value: 7 }, { value: 8 }], + 2: [{ value: 9 }, { value: 10 }, { value: 11 }, { value: 12 }], }; interface MultiSelectYearCalendarProps { @@ -48,6 +34,11 @@ interface MultiSelectYearCalendarProps { disabledMonths?: DateValue[]; yearRange?: { start: number; end: number }; + /** Earliest selectable month; earlier months render disabled */ + minValue?: DateValue | null; + /** Latest selectable month; later months render disabled */ + maxValue?: DateValue | null; + // onSelectionChange: ( // selections: { // year: number; @@ -61,6 +52,9 @@ interface MultiSelectYearCalendarProps { defaultValues?: DateValue[]; onChange?: (dates: DateValue[]) => void; + + /** Localizable strings. Falls back to English defaults. */ + labels?: YearCalendarLabels; } export const MultiSelectYearCalendar = ( @@ -147,6 +141,7 @@ export const MultiSelectYearCalendar = ( state={state} selectedDates={selectedDates} defaultValue={props.defaultValues} + labels={props.labels} /> ); @@ -170,10 +165,12 @@ function MonthSelector({ state, selectedDates, defaultValue, + labels, }: { state: CalendarState; selectedDates: DateValue[]; defaultValue?: DateValue[]; + labels?: YearCalendarLabels; }) { React.useEffect(() => { const nextFocusElm = document.querySelector( @@ -224,6 +221,7 @@ function MonthSelector({ state={state} selectedDates={selectedDates} defaultValue={defaultValue} + labels={labels} /> ); })} @@ -243,14 +241,25 @@ const Cell = ({ state, selectedDates, defaultValue, + labels, }: { - mon: { month: string; value: number; label: string }; + mon: { value: number }; state: CalendarState; selectedDates: DateValue[]; defaultValue?: DateValue[]; + labels?: YearCalendarLabels; }) => { let date = state.focusedDate.set({ month: mon.value }); + // Localized month names from the active locale (via the nearest + // , falling back to the runtime locale). The day is fixed to + // the 1st in local time to avoid the UTC-midnight off-by-one. + const monthShortFormatter = useDateFormatter({ month: 'short' }); + const monthLongFormatter = useDateFormatter({ month: 'long' }); + const nameDate = new Date(state.focusedDate.year, mon.value - 1, 1); + const monthShort = monthShortFormatter.format(nameDate); + const monthLong = monthLongFormatter.format(nameDate); + const { minValue, maxValue } = state; const isDisabled = (minValue && date.compare(minValue) < 0) || @@ -278,16 +287,21 @@ const Cell = ({ state.setFocusedDate(date); }; + const ariaLabel = `${monthLong}, ${state.focusedDate.year}${ + isSelected ? `, ${labels?.selected ?? 'selected'}` : '' + }`; + return ( ); diff --git a/packages/opub-ui/src/components/Calendar/YearCalendar.tsx b/packages/opub-ui/src/components/Calendar/YearCalendar.tsx index ac8559da..53e3b6fd 100644 --- a/packages/opub-ui/src/components/Calendar/YearCalendar.tsx +++ b/packages/opub-ui/src/components/Calendar/YearCalendar.tsx @@ -15,36 +15,25 @@ import { useCalendarState, } from 'react-stately'; +import { YearCalendarLabels } from '../../types/datetime'; import { cn } from '../../utils'; import { Icon } from '../Icon'; import { Text } from '../Text'; import styles from './Calendar.module.scss'; -const monthsObj: { - [key: number]: { month: string; value: number; label: string }[]; -} = { - 0: [ - { month: 'Jan', value: 1, label: 'January' }, - { month: 'Feb', value: 2, label: 'February' }, - { month: 'Mar', value: 3, label: 'March' }, - { month: 'Apr', value: 4, label: 'April' }, - ], - 1: [ - { month: 'May', value: 5, label: 'May' }, - { month: 'Jun', value: 6, label: 'June' }, - { month: 'Jul', value: 7, label: 'July' }, - { month: 'Aug', value: 8, label: 'August' }, - ], - 2: [ - { month: 'Sep', value: 9, label: 'September' }, - { month: 'Oct', value: 10, label: 'October' }, - { month: 'Nov', value: 11, label: 'November' }, - { month: 'Dec', value: 12, label: 'December' }, - ], +// Month numbers laid out as the 3x4 grid. Names are formatted per locale at +// render time (see Cell), so no month strings are hardcoded here. +const monthsObj: { [key: number]: { value: number }[] } = { + 0: [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }], + 1: [{ value: 5 }, { value: 6 }, { value: 7 }, { value: 8 }], + 2: [{ value: 9 }, { value: 10 }, { value: 11 }, { value: 12 }], }; export const YearCalendar = ( - props: CalendarStateOptions | AriaCalendarProps + props: (CalendarStateOptions | AriaCalendarProps) & { + /** Localizable strings. Falls back to English defaults. */ + labels?: YearCalendarLabels; + } ) => { let { locale } = useLocale(); let state = useCalendarState({ @@ -98,7 +87,7 @@ export const YearCalendar = ( - + ); }; @@ -117,7 +106,13 @@ function YearDropdown({ state }: { state: CalendarState }) { ); } -function MonthSelector({ state }: { state: CalendarState }) { +function MonthSelector({ + state, + labels, +}: { + state: CalendarState; + labels?: YearCalendarLabels; +}) { React.useEffect(() => { const nextFocusElm = document.querySelector( `[data-label="${state.focusedDate.month}, ${state.focusedDate.year}"]` @@ -155,7 +150,9 @@ function MonthSelector({ state }: { state: CalendarState }) { return ( {monthsObj[i].map((mon) => { - return ; + return ( + + ); })} ); @@ -171,12 +168,23 @@ function MonthSelector({ state }: { state: CalendarState }) { const Cell = ({ mon, state, + labels, }: { - mon: { month: string; value: number; label: string }; + mon: { value: number }; state: CalendarState; + labels?: YearCalendarLabels; }) => { let date = state.focusedDate.set({ month: mon.value }); + // Localized month names from the active locale (via the nearest + // , falling back to the runtime locale). The day is fixed to + // the 1st in local time to avoid the UTC-midnight off-by-one. + const monthShortFormatter = useDateFormatter({ month: 'short' }); + const monthLongFormatter = useDateFormatter({ month: 'long' }); + const nameDate = new Date(state.focusedDate.year, mon.value - 1, 1); + const monthShort = monthShortFormatter.format(nameDate); + const monthLong = monthLongFormatter.format(nameDate); + const { minValue, maxValue } = state; const isDisabled = (minValue && date.compare(minValue) < 0) || @@ -198,22 +206,26 @@ const Cell = ({ const value = Number((e.target as HTMLElement).getAttribute('value')); let date = state.focusedDate.set({ month: value }); - console.log(date.toDate('UTC')); state.setFocusedDate(date); state.setValue(date); }; + const ariaLabel = `${monthLong}, ${state.focusedDate.year}${ + isSelected ? `, ${labels?.selected ?? 'selected'}` : '' + }`; + return ( ); diff --git a/packages/opub-ui/src/components/Select/Select.stories.tsx b/packages/opub-ui/src/components/Select/Select.stories.tsx index 883e96ee..a6d63ef6 100644 --- a/packages/opub-ui/src/components/Select/Select.stories.tsx +++ b/packages/opub-ui/src/components/Select/Select.stories.tsx @@ -115,6 +115,26 @@ export function Disabled({ ...props }) { // ); // } +export function WithDescribedBy({ ...props }) { + const options = [ + { label: 'Block A', value: 'a' }, + { label: 'Block B', value: 'b' }, + ]; + + return ( + <> +

Select a district first to enable this field.

+