diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 03d42c587..afb15902c 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -206,6 +206,10 @@ export default defineConfig({ text: `Time Field ${BadgeHTML('Alpha', true)}`, link: '/docs/components/time-field', }, + { + text: `Time Range Field ${BadgeHTML('Alpha', true)}`, + link: '/docs/components/time-range-field', + }, { text: `Month Picker ${BadgeHTML('Alpha', true)}`, link: '/docs/components/month-picker', diff --git a/docs/components/demo/TimeRangeField/css/index.vue b/docs/components/demo/TimeRangeField/css/index.vue new file mode 100644 index 000000000..bda032d89 --- /dev/null +++ b/docs/components/demo/TimeRangeField/css/index.vue @@ -0,0 +1,64 @@ + + + diff --git a/docs/components/demo/TimeRangeField/css/styles.css b/docs/components/demo/TimeRangeField/css/styles.css new file mode 100644 index 000000000..3bac22fab --- /dev/null +++ b/docs/components/demo/TimeRangeField/css/styles.css @@ -0,0 +1,55 @@ +@import '@radix-ui/colors/black-alpha.css'; +@import '@radix-ui/colors/mauve.css'; +@import '@radix-ui/colors/grass.css'; + +.TimeFieldWrapper { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.TimeFieldLabel { + font-size: 0.875rem; + line-height: 1.25rem; + color: var(--gray-9); +} + +.TimeField { + display: flex; + padding: 0.5rem; + align-items: center; + border-radius: 0.25rem; + text-align: center; + background-color: #fff; + user-select: none; + color: var(--green-10); + border: 1px solid var(--gray-9); +} + +.TimeField::placeholder { + color: var(--mauve-5); +} + +.TimeField[data-invalid] { + border: 1px solid var(--red-500); +} + +.TimeFieldLiteral { + padding: 0.25rem; +} + +.TimeFieldSegment { + padding: 0.25rem; +} + +.TimeFieldSegment:hover{ + background-color: var(--grass-4); +} + +.TimeFieldSegment:focus { + background-color: var(--grass-2); +} + +.TimeFieldSegment[aria-valuetext='Empty'] { + color: var(--grass-6); +} diff --git a/docs/components/demo/TimeRangeField/tailwind/index.vue b/docs/components/demo/TimeRangeField/tailwind/index.vue new file mode 100644 index 000000000..886a8ee26 --- /dev/null +++ b/docs/components/demo/TimeRangeField/tailwind/index.vue @@ -0,0 +1,65 @@ + + + diff --git a/docs/components/demo/TimeRangeField/tailwind/tailwind.config.js b/docs/components/demo/TimeRangeField/tailwind/tailwind.config.js new file mode 100644 index 000000000..a9c58386b --- /dev/null +++ b/docs/components/demo/TimeRangeField/tailwind/tailwind.config.js @@ -0,0 +1,16 @@ +const { blackA, grass, green } = require('@radix-ui/colors') + +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./**/*.vue'], + theme: { + extend: { + colors: { + ...blackA, + ...grass, + ...green, + }, + }, + }, + plugins: [], +} diff --git a/docs/content/docs/components/time-range-field.md b/docs/content/docs/components/time-range-field.md new file mode 100644 index 000000000..4fffcaf04 --- /dev/null +++ b/docs/content/docs/components/time-range-field.md @@ -0,0 +1,858 @@ +--- +title: Time Range Field +description: Allows users to input a range of times within a designated field. +name: time-range-field +--- + +# Time Range Field + +Alpha + + +Allows users to input a range of times within a designated field. + + + + +## Features + + + +## Preface + +The component depends on the [@internationalized/date](https://react-spectrum.adobe.com/internationalized/date/index.html) package, which solves a lot of the problems that come with working with dates and times in JavaScript. + +We highly recommend reading through the documentation for the package to get a solid feel for how it works, and you'll need to install it in your project to use the time-related components. + +## Installation + +Install the date package. + + + +Install the component from your command line. + + + +## Anatomy + +Import all parts and piece them together. + +```vue + + + +``` + +## API Reference + +### Root + +Contains all the parts of a time range field. + + + + + +### Input + +Contains the time field segments. + + + + + +## Accessibility + +### Keyboard Interactions + + + +## Usage Examples + +### Basic Usage + +```vue + + + +``` + +### Controlled Component + +```vue + + + +``` + +### With Validation + +```vue + + + +``` + +### With Different Granularity + +```vue + + + +``` + +### With Locale and Hour Cycle + +```vue + + + +``` + +### With Min and Max Values + +```vue + + + +``` + +### With Step Increment + +```vue + + + +``` + +### Disabled State + +```vue + + + +``` + +### Read-only State + +```vue + + + +``` + +### Advanced Keyboard Navigation + +The TimeRangeField provides intuitive keyboard navigation for efficient time input. Users can seamlessly move between time segments, increment or decrement values, and type numbers directly. + +```vue + + + +``` + +### Form Integration + +Integrate TimeRangeField with HTML forms to handle submissions and validation. + +```vue + + + +``` + +### Custom Styling + +Customize the appearance of the TimeRangeField using CSS classes or Tailwind utilities. + +```vue + + + +``` + +### Advanced Validation + +Implement complex validation rules, such as ensuring the end time is after the start time and within business hours. + +```vue + + + +``` + +### Accessibility Features + +The TimeRangeField is built with accessibility in mind. Enhance it further with ARIA labels and descriptions for screen readers. + +```vue + + + +``` + +### Real-world Use Cases + +Use TimeRangeField in practical scenarios like scheduling appointments or booking resources. + +```vue + + + diff --git a/docs/content/meta/TimeRangeFieldInput.md b/docs/content/meta/TimeRangeFieldInput.md new file mode 100644 index 000000000..63d009992 --- /dev/null +++ b/docs/content/meta/TimeRangeFieldInput.md @@ -0,0 +1,29 @@ + + + diff --git a/docs/content/meta/TimeRangeFieldRoot.md b/docs/content/meta/TimeRangeFieldRoot.md new file mode 100644 index 000000000..263749809 --- /dev/null +++ b/docs/content/meta/TimeRangeFieldRoot.md @@ -0,0 +1,171 @@ + + + + + + + + + diff --git a/packages/core/constant/components.ts b/packages/core/constant/components.ts index 6a822b734..82e2f7a1a 100644 --- a/packages/core/constant/components.ts +++ b/packages/core/constant/components.ts @@ -458,6 +458,11 @@ export const components = { 'TimeFieldRoot', ] as const, + timeRangeField: [ + 'TimeRangeFieldRoot', + 'TimeRangeFieldInput', + ] as const, + toast: [ 'ToastProvider', 'ToastRoot', diff --git a/packages/core/src/DateRangeField/DateRangeFieldRoot.vue b/packages/core/src/DateRangeField/DateRangeFieldRoot.vue index 99a51fa49..b4d0e4ad2 100644 --- a/packages/core/src/DateRangeField/DateRangeFieldRoot.vue +++ b/packages/core/src/DateRangeField/DateRangeFieldRoot.vue @@ -12,15 +12,12 @@ import { hasTime, isBefore, isBeforeOrSame, - } from '@/date' import { createContext, useDateFormatter, useDirection, useKbd, useLocale } from '@/shared' import { createContent, - getDefaultDate, getSegmentElements, - initializeSegmentValues, isSegmentNavigationKey, normalizeDateStep, @@ -114,6 +111,16 @@ const props = withDefaults(defineProps(), { isDateUnavailable: undefined, }) const emits = defineEmits() +defineSlots<{ + default?: (props: { + /** The current date range of the field */ + modelValue: DateRange | null + /** The date field segment contents */ + segments: { start: { part: SegmentPart, value: string }[], end: { part: SegmentPart, value: string }[] } + /** Value if the input is invalid */ + isInvalid: boolean + }) => any +}>() const { disabled, readonly, isDateUnavailable: propsIsDateUnavailable, dir: propDir, locale: propLocale } = toRefs(props) const locale = useLocale(propLocale) const dir = useDirection(propDir) diff --git a/packages/core/src/TimeField/TimeFieldRoot.vue b/packages/core/src/TimeField/TimeFieldRoot.vue index aa003359b..4bab873db 100644 --- a/packages/core/src/TimeField/TimeFieldRoot.vue +++ b/packages/core/src/TimeField/TimeFieldRoot.vue @@ -12,14 +12,11 @@ import { createContent, getDefaultTime, getTimeFieldSegmentElements, - initializeTimeSegmentValues, isSegmentNavigationKey, normalizeDateStep, normalizeHourCycle, - syncTimeSegmentValues, - } from '@/shared/date' type TimeFieldRootContext = { diff --git a/packages/core/src/TimeRangeField/TimeRangeField.test.ts b/packages/core/src/TimeRangeField/TimeRangeField.test.ts new file mode 100644 index 000000000..9c46497ec --- /dev/null +++ b/packages/core/src/TimeRangeField/TimeRangeField.test.ts @@ -0,0 +1,327 @@ +import type { TimeRangeFieldRootProps } from './TimeRangeFieldRoot.vue' +import type { TimeValue } from '@/shared/date' +import { CalendarDateTime, Time, toZoned } from '@internationalized/date' +import userEvent from '@testing-library/user-event' +import { render } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' +import { axe } from 'vitest-axe' +import { useTestKbd } from '@/shared' +import TimeField from './story/_TimeRangeField.vue' + +const time = { start: new Time(9, 15, 29), end: new Time(17, 45, 0) } +const calendarDateTime = { + start: new CalendarDateTime(2022, 1, 1, 9, 15), + end: new CalendarDateTime(2022, 1, 1, 17, 45), +} +const zonedDateTime = { + start: toZoned(calendarDateTime.start, 'America/New_York'), + end: toZoned(calendarDateTime.end, 'America/New_York'), +} + +const kbd = useTestKbd() + +function setup(props: { timeRangeFieldProps?: TimeRangeFieldRootProps, emits?: { 'onUpdate:modelValue'?: (data: TimeValue) => void } } = {}) { + const user = userEvent.setup() + const returned = render(TimeField, { props }) + const value = returned.getByTestId('value') + + const start = { + hour: returned.getByTestId('start-hour'), + minute: returned.getByTestId('start-minute'), + } + + const end = { + hour: returned.getByTestId('end-hour'), + minute: returned.getByTestId('end-minute'), + } + + const input = returned.getByTestId('input') + const label = returned.getByTestId('label') + + return { ...returned, user, input, start, end, label, value } +} + +it('should pass axe accessibility tests', async () => { + const { container } = setup() + expect(await axe(container)).toHaveNoViolations() +}) + +describe('timeField', () => { + it('populates segment with value - `Time`', async () => { + const { start, end } = setup({ + timeRangeFieldProps: { modelValue: time, locale: 'en-GB' }, + }) + + expect(start.hour).toHaveTextContent(String(time.start.hour).padStart(2, '0')) + expect(end.hour).toHaveTextContent(String(time.end.hour).padStart(2, '0')) + }) + + it('populates segment with value - `CalendarDateTime`', async () => { + const { start, end } = setup({ + timeRangeFieldProps: { modelValue: calendarDateTime, locale: 'en-GB' }, + }) + + expect(start.hour).toHaveTextContent(String(calendarDateTime.start.hour)) + expect(start.minute).toHaveTextContent(String(calendarDateTime.start.minute)) + expect(end.hour).toHaveTextContent(String(calendarDateTime.end.hour).padStart(2, '0')) + expect(end.minute).toHaveTextContent(String(calendarDateTime.end.minute)) + }) + + it('populates segment with value - `ZonedDateTime`', async () => { + const { start, end, getByTestId } = setup({ + timeRangeFieldProps: { modelValue: zonedDateTime, locale: 'en-US', hourCycle: 12 }, + }) + + expect(start.hour).toHaveTextContent(String(zonedDateTime.start.hour)) + expect(start.minute).toHaveTextContent(String(zonedDateTime.start.minute)) + expect(end.hour).toHaveTextContent(String(zonedDateTime.end.hour - 12)) + expect(end.minute).toHaveTextContent(String(zonedDateTime.end.minute)) + expect(getByTestId('start-dayPeriod')).toHaveTextContent('AM') + expect(getByTestId('start-timeZoneName')).toHaveTextContent('EST') + expect(getByTestId('end-dayPeriod')).toHaveTextContent('PM') + expect(getByTestId('end-timeZoneName')).toHaveTextContent('EST') + }) + it('navigates between the fields', async () => { + const { getByTestId, user } = setup({ + timeRangeFieldProps: { modelValue: time, locale: 'en-GB' }, + }) + + const fields = ['start', 'end'] as const + const segments = ['hour', 'minute'] as const + + await user.click(getByTestId('start-hour')) + + for (const field of fields) { + for (const segment of segments) { + if (field === 'start' && segment === 'hour') + continue + const seg = getByTestId(`${field}-${segment}`) + await user.keyboard(kbd.ARROW_RIGHT) + expect(seg).toHaveFocus() + } + } + + await user.click(getByTestId('start-hour')) + + for (const field of fields) { + for (const segment of segments) { + if (field === 'start' && segment === 'hour') + continue + const seg = getByTestId(`${field}-${segment}`) + await user.keyboard(kbd.TAB) + expect(seg).toHaveFocus() + } + } + }) + + it('navigates between the fields - right to left', async () => { + const { getByTestId, user } = setup({ + timeRangeFieldProps: { modelValue: time, locale: 'en-GB' }, + }) + + const fields = ['end', 'start'] as const + const segments = ['minute', 'hour'] as const + + await user.click(getByTestId('end-minute')) + + for (const field of fields) { + for (const segment of segments) { + if (field === 'end' && segment === 'minute') + continue + const seg = getByTestId(`${field}-${segment}`) + await user.keyboard(kbd.ARROW_LEFT) + expect(seg).toHaveFocus() + } + } + + await user.click(getByTestId('end-minute')) + + for (const field of fields) { + for (const segment of segments) { + if (field === 'end' && segment === 'minute') + continue + const seg = getByTestId(`${field}-${segment}`) + await user.keyboard(kbd.SHIFT_TAB) + expect(seg).toHaveFocus() + } + } + }) + + it('binds to the value', async () => { + const { start, end, user } = setup({ + timeRangeFieldProps: { modelValue: time, locale: 'en-GB' }, + }) + expect(start.hour).toHaveTextContent(String(time.start.hour)) + expect(end.hour).toHaveTextContent(String(time.end.hour)) + + await user.click(start.minute) + await user.keyboard('2') + expect(start.minute).toHaveTextContent('2') + expect(end.minute).toHaveTextContent(String(time.end.minute)) + }) + + it('modifying end value does not affect start value', async () => { + const { start, end, user } = setup({ + timeRangeFieldProps: { modelValue: time, locale: 'en-GB' }, + }) + + await user.click(end.hour) + await user.keyboard(kbd.ARROW_UP) + expect(start.hour).toHaveTextContent(String(time.start.hour).padStart(2, '0')) + expect(start.minute).toHaveTextContent(String(time.start.minute)) + }) + + it('increments start hour on arrow up', async () => { + const { start, user } = setup({ + timeRangeFieldProps: { modelValue: time, locale: 'en-GB' }, + }) + + await user.click(start.hour) + await user.keyboard(kbd.ARROW_UP) + expect(start.hour).toHaveTextContent(String(time.start.hour + 1).padStart(2, '0')) + }) + + it('decrements end minute on arrow down', async () => { + const { end, user } = setup({ + timeRangeFieldProps: { modelValue: time, locale: 'en-GB' }, + }) + + await user.click(end.minute) + await user.keyboard(kbd.ARROW_DOWN) + expect(end.minute).toHaveTextContent(String(time.end.minute - 1)) + }) + + it('types a digit into start segment', async () => { + const { start, user } = setup({ + timeRangeFieldProps: { modelValue: time, locale: 'en-GB' }, + }) + + await user.click(start.hour) + await user.keyboard('{1}{4}') + expect(start.hour).toHaveTextContent('14') + }) + + it('types a digit into end segment', async () => { + const { end, user } = setup({ + timeRangeFieldProps: { modelValue: time, locale: 'en-GB' }, + }) + + await user.click(end.minute) + await user.keyboard('{3}{0}') + expect(end.minute).toHaveTextContent('30') + }) + + it('prevents interaction when disabled', async () => { + const { start, end, user } = setup({ + timeRangeFieldProps: { modelValue: time, locale: 'en-GB', disabled: true }, + }) + + const segments = [start.hour, start.minute, end.hour, end.minute] + for (const seg of segments) { + await user.click(seg) + expect(seg).not.toHaveFocus() + } + }) + + it('prevents modification when readonly', async () => { + const { start, end, user } = setup({ + timeRangeFieldProps: { modelValue: time, locale: 'en-GB', readonly: true }, + }) + + await user.click(start.hour) + expect(start.hour).toHaveFocus() + await user.keyboard(kbd.ARROW_UP) + expect(start.hour).toHaveTextContent(String(time.start.hour).padStart(2, '0')) + + await user.click(end.hour) + expect(end.hour).toHaveFocus() + await user.keyboard(kbd.ARROW_UP) + expect(end.hour).toHaveTextContent(String(time.end.hour).padStart(2, '0')) + }) + + it('displays data-invalid when start is after end', async () => { + const invalidTime = { + start: new Time(17, 0), + end: new Time(9, 0), + } + const { input } = setup({ + timeRangeFieldProps: { modelValue: invalidTime, locale: 'en-GB' }, + }) + + expect(input).toHaveAttribute('data-invalid', '') + }) + + it('does not display data-invalid for valid range', async () => { + const { input } = setup({ + timeRangeFieldProps: { modelValue: time, locale: 'en-GB' }, + }) + + expect(input).not.toHaveAttribute('data-invalid') + }) + + it('displays data-invalid when value is outside min/max', async () => { + const { input } = setup({ + timeRangeFieldProps: { + modelValue: time, + locale: 'en-GB', + minValue: new Time(10, 0), + }, + }) + + // start time is 9:15 which is before min of 10:00 + expect(input).toHaveAttribute('data-invalid', '') + }) + + it('focuses first segment on label click', async () => { + const { user, label, start } = setup({ + timeRangeFieldProps: { modelValue: time, locale: 'en-GB' }, + }) + + await user.click(label) + expect(start.hour).toHaveFocus() + }) + + it('renders with second granularity', async () => { + const { getByTestId } = setup({ + timeRangeFieldProps: { + modelValue: time, + locale: 'en-GB', + granularity: 'second', + }, + }) + + expect(getByTestId('start-second')).toHaveTextContent(String(time.start.second)) + expect(getByTestId('end-second')).toHaveTextContent(String(time.end.second).padStart(2, '0')) + }) + + it('renders with hour granularity', async () => { + const returned = render(TimeField, { + props: { + timeRangeFieldProps: { + modelValue: time, + locale: 'en-GB', + granularity: 'hour', + }, + }, + }) + + expect(returned.getByTestId('start-hour')).toBeVisible() + expect(returned.getByTestId('end-hour')).toBeVisible() + expect(returned.queryByTestId('start-minute')).toBeNull() + expect(returned.queryByTestId('end-minute')).toBeNull() + }) + + it('navigates from start to end with keyboard typing', async () => { + const { start, end, user } = setup({ + timeRangeFieldProps: { modelValue: time, locale: 'en-GB' }, + }) + + // Type into start hour (value > 2 auto-advances), then start minute + await user.click(start.hour) + await user.keyboard('{0}{9}') + expect(start.minute).toHaveFocus() + await user.keyboard('{1}{5}') + // After finishing start minute, focus should move to end hour + expect(end.hour).toHaveFocus() + }) +}) diff --git a/packages/core/src/TimeRangeField/TimeRangeFieldInput.vue b/packages/core/src/TimeRangeField/TimeRangeFieldInput.vue new file mode 100644 index 000000000..9484c8fc9 --- /dev/null +++ b/packages/core/src/TimeRangeField/TimeRangeFieldInput.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/packages/core/src/TimeRangeField/TimeRangeFieldRoot.vue b/packages/core/src/TimeRangeField/TimeRangeFieldRoot.vue new file mode 100644 index 000000000..b941e9d17 --- /dev/null +++ b/packages/core/src/TimeRangeField/TimeRangeFieldRoot.vue @@ -0,0 +1,473 @@ + + + + + diff --git a/packages/core/src/TimeRangeField/index.ts b/packages/core/src/TimeRangeField/index.ts new file mode 100644 index 000000000..651e66cb3 --- /dev/null +++ b/packages/core/src/TimeRangeField/index.ts @@ -0,0 +1,2 @@ +export { default as TimeRangeFieldInput, type TimeRangeFieldInputProps } from './TimeRangeFieldInput.vue' +export { injectTimeRangeFieldRootContext, default as TimeRangeFieldRoot, type TimeRangeFieldRootEmits, type TimeRangeFieldRootProps } from './TimeRangeFieldRoot.vue' diff --git a/packages/core/src/TimeRangeField/story/TimeRangeFieldChromatic.story.vue b/packages/core/src/TimeRangeField/story/TimeRangeFieldChromatic.story.vue new file mode 100644 index 000000000..58148472d --- /dev/null +++ b/packages/core/src/TimeRangeField/story/TimeRangeFieldChromatic.story.vue @@ -0,0 +1,57 @@ + + + diff --git a/packages/core/src/TimeRangeField/story/TimeRangeFieldDefault.story.vue b/packages/core/src/TimeRangeField/story/TimeRangeFieldDefault.story.vue new file mode 100644 index 000000000..6de99308d --- /dev/null +++ b/packages/core/src/TimeRangeField/story/TimeRangeFieldDefault.story.vue @@ -0,0 +1,57 @@ + + + diff --git a/packages/core/src/TimeRangeField/story/TimeRangeFieldGranular.story.vue b/packages/core/src/TimeRangeField/story/TimeRangeFieldGranular.story.vue new file mode 100644 index 000000000..320a99351 --- /dev/null +++ b/packages/core/src/TimeRangeField/story/TimeRangeFieldGranular.story.vue @@ -0,0 +1,34 @@ + + + diff --git a/packages/core/src/TimeRangeField/story/TimeRangeFieldValidation.story.vue b/packages/core/src/TimeRangeField/story/TimeRangeFieldValidation.story.vue new file mode 100644 index 000000000..3f19a9a5e --- /dev/null +++ b/packages/core/src/TimeRangeField/story/TimeRangeFieldValidation.story.vue @@ -0,0 +1,41 @@ + + + diff --git a/packages/core/src/TimeRangeField/story/_DummyTimeRangeField.vue b/packages/core/src/TimeRangeField/story/_DummyTimeRangeField.vue new file mode 100644 index 000000000..d2d5ae705 --- /dev/null +++ b/packages/core/src/TimeRangeField/story/_DummyTimeRangeField.vue @@ -0,0 +1,59 @@ + + + diff --git a/packages/core/src/TimeRangeField/story/_TimeRangeField.vue b/packages/core/src/TimeRangeField/story/_TimeRangeField.vue new file mode 100644 index 000000000..c700d49e3 --- /dev/null +++ b/packages/core/src/TimeRangeField/story/_TimeRangeField.vue @@ -0,0 +1,47 @@ + + + diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8772cc5d2..0c3948d29 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -105,6 +105,7 @@ export * from './Switch' export * from './Tabs' export * from './TagsInput' export * from './TimeField' +export * from './TimeRangeField' export * from './Toast' export * from './Toggle' diff --git a/packages/core/src/shared/date/types.ts b/packages/core/src/shared/date/types.ts index 285b5266f..964e7aa40 100644 --- a/packages/core/src/shared/date/types.ts +++ b/packages/core/src/shared/date/types.ts @@ -5,6 +5,7 @@ import type { DateValue } from '@internationalized/date' export type { DateValue } +import type { TimeValue } from './comparators' import type { DATE_SEGMENT_PARTS, EDITABLE_SEGMENT_PARTS, NON_EDITABLE_SEGMENT_PARTS, TIME_SEGMENT_PARTS } from './parts' // Days of the week, starting with Sunday @@ -28,6 +29,11 @@ export type DateRange = { end: DateValue | undefined } +export type TimeRange = { + start: TimeValue | undefined + end: TimeValue | undefined +} + export type HourCycle = 12 | 24 | undefined export type DateSegmentPart = (typeof DATE_SEGMENT_PARTS)[number] diff --git a/time-range-field.md b/time-range-field.md new file mode 100644 index 000000000..ebb6d343d --- /dev/null +++ b/time-range-field.md @@ -0,0 +1,620 @@ +--- +title: Time Range Field +description: Allows users to input a range of times within a designated field. +name: time-range-field +--- + +# Time Range Field +Alpha + + +Allows users to input a range of times within a designated field. + + + + +## Features + + +## Preface + +The Time Range Field component relies on the `@internationalized/date` package for date and time manipulation. This package provides a robust, locale-aware way to handle dates and times across different cultures and time zones. + +[Learn more about @internationalized/date](https://internationalized.date/) + +## Installation + +Install the date package with + +Install the component with + +## Anatomy + +Import all parts and piece them together. + +```vue + + + +``` + +## API Reference + +### Root + +Contains all the parts of a time range field. + + + + + +### Input + +Contains the time field segments. + + + + + +## Accessibility + +The Time Range Field component follows the ARIA design pattern for time inputs and includes: + +- Proper ARIA attributes for time inputs +- Screen reader announcements for time values +- Full keyboard navigation support +- Focus management between segments +- Visual indicators for invalid states + +Each segment is properly labeled and associated with the time range field as a group, ensuring screen reader users understand the relationship between the segments. + +## Keyboard Interactions + + + +## Examples + +### Basic Usage + +```vue + + + +``` + +### Controlled Component + +```vue + + + +``` + +### With Validation + +```vue + + + +``` + +### With Custom Granularity + +```vue + + + +``` + +### With Locale and Hour Cycle + +```vue + + +