Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -108,6 +109,10 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
<UInputNumber v-model="state.inputNumber" class="w-full" />
</UFormField>

<UFormField name="inputTime" label="Input Time">
<UInputTime v-model="state.inputTime" class="w-full" />
</UFormField>

<UFormField label="Textarea" name="textarea">
<UTextarea v-model="state.textarea" class="w-full" />
</UFormField>
Expand Down
1 change: 1 addition & 0 deletions playground-vue/src/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const components = [
'input-menu',
'input-number',
'input-tags',
'input-time',
'kbd',
'link',
'modal',
Expand Down
1 change: 1 addition & 0 deletions playground/app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const components = [
'input-menu',
'input-number',
'input-tags',
'input-time',
'kbd',
'link',
'modal',
Expand Down
80 changes: 80 additions & 0 deletions playground/app/pages/components/input-time.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { Time } from '@internationalized/date'
import theme from '#build/ui/input'

const sizes = Object.keys(theme.variants.size) as Array<keyof typeof theme.variants.size>
const variants = Object.keys(theme.variants.variant) as Array<keyof typeof theme.variants.variant>

// Default time values
const defaultTime = ref(new Time(10, 30))
const hourOnlyTime = ref(new Time(14, 0))
const secondsTime = ref(new Time(9, 45, 30))
const cycle24Time = ref(new Time(16, 30))
</script>

<template>
<div class="flex flex-col items-center gap-4">
<div class="flex flex-col gap-4 w-48">
<UInputTime v-model="defaultTime" autofocus />
</div>

<div class="flex items-center gap-2 flex-wrap justify-center">
<UInputTime
v-for="variant in variants"
:key="variant"
v-model="defaultTime"
:variant="variant"
class="w-48"
/>
</div>

<div class="flex items-center gap-2 flex-wrap justify-center">
<UInputTime
v-for="variant in variants"
:key="variant"
v-model="defaultTime"
:variant="variant"
color="error"
highlight
class="w-48"
/>
</div>

<div class="flex flex-col gap-4 w-48">
<UInputTime v-model="defaultTime" disabled />
<UInputTime v-model="defaultTime" required />
<UInputTime v-model="defaultTime" readonly />
<UInputTime v-model="hourOnlyTime" granularity="hour" />
<UInputTime v-model="secondsTime" granularity="second" />
<UInputTime v-model="cycle24Time" :hour-cycle="24" />
<UInputTime v-model="defaultTime" loading />
<UInputTime v-model="defaultTime" loading trailing />
<UInputTime v-model="defaultTime" icon="i-lucide-clock" trailing-icon="i-lucide-chevron-down" />
</div>

<div class="flex flex-wrap gap-4 justify-center">
<UInputTime v-for="size in sizes" :key="size" v-model="defaultTime" :size="size" class="w-48" />
</div>
<div class="flex flex-wrap gap-4 justify-center">
<UInputTime
v-for="size in sizes"
:key="size"
v-model="defaultTime"
icon="i-lucide-clock"
:size="size"
class="w-48"
/>
</div>
<div class="flex flex-wrap gap-4 justify-center">
<UInputTime
v-for="size in sizes"
:key="size"
v-model="defaultTime"
icon="i-lucide-clock"
trailing
:size="size"
class="w-48"
/>
</div>
</div>
</template>
12 changes: 6 additions & 6 deletions src/runtime/components/InputNumber.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import type { NumberFieldRootProps } from 'reka-ui'
import type { NumberFieldRootProps, NumberFieldRootEmits } from 'reka-ui'
import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/input-number'
import type { ButtonProps } from '../types'
Expand Down Expand Up @@ -62,10 +62,9 @@ export interface InputNumberProps extends Pick<NumberFieldRootProps, 'modelValue
ui?: InputNumber['slots']
}

export interface InputNumberEmits {
(e: 'update:modelValue', payload: number): void
(e: 'blur', event: FocusEvent): void
(e: 'change', payload: Event): void
export interface InputNumberEmits extends NumberFieldRootEmits {
blur: [event: FocusEvent]
change: [event: Event]
}

export interface InputNumberSlots {
Expand All @@ -90,7 +89,8 @@ defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<InputNumberProps>(), {
orientation: 'horizontal',
disabledIncrement: false,
disabledDecrement: false
disabledDecrement: false,
autofocusDelay: 0
})
const emits = defineEmits<InputNumberEmits>()
defineSlots<InputNumberSlots>()
Expand Down
174 changes: 174 additions & 0 deletions src/runtime/components/InputTime.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<script lang="ts">
import type { TimeFieldRootProps, TimeFieldRootEmits } from 'reka-ui'
import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/input-time'
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import type { ComponentConfig } from '../types/utils'

type InputTime = ComponentConfig<typeof theme, AppConfig, 'inputTime'>

export interface InputTimeProps extends Pick<TimeFieldRootProps, 'defaultValue' | 'defaultPlaceholder' | 'placeholder' | 'modelValue' | 'hourCycle' | 'step' | 'granularity' | 'hideTimeZone' | 'minValue' | 'maxValue' | 'disabled' | 'readonly' | 'required' | 'id' | 'name' | 'required'>, UseComponentIconsProps {
/**
* The element or component this component should render as.
* @defaultValue 'div'
*/
as?: any
/**
* @defaultValue 'primary'
*/
color?: InputTime['variants']['color']
/**
* @defaultValue 'outline'
*/
variant?: InputTime['variants']['variant']
/**
* @defaultValue 'md'
*/
size?: InputTime['variants']['size']
/** Highlight the ring color like a focus state. */
highlight?: boolean
autofocus?: boolean
autofocusDelay?: number
/**
* The locale to use for formatting and parsing numbers.
* @defaultValue UApp.locale.code
*/
locale?: string
class?: any
ui?: InputTime['slots']
}

export interface InputTimeEmits extends TimeFieldRootEmits {
blur: [event: FocusEvent]
change: [event: Event]
}

export interface InputTimeSlots {
leading(props?: {}): any
default(props?: {}): any
trailing(props?: {}): any
}
</script>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Primitive, TimeFieldRoot, TimeFieldInput, useForwardPropsEmits } from 'reka-ui'
import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { useButtonGroup } from '../composables/useButtonGroup'
import { useComponentIcons } from '../composables/useComponentIcons'
import { useFormField } from '../composables/useFormField'
import { useLocale } from '../composables/useLocale'
import { tv } from '../utils/tv'
import UIcon from './Icon.vue'

defineOptions({ inheritAttrs: false })

const props = withDefaults(defineProps<InputTimeProps>(), {
autofocusDelay: 0
})
const emits = defineEmits<InputTimeEmits>()
const slots = defineSlots<InputTimeSlots>()

const { code: codeLocale } = useLocale()
const appConfig = useAppConfig() as InputTime['AppConfig']

const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'hourCycle', 'step', 'granularity', 'hideTimeZone', 'readonly', 'required'), emits)

const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color, size: formGroupSize, name, highlight, disabled, ariaAttrs } = useFormField<InputTimeProps>(props)
const { orientation, size: buttonGroupSize } = useButtonGroup<InputTimeProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)

const locale = computed(() => props.locale || codeLocale.value)
const inputSize = computed(() => buttonGroupSize.value || formGroupSize.value)

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.inputTime || {}) })({
color: color.value,
variant: props.variant,
size: inputSize?.value,
loading: props.loading,
highlight: highlight.value,
leading: isLeading.value || !!slots.leading,
trailing: isTrailing.value || !!slots.trailing,
buttonGroup: orientation.value
}))

const inputRef = ref<InstanceType<typeof TimeFieldInput> | null>(null)

function onUpdate(value: any) {
// @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value } })
emits('change', event)

emitFormChange()
emitFormInput()
}

function onBlur(event: FocusEvent) {
emitFormBlur()
emits('blur', event)
}

function autoFocus() {
if (props.autofocus) {
inputRef.value?.$el?.focus()
}
}

onMounted(() => {
setTimeout(() => {
autoFocus()
}, props.autofocusDelay)
})

defineExpose({
inputRef
})
</script>

<template>
<Primitive :as="as" :class="ui.root({ class: [props.class, props.ui?.root] })">
<TimeFieldRoot
v-bind="{ ...rootProps, ...$attrs, ...ariaAttrs }"
:id="id"
ref="inputRef"
v-slot="{ segments }"
:name="name"
:default-value="defaultValue"
:model-value="modelValue"
:default-placeholder="defaultPlaceholder"
:placeholder="placeholder"
:max-value="maxValue"
:min-value="minValue"
:locale="locale"
:disabled="disabled"
:class="ui.base({ class: props.ui?.base })"
@update:model-value="onUpdate"
@blur="onBlur"
@focus="emitFormFocus"
>
<TimeFieldInput
v-for="segment in segments"
:key="segment.part"
:part="segment.part"
:class="ui.segment({ class: props.ui?.segment })"
>
{{ segment.value }}
</TimeFieldInput>

<span v-if="isLeading || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })">
<slot name="leading">
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
</slot>
</span>

<slot />

<span v-if="isTrailing || !!slots.trailing" :class="ui.trailing({ class: props.ui?.trailing })">
<slot name="trailing">
<UIcon v-if="trailingIconName" :name="trailingIconName" :class="ui.trailingIcon({ class: props.ui?.trailingIcon })" />
</slot>
</span>
</TimeFieldRoot>
</Primitive>
</template>
1 change: 1 addition & 0 deletions src/runtime/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,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/Modal.vue'
Expand Down
1 change: 1 addition & 0 deletions src/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,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 modal } from './modal'
Expand Down
38 changes: 38 additions & 0 deletions src/theme/input-time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { defuFn } from 'defu'
import type { ModuleOptions } from '../module'
import input from './input'

export default (options: Required<ModuleOptions>) => {
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))
}
4 changes: 2 additions & 2 deletions test/components/FormField.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FormFieldProps>
Expand Down
Loading
Loading