Skip to content

feat(InputTime): new component #3969

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 11 commits into
base: v3
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -40,6 +40,7 @@ const components = [
'input',
'input-menu',
'input-number',
'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 @@ -40,6 +40,7 @@ const components = [
'input',
'input-menu',
'input-number',
'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 @@ -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'
Expand Down
1 change: 1 addition & 0 deletions src/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
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