diff --git a/docs/app/components/content/examples/form/FormExampleElements.vue b/docs/app/components/content/examples/form/FormExampleElements.vue index 0722a7e14c..9ca6264921 100644 --- a/docs/app/components/content/examples/form/FormExampleElements.vue +++ b/docs/app/components/content/examples/form/FormExampleElements.vue @@ -11,6 +11,7 @@ const schema = z.object({ inputMenuMultiple: z.any().refine(values => !!values?.find((option: any) => option.value === 'option-2'), { message: 'Include Option 2' }), + inputTime: z.string().min(10), textarea: z.string().min(10), select: z.string().refine(value => value === 'option-2', { message: 'Select Option 2' @@ -108,6 +109,10 @@ async function onSubmit(event: FormSubmitEvent) { + + + + diff --git a/playground-vue/src/app.vue b/playground-vue/src/app.vue index 62f0573b83..a565ba6808 100644 --- a/playground-vue/src/app.vue +++ b/playground-vue/src/app.vue @@ -40,6 +40,7 @@ const components = [ 'input', 'input-menu', 'input-number', + 'input-time', 'kbd', 'link', 'modal', diff --git a/playground/app/app.vue b/playground/app/app.vue index d547b989ed..521cef0ddf 100644 --- a/playground/app/app.vue +++ b/playground/app/app.vue @@ -40,6 +40,7 @@ const components = [ 'input', 'input-menu', 'input-number', + 'input-time', 'kbd', 'link', 'modal', diff --git a/playground/app/pages/components/input-time.vue b/playground/app/pages/components/input-time.vue new file mode 100644 index 0000000000..776559fc4d --- /dev/null +++ b/playground/app/pages/components/input-time.vue @@ -0,0 +1,80 @@ + + + diff --git a/src/runtime/components/InputNumber.vue b/src/runtime/components/InputNumber.vue index e212122d06..a99a685257 100644 --- a/src/runtime/components/InputNumber.vue +++ b/src/runtime/components/InputNumber.vue @@ -1,5 +1,5 @@ + + + + diff --git a/src/runtime/types/index.ts b/src/runtime/types/index.ts index f2a9d6841e..815d815011 100644 --- a/src/runtime/types/index.ts +++ b/src/runtime/types/index.ts @@ -26,6 +26,7 @@ export * from '../components/Icon.vue' export * from '../components/Input.vue' export * from '../components/InputMenu.vue' export * from '../components/InputNumber.vue' +export * from '../components/InputTime.vue' export * from '../components/Kbd.vue' export * from '../components/Link.vue' export * from '../components/Modal.vue' diff --git a/src/theme/index.ts b/src/theme/index.ts index c52531309d..b6a2193916 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -24,6 +24,7 @@ export { default as formField } from './form-field' export { default as input } from './input' export { default as inputMenu } from './input-menu' export { default as inputNumber } from './input-number' +export { default as inputTime } from './input-time' export { default as kbd } from './kbd' export { default as link } from './link' export { default as modal } from './modal' diff --git a/src/theme/input-time.ts b/src/theme/input-time.ts new file mode 100644 index 0000000000..bc75918066 --- /dev/null +++ b/src/theme/input-time.ts @@ -0,0 +1,38 @@ +import { defuFn } from 'defu' +import type { ModuleOptions } from '../module' +import input from './input' + +export default (options: Required) => { + return defuFn({ + slots: { + base: () => ['w-full select-none relative group rounded-md inline-flex items-center focus:outline-none !gap-0', options.theme.transitions && 'transition-colors'], + segment: 'focus:bg-muted data-invalid:data-focused:bg-error data-focused:data-placeholder:text-muted data-focused:text-highlighted data-invalid:data-placeholder:text-error data-invalid:text-error data-placeholder:text-muted data-[segment=literal]:text-muted rounded px-1 data-[segment=literal]:px-0 outline-hidden data-disabled:cursor-not-allowed data-disabled:opacity-75 data-invalid:data-focused:text-white data-invalid:data-focused:data-placeholder:text-white' + }, + variants: { + variant: { + outline: 'text-highlighted bg-default ring ring-inset ring-accented', + soft: 'text-highlighted bg-elevated/50 hover:bg-elevated focus:bg-elevated disabled:bg-elevated/50', + subtle: 'text-highlighted bg-elevated ring ring-inset ring-accented', + ghost: 'text-highlighted bg-transparent hover:bg-elevated focus:bg-elevated disabled:bg-transparent dark:disabled:bg-transparent', + none: 'text-highlighted bg-transparent' + } + }, + compoundVariants: [...(options.theme.colors || []).map((color: string) => ({ + color, + variant: ['outline', 'subtle'], + class: `focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-${color}` + })), ...(options.theme.colors || []).map((color: string) => ({ + color, + highlight: true, + class: `ring ring-inset ring-${color}` + })), { + color: 'neutral', + variant: ['outline', 'subtle'], + class: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-inverted' + }, { + color: 'neutral', + highlight: true, + class: 'ring ring-inset ring-inverted' + }] + }, input(options)) +} diff --git a/test/components/FormField.spec.ts b/test/components/FormField.spec.ts index c15ac6e379..b352b233c9 100644 --- a/test/components/FormField.spec.ts +++ b/test/components/FormField.spec.ts @@ -14,14 +14,14 @@ import { USelectMenu, UInputMenu, UInputNumber, + UInputTime, USwitch, USlider, UPinInput, UFormField - } from '#components' -const inputComponents = [UInput, URadioGroup, UTextarea, UCheckbox, USelect, USelectMenu, UInputMenu, UInputNumber, USwitch, USlider, UPinInput] +const inputComponents = [UInput, URadioGroup, UTextarea, UCheckbox, USelect, USelectMenu, UInputMenu, UInputNumber, UInputTime, USwitch, USlider, UPinInput] async function renderFormField(options: { props: Partial diff --git a/test/components/InputTime.spec.ts b/test/components/InputTime.spec.ts new file mode 100644 index 0000000000..a17591bceb --- /dev/null +++ b/test/components/InputTime.spec.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import { Time } from '@internationalized/date' +import InputTime, { type InputTimeProps, type InputTimeSlots } from '../../src/runtime/components/InputTime.vue' +import ComponentRender from '../component-render' +import theme from '#build/ui/input-time' + +describe('InputTime', () => { + const sizes = Object.keys(theme.variants.size) as any + const variants = Object.keys(theme.variants.variant) as any + const defaultTime = new Time(10, 30) + + it.each([ + // Props + ['with id', { props: { id: 'id' } }], + ['with name', { props: { name: 'name' } }], + ['with placeholder', { props: { placeholder: 'Select time...' } }], + ['with disabled', { props: { disabled: true } }], + ['with required', { props: { required: true } }], + ['with readonly', { props: { readonly: true } }], + ['with icon', { props: { icon: 'i-lucide-clock' } }], + ['with leading and icon', { props: { leading: true, icon: 'i-lucide-clock' } }], + ['with leadingIcon', { props: { leadingIcon: 'i-lucide-clock' } }], + ['with trailing and icon', { props: { trailing: true, icon: 'i-lucide-chevron-down' } }], + ['with trailingIcon', { props: { trailingIcon: 'i-lucide-chevron-down' } }], + ['with loading', { props: { loading: true } }], + ['with loading trailing', { props: { loading: true, trailing: true } }], + ['with loadingIcon', { props: { loading: true, loadingIcon: 'i-lucide-loader' } }], + ['with granularity hour', { props: { granularity: 'hour' } }], + ['with granularity minute', { props: { granularity: 'minute' } }], + ['with granularity second', { props: { granularity: 'second' } }], + ['with 12 hour cycle', { props: { hourCycle: 12 } }], + ['with 24 hour cycle', { props: { hourCycle: 24 } }], + ['with hideTimeZone', { props: { hideTimeZone: true } }], + ['with locale', { props: { locale: 'fr-FR' } }], + ['with minValue', { props: { minValue: new Time(8, 0) } }], + ['with maxValue', { props: { maxValue: new Time(17, 0) } }], + ...sizes.map((size: string) => [`with size ${size}`, { props: { size } }]), + ...variants.map((variant: string) => [`with primary variant ${variant}`, { props: { variant } }]), + ...variants.map((variant: string) => [`with neutral variant ${variant}`, { props: { variant, color: 'neutral' } }]), + ['with ariaLabel', { attrs: { 'aria-label': 'Time selector' } }], + ['with as', { props: { as: 'section' } }], + ['with class', { props: { class: 'absolute' } }], + ['with ui', { props: { ui: { base: 'rounded-full' } } }], + // Slots + ['with default slot', { slots: { default: () => 'Default slot' } }], + ['with leading slot', { slots: { leading: () => 'Leading slot' } }], + ['with trailing slot', { slots: { trailing: () => 'Trailing 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 = mount(InputTime, { + props: { + modelValue: defaultTime + } + }) + + // Find the InputTimeRoot component and trigger update:model-value event + const InputTimeRoot = wrapper.findComponent({ name: 'InputTimeRoot' }) + const newTime = new Time(14, 45) + + await InputTimeRoot.vm.$emit('update:model-value', newTime) + + expect(wrapper.emitted()).toHaveProperty('update:modelValue') + expect(wrapper.emitted()['update:modelValue'][0]).toEqual([newTime]) + }) + + test('blur event', async () => { + const wrapper = mount(InputTime) + const InputTimeRoot = wrapper.findComponent({ name: 'InputTimeRoot' }) + + await InputTimeRoot.vm.$emit('blur', { type: 'blur' }) + + expect(wrapper.emitted()).toHaveProperty('blur') + expect(wrapper.emitted().blur[0]).toEqual([{ type: 'blur' }]) + }) + + test('update:placeholder event', async () => { + const wrapper = mount(InputTime) + const InputTimeRoot = wrapper.findComponent({ name: 'InputTimeRoot' }) + const placeholder = new Time(12, 0) + + await InputTimeRoot.vm.$emit('update:placeholder', placeholder) + + expect(wrapper.emitted()).toHaveProperty('update:placeholder') + expect(wrapper.emitted()['update:placeholder'][0]).toEqual([placeholder]) + }) + }) +})