diff --git a/docs/app/components/content/ComponentCode.vue b/docs/app/components/content/ComponentCode.vue index 6dca699f58..5f018fe213 100644 --- a/docs/app/components/content/ComponentCode.vue +++ b/docs/app/components/content/ComponentCode.vue @@ -4,7 +4,7 @@ import type { ChipProps } from '@nuxt/ui' import json5 from 'json5' import { upperFirst, camelCase, kebabCase } from 'scule' import { hash } from 'ohash' -import { CalendarDate } from '@internationalized/date' +import { CalendarDate, Time } from '@internationalized/date' import * as theme from '#build/ui' import { get, set } from '#ui/utils' @@ -14,6 +14,7 @@ interface Cast { } type CastDateValue = [number, number, number] +type CastTimeValue = [number, number, number] const castMap: Record = { 'DateValue': { @@ -37,6 +38,12 @@ const castMap: Record = { return `{ start: new CalendarDate(${value.start.year}, ${value.start.month}, ${value.start.day}), end: new CalendarDate(${value.end.year}, ${value.end.month}, ${value.end.day}) }` } + }, + 'TimeValue': { + get: (args: CastTimeValue) => new Time(...args), + template: (value: Time) => { + return value ? `new Time(${value.hour}, ${value.minute}, ${value.second})` : 'null' + } } } diff --git a/docs/app/components/content/examples/input-time/InputTimeFormFieldExample.vue b/docs/app/components/content/examples/input-time/InputTimeFormFieldExample.vue new file mode 100644 index 0000000000..d5a607e155 --- /dev/null +++ b/docs/app/components/content/examples/input-time/InputTimeFormFieldExample.vue @@ -0,0 +1,11 @@ + + + diff --git a/docs/content/docs/2.components/input-time.md b/docs/content/docs/2.components/input-time.md new file mode 100644 index 0000000000..0828d3c0e9 --- /dev/null +++ b/docs/content/docs/2.components/input-time.md @@ -0,0 +1,181 @@ +--- +title: InputTime +description: 'An input for selecting a time.' +category: form +links: + - label: TimeField + icon: i-custom-reka-ui + to: https://reka-ui.com/docs/components/time-field + - label: GitHub + icon: i-simple-icons-github + to: https://github.com/nuxt/ui/blob/v4/src/runtime/components/InputTime.vue +navigation.badge: Soon +--- + +## Usage + +Use the `v-model` directive to control the selected date. + +::component-code +--- +cast: + modelValue: TimeValue +ignore: + - modelValue +external: + - modelValue +props: + modelValue: [12, 30, 0] +--- +:: + +Use the `default-value` prop to set the initial value when you do not need to control its state. + +::component-code +--- +cast: + defaultValue: TimeValue +ignore: + - defaultValue +external: + - defaultValue +props: + defaultValue: [12, 30, 0] +--- +:: + +::note +This component relies on the [`@internationalized/date`](https://react-spectrum.adobe.com/internationalized/date/index.html) package which provides objects and functions for representing and manipulating dates and times in a locale-aware manner. +:: + +### Hour Cycle + +Use the `hour-cycle` prop to change the hour cycle of the InputTime. Defaults to `12`. + +::component-code +--- +cast: + defaultValue: TimeValue +ignore: + - hourCycle + - defaultValue +external: + - defaultValue +props: + hourCycle: 24 + defaultValue: [16, 30, 0] +--- +:: + +### Color + +Use the `color` prop to change the color of the InputTime. + +::component-code +--- +props: + color: neutral + highlight: true +--- +:: + +::note +The `highlight` prop is used here to show the focus state. It's used internally when a validation error occurs. +:: + +### Variant + +Use the `variant` prop to change the variant of the InputTime. + +::component-code +--- +props: + variant: subtle +--- +:: + +### Size + +Use the `size` prop to change the size of the InputTime. + +::component-code +--- +props: + size: xl +--- +:: + +### Icon + +Use the `icon` prop to show an [Icon](/docs/components/icon) inside the InputTime. + +::component-code +--- +props: + icon: 'i-lucide-clock' +--- +:: + +::note +Use the `leading` and `trailing` props to set the icon position or the `leading-icon` and `trailing-icon` props to set a different icon for each position. +:: + +### Avatar + +Use the `avatar` prop to show an [Avatar](/docs/components/avatar) inside the InputTime. + +::component-code +--- +prettier: true +props: + avatar: + src: 'https://github.com/vuejs.png' + size: md + variant: outline +--- +:: + +### Disabled + +Use the `disabled` prop to disable the InputTime. + +::component-code +--- +props: + disabled: true +--- +:: + +## Examples + +### Within a FormField + +You can use the InputTime within a [FormField](/docs/components/form-field) component to display a label, help text, required indicator, etc. + +::component-example +--- +name: 'input-time-form-field-example' +--- +:: + +## API + +### Props + +:component-props + +### Slots + +:component-slots + +### Emits + +:component-emits + +## Theme + +:component-theme + +## Changelog + +:component-changelog diff --git a/docs/public/components/dark/input-time.png b/docs/public/components/dark/input-time.png new file mode 100644 index 0000000000..383e736afb Binary files /dev/null and b/docs/public/components/dark/input-time.png differ diff --git a/docs/public/components/light/input-time.png b/docs/public/components/light/input-time.png new file mode 100644 index 0000000000..cb35f1f578 Binary files /dev/null and b/docs/public/components/light/input-time.png differ diff --git a/playgrounds/nuxt/app/composables/useNavigation.ts b/playgrounds/nuxt/app/composables/useNavigation.ts index 6248b38e9c..462fb32e27 100644 --- a/playgrounds/nuxt/app/composables/useNavigation.ts +++ b/playgrounds/nuxt/app/composables/useNavigation.ts @@ -38,6 +38,7 @@ const components = [ 'input-menu', 'input-number', 'input-tags', + 'input-time', 'input', 'kbd', 'link', diff --git a/playgrounds/nuxt/app/pages/components/input-time.vue b/playgrounds/nuxt/app/pages/components/input-time.vue new file mode 100644 index 0000000000..dc97343457 --- /dev/null +++ b/playgrounds/nuxt/app/pages/components/input-time.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/runtime/components/InputTime.vue b/src/runtime/components/InputTime.vue new file mode 100644 index 0000000000..09bdc40060 --- /dev/null +++ b/src/runtime/components/InputTime.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/src/runtime/types/index.ts b/src/runtime/types/index.ts index 015f48fc9e..091f07bd05 100644 --- a/src/runtime/types/index.ts +++ b/src/runtime/types/index.ts @@ -54,6 +54,7 @@ export * from '../components/Input.vue' export * from '../components/InputMenu.vue' export * from '../components/InputNumber.vue' export * from '../components/InputTags.vue' +export * from '../components/InputTime.vue' export * from '../components/Kbd.vue' export * from '../components/Link.vue' export * from '../components/Main.vue' diff --git a/src/theme/index.ts b/src/theme/index.ts index 2d40b33607..ff8825701b 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -52,6 +52,7 @@ export { default as input } from './input' export { default as inputMenu } from './input-menu' export { default as inputNumber } from './input-number' export { default as inputTags } from './input-tags' +export { default as inputTime } from './input-time' export { default as kbd } from './kbd' export { default as link } from './link' export { default as main } from './main' diff --git a/src/theme/input-time.ts b/src/theme/input-time.ts new file mode 100644 index 0000000000..70974200ce --- /dev/null +++ b/src/theme/input-time.ts @@ -0,0 +1,63 @@ +import { defuFn } from 'defu' +import type { ModuleOptions } from '../module' +import input from './input' + +export default (options: Required) => { + return defuFn({ + slots: { + root: () => undefined, + base: () => ['group relative inline-flex items-center rounded-md select-none', options.theme.transitions && 'transition-colors'], + segment: ['rounded text-center outline-hidden data-placeholder:text-dimmed data-[segment=literal]:text-muted data-invalid:text-error data-disabled:cursor-not-allowed data-disabled:opacity-75', options.theme.transitions && 'transition-colors'] + }, + variants: { + size: { + xs: { + base: (prev: string) => [prev, 'gap-0.25'], + segment: 'not-data-[segment=literal]:w-6' + }, + sm: { + base: (prev: string) => [prev, 'gap-0.5'], + segment: 'not-data-[segment=literal]:w-6' + }, + md: { + base: (prev: string) => [prev, 'gap-0.5'], + segment: 'not-data-[segment=literal]:w-7' + }, + lg: { + base: (prev: string) => [prev, 'gap-0.75'], + segment: 'not-data-[segment=literal]:w-7' + }, + xl: { + base: (prev: string) => [prev, 'gap-0.75'], + segment: 'not-data-[segment=literal]:w-8' + } + } + }, + compoundVariants: [{ + variant: 'outline', + class: { + segment: 'focus:bg-elevated' + } + }, { + variant: 'soft', + class: { + segment: 'focus:bg-accented/50 group-hover:focus:bg-accented' + } + }, { + variant: 'subtle', + class: { + segment: 'focus:bg-accented' + } + }, { + variant: 'ghost', + class: { + segment: 'focus:bg-elevated group-hover:focus:bg-accented' + } + }, { + variant: 'none', + class: { + segment: 'focus:bg-elevated' + } + }] + }, input(options)) +} diff --git a/test/components/InputTime.spec.ts b/test/components/InputTime.spec.ts new file mode 100644 index 0000000000..78ce1ae8fa --- /dev/null +++ b/test/components/InputTime.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, afterAll, test } from 'vitest' +import { axe } from 'vitest-axe' +import { mountSuspended } from '@nuxt/test-utils/runtime' +import InputTime from '../../src/runtime/components/InputTime.vue' +import type { InputTimeProps, InputTimeSlots } from '../../src/runtime/components/InputTime.vue' +import ComponentRender from '../component-render' +import theme from '#build/ui/input-time' +import { Time } from '@internationalized/date' + +describe('InputTime', () => { + const sizes = Object.keys(theme.variants.size) as any + const variants = Object.keys(theme.variants.variant) as any + const date = new Date('2025-01-01') + + vi.setSystemTime(date) + + afterAll(() => { + vi.useRealTimers() + }) + + it.each([ + // Props + ['with modelValue', { props: { modelValue: new Time(12, 30) } }], + ['with default value', { props: { defaultValue: new Time(12, 30) } }], + ['with placeholder', { props: { placeholder: new Time(12, 30) } }], + ['with disabled', { props: { disabled: true, modelValue: new Time(12, 30) } }], + ['with required', { props: { required: true, modelValue: new Time(12, 30) } }], + ['with readonly', { props: { readonly: true, modelValue: new Time(12, 30) } }], + ['with hour cycle 24', { props: { hourCycle: 24 as const } }], + ['with hour cycle 12', { props: { hourCycle: 12 as const } }], + ['with granularity', { props: { granularity: 'minute' as const } }], + ['with hide time zone', { props: { hideTimeZone: true } }], + ['with max value', { props: { maxValue: new Time(12, 30) } }], + ['with min value', { props: { minValue: new Time(12, 30) } }], + ['with icon', { props: { icon: 'i-lucide-clock' } }], + ['with leadingIcon', { props: { leadingIcon: 'i-lucide-arrow-left' } }], + ['with trailingIcon', { props: { trailingIcon: 'i-lucide-arrow-right' } }], + ...sizes.map((size: string) => [`with size ${size}`, { props: { size } }]), + ...variants.map((variant: string) => [`with primary variant ${variant}`, { props: { variant, defaultValue: new Time(12, 30) } }]), + ...variants.map((variant: string) => [`with neutral variant ${variant}`, { props: { variant, color: 'neutral', defaultValue: new Time(12, 30) } }]), + ['with ariaLabel', { attrs: { 'aria-label': 'Aria label' } }], + ['with as', { props: { as: 'section' } }], + ['with class', { props: { class: 'max-w-sm' } }], + ['with ui', { props: { ui: { base: 'rounded-full' } } }], + // Slots + ['with default slot', { slots: { default: () => 'Default slot' } }] + ])('renders %s correctly', async (nameOrHtml: string, options: { props?: InputTimeProps, slots?: Partial }) => { + const html = await ComponentRender(nameOrHtml, options, InputTime) + expect(html).toMatchSnapshot() + }) + + describe('emits', () => { + test('update:modelValue event', async () => { + const wrapper = await mountSuspended(InputTime) + const time = new Time(12, 30) + + await wrapper.setValue(time) + expect(wrapper.emitted()).toMatchObject({ 'update:modelValue': [[time]] }) + }) + }) + + it('passes accessibility tests', async () => { + const wrapper = await mountSuspended(InputTime, { + props: { + modelValue: new Time(12, 30) + } + }) + + expect(await axe(wrapper.element)).toHaveNoViolations() + }) +}) diff --git a/test/components/__snapshots__/InputTime-vue.spec.ts.snap b/test/components/__snapshots__/InputTime-vue.spec.ts.snap new file mode 100644 index 0000000000..4a6818b6b4 --- /dev/null +++ b/test/components/__snapshots__/InputTime-vue.spec.ts.snap @@ -0,0 +1,451 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`InputTime > renders with ariaLabel correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with as correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with class correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with default slot correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ Default slot + + +
" +`; + +exports[`InputTime > renders with default value correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with disabled correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with granularity correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with hide time zone correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with hour cycle 12 correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with hour cycle 24 correctly 1`] = ` +"
+
––
+ +
––
+ + + +
" +`; + +exports[`InputTime > renders with icon correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + +
" +`; + +exports[`InputTime > renders with leadingIcon correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + +
" +`; + +exports[`InputTime > renders with max value correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with min value correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with modelValue correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with neutral variant ghost correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with neutral variant none correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with neutral variant outline correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with neutral variant soft correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with neutral variant subtle correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with placeholder correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with primary variant ghost correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with primary variant none correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with primary variant outline correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with primary variant soft correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with primary variant subtle correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with readonly correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with required correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with size lg correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with size md correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with size sm correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with size xl correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with size xs correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with trailingIcon correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + +
" +`; + +exports[`InputTime > renders with ui correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; diff --git a/test/components/__snapshots__/InputTime.spec.ts.snap b/test/components/__snapshots__/InputTime.spec.ts.snap new file mode 100644 index 0000000000..db7d505ee6 --- /dev/null +++ b/test/components/__snapshots__/InputTime.spec.ts.snap @@ -0,0 +1,451 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`InputTime > renders with ariaLabel correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with as correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with class correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with default slot correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ Default slot + + +
" +`; + +exports[`InputTime > renders with default value correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with disabled correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with granularity correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with hide time zone correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with hour cycle 12 correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with hour cycle 24 correctly 1`] = ` +"
+
––
+ +
––
+ + + +
" +`; + +exports[`InputTime > renders with icon correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + +
" +`; + +exports[`InputTime > renders with leadingIcon correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + +
" +`; + +exports[`InputTime > renders with max value correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with min value correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with modelValue correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with neutral variant ghost correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with neutral variant none correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with neutral variant outline correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with neutral variant soft correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with neutral variant subtle correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with placeholder correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with primary variant ghost correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with primary variant none correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with primary variant outline correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with primary variant soft correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with primary variant subtle correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with readonly correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with required correctly 1`] = ` +"
+
12
+ +
30
+ +
PM
+ + + +
" +`; + +exports[`InputTime > renders with size lg correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with size md correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with size sm correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with size xl correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with size xs correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`; + +exports[`InputTime > renders with trailingIcon correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + +
" +`; + +exports[`InputTime > renders with ui correctly 1`] = ` +"
+
––
+ +
––
+ +
AM
+ + + +
" +`;