Skip to content
Open
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
25 changes: 23 additions & 2 deletions docs/content/docs/2.components/input-time.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ links:

## Usage

Use the `v-model` directive to control the selected date.
Use the `v-model` directive to control the selected time.

::component-code
---
Expand Down Expand Up @@ -44,7 +44,28 @@ props:
::

::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. Format of date depends on the [`locale`](/docs/getting-started/integrations/i18n) installed in your application.
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. Format of time depends on the [`locale`](/docs/getting-started/integrations/i18n) installed in your application.
::

### Range

Use the `range` prop to select a range of times instead of a single time. When `range` is set, the `v-model` and `default-value` props accept an object with `start` and `end` properties.

::component-code
---
cast:
modelValue: TimeRange
ignore:
- range
- modelValue
external:
- modelValue
props:
range: true
modelValue:
start: [9, 0, 0]
end: [17, 30, 0]
---
::

### Hour Cycle
Expand Down
12 changes: 12 additions & 0 deletions playgrounds/nuxt/app/pages/components/input-time.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const attrs = reactive({
})

const value = shallowRef(new Time(12, 30))
const rangeValue = shallowRef({ start: new Time(9, 0), end: new Time(17, 30) })
</script>

<template>
Expand All @@ -35,4 +36,15 @@ const value = shallowRef(new Time(12, 30))
<UInputTime loading trailing v-bind="props" />
<UInputTime loading icon="i-lucide-clock" trailing-icon="i-lucide-chevron-down" v-bind="props" />
</Matrix>

<USeparator label="Range Mode" />

<Matrix v-slot="props" :attrs="attrs">
<UInputTime v-model="rangeValue" range v-bind="props" :hour-cycle="24" />
<UInputTime :default-value="{ start: new Time(9, 0), end: new Time(17, 30) }" range v-bind="props" />
<UInputTime range highlight v-bind="props" />
<UInputTime range disabled v-bind="props" />
<UInputTime range icon="i-lucide-clock" separator-icon="i-lucide-arrow-right" :hour-cycle="24" v-bind="props" />
<UInputTime range loading v-bind="props" />
</Matrix>
</template>
200 changes: 175 additions & 25 deletions src/runtime/components/InputTime.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,29 @@ 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 { AvatarProps } from '../types'
import type { AvatarProps, IconProps } from '../types'
import type { ComponentConfig } from '../types/tv'
import { Time } from '@internationalized/date'

type SegmentPart = 'day' | 'month' | 'year' | 'hour' | 'minute' | 'second' | 'dayPeriod' | 'literal' | 'timeZoneName'

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

export interface InputTimeProps extends Omit<TimeFieldRootProps, 'as' | 'asChild' | 'locale' | 'dir'>, UseComponentIconsProps {
type _TimeFieldRootProps = Omit<TimeFieldRootProps, 'as' | 'asChild' | 'modelValue' | 'defaultValue' | 'dir' | 'locale'>

type TimeValue = TimeFieldRootProps['modelValue']

interface TimeRange {
start: TimeValue | undefined
end: TimeValue | undefined
}

export type { TimeRange }

type InputTimeDefaultValue<R extends boolean = false> = R extends true ? TimeRange : TimeFieldRootProps['defaultValue']
type InputTimeModelValue<R extends boolean = false> = (R extends true ? TimeRange : TimeFieldRootProps['modelValue']) | undefined

export interface InputTimeProps<R extends boolean = false> extends UseComponentIconsProps, _TimeFieldRootProps {
/**
* The element or component this component should render as.
* @defaultValue 'div'
Expand All @@ -31,27 +48,39 @@ export interface InputTimeProps extends Omit<TimeFieldRootProps, 'as' | 'asChild
highlight?: boolean
autofocus?: boolean
autofocusDelay?: number
/**
* The icon to use as a range separator.
* @defaultValue appConfig.ui.icons.minus
* @IconifyIcon
*/
separatorIcon?: IconProps['name']
/** Whether or not a range of times can be selected */
range?: R & boolean
defaultValue?: InputTimeDefaultValue<R>
modelValue?: InputTimeModelValue<R>
class?: any
ui?: InputTime['slots']
}

export interface InputTimeEmits extends TimeFieldRootEmits {
change: [event: Event]
blur: [event: FocusEvent]
focus: [event: FocusEvent]
export interface InputTimeEmits<R extends boolean> extends Omit<TimeFieldRootEmits, 'update:modelValue'> {
'update:modelValue': [date: InputTimeModelValue<R>]
'change': [event: Event]
'blur': [event: FocusEvent]
'focus': [event: FocusEvent]
}

export interface InputTimeSlots {
leading(props: { ui: InputTime['ui'] }): any
default(props: { ui: InputTime['ui'] }): any
trailing(props: { ui: InputTime['ui'] }): any
separator(props: { ui: InputTime['ui'] }): any
}
</script>

<script setup lang="ts">
<script setup lang="ts" generic="R extends boolean">
import { computed, onMounted, ref } from 'vue'
import { TimeFieldRoot, TimeFieldInput, useForwardPropsEmits } from 'reka-ui'
import { reactiveOmit } from '@vueuse/core'
import { reactiveOmit, createReusableTemplate } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { useFieldGroup } from '../composables/useFieldGroup'
import { useComponentIcons } from '../composables/useComponentIcons'
Expand All @@ -60,20 +89,26 @@ import { tv } from '../utils/tv'
import UIcon from './Icon.vue'
import UAvatar from './Avatar.vue'

const props = withDefaults(defineProps<InputTimeProps>(), {
defineOptions({ inheritAttrs: false })

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

const appConfig = useAppConfig() as InputTime['AppConfig']

const rootProps = useForwardPropsEmits(reactiveOmit(props, 'id', 'name', 'color', 'variant', 'size', 'highlight', 'disabled', 'autofocus', 'autofocusDelay', 'icon', 'avatar', 'leading', 'leadingIcon', 'trailing', 'trailingIcon', 'loading', 'loadingIcon', 'class', 'ui'), emits)
const rootProps = useForwardPropsEmits(reactiveOmit(props, 'id', 'name', 'range', 'modelValue', 'defaultValue', 'color', 'variant', 'size', 'highlight', 'disabled', 'autofocus', 'autofocusDelay', 'icon', 'avatar', 'leading', 'leadingIcon', 'trailing', 'trailingIcon', 'loading', 'loadingIcon', 'separatorIcon', 'class', 'ui'), emits)

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

const [DefineSegmentsTemplate, ReuseSegmentsTemplate] = createReusableTemplate<{
segments?: { part: SegmentPart, value: string }[]
}>()

const inputSize = computed(() => fieldGroupSize.value || formGroupSize.value)

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.inputTime || {}) })({
Expand All @@ -89,7 +124,52 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.inputTime ||

const inputsRef = ref<ComponentPublicInstance[]>([])

function onUpdate(value: any) {
// Range mode: internal state for start/end
const startValue = computed(() => {
if (props.range && props.modelValue) {
return (props.modelValue as TimeRange).start ?? new Time(0, 0)
}
return undefined
})

const endValue = computed(() => {
if (props.range && props.modelValue) {
return (props.modelValue as TimeRange).end ?? new Time(0, 0)
}
return undefined
})

const startDefaultValue = computed(() => {
if (props.range && props.defaultValue) {
return (props.defaultValue as TimeRange).start ?? new Time(0, 0)
}
return undefined
})

const endDefaultValue = computed(() => {
if (props.range && props.defaultValue) {
return (props.defaultValue as TimeRange).end ?? new Time(0, 0)
}
return undefined
})

function onUpdateStart(value: TimeValue | undefined) {
if (props.range) {
const newRange = { start: value, end: endValue.value } as InputTimeModelValue<R>
emits('update:modelValue', newRange)
triggerChange(newRange)
}
}

function onUpdateEnd(value: TimeValue | undefined) {
if (props.range) {
const newRange = { start: startValue.value, end: value } as InputTimeModelValue<R>
emits('update:modelValue', newRange)
triggerChange(newRange)
}
}

function triggerChange(value: any) {
// @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value } })
emits('change', event)
Expand All @@ -98,6 +178,10 @@ function onUpdate(value: any) {
emitFormInput()
}

function onUpdate(value: any) {
triggerChange(value)
}

function onBlur(event: FocusEvent) {
emitFormBlur()
emits('blur', event)
Expand Down Expand Up @@ -126,8 +210,83 @@ defineExpose({
</script>

<template>
<DefineSegmentsTemplate v-slot="{ segments }">
<TimeFieldInput
v-for="(segment, index) in segments"
:key="`${segment.part}-${index}`"
:ref="(el) => (inputsRef[index] = el as ComponentPublicInstance)"
:part="segment.part"
data-slot="segment"
:class="ui.segment({ class: props.ui?.segment })"
:data-segment="segment.part"
>
{{ segment.value.trim() }}
</TimeFieldInput>
</DefineSegmentsTemplate>

<!-- Range mode: two TimeFieldRoot side by side -->
<template v-if="range">
<div
v-bind="{ ...$attrs, ...ariaAttrs }"
:id="id"
data-slot="base"
:class="ui.base({ class: [props.ui?.base, props.class] })"
>
<TimeFieldRoot
v-slot="{ segments }"
v-bind="rootProps"
class="flex"
:model-value="startValue"
:default-value="startDefaultValue"
:name="name ? `${name}[start]` : undefined"
:disabled="disabled"
@update:model-value="onUpdateStart"
@blur="onBlur"
@focus="onFocus"
>
<ReuseSegmentsTemplate :segments="segments" />
</TimeFieldRoot>

<slot name="separator" :ui="ui">
<UIcon :name="separatorIcon || appConfig.ui.icons.minus" data-slot="separatorIcon" :class="ui.separatorIcon({ class: props.ui?.separatorIcon })" />
</slot>

<TimeFieldRoot
v-slot="{ segments }"
v-bind="rootProps"
class="flex"
:model-value="endValue"
:default-value="endDefaultValue"
:name="name ? `${name}[end]` : undefined"
:disabled="disabled"
@update:model-value="onUpdateEnd"
@blur="onBlur"
@focus="onFocus"
>
<ReuseSegmentsTemplate :segments="segments" />
</TimeFieldRoot>

<slot :ui="ui" />

<span v-if="isLeading || !!avatar || !!slots.leading" data-slot="leading" :class="ui.leading({ class: props.ui?.leading })">
<slot name="leading" :ui="ui">
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" data-slot="leadingIcon" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
<UAvatar v-else-if="!!avatar" :size="((props.ui?.leadingAvatarSize || ui.leadingAvatarSize()) as AvatarProps['size'])" v-bind="avatar" data-slot="leadingAvatar" :class="ui.leadingAvatar({ class: props.ui?.leadingAvatar })" />
</slot>
</span>

<span v-if="isTrailing || !!slots.trailing" data-slot="trailing" :class="ui.trailing({ class: props.ui?.trailing })">
<slot name="trailing" :ui="ui">
<UIcon v-if="trailingIconName" :name="trailingIconName" data-slot="trailingIcon" :class="ui.trailingIcon({ class: props.ui?.trailingIcon })" />
</slot>
</span>
</div>
</template>

<!-- Single mode -->
<TimeFieldRoot
v-bind="{ ...rootProps, ...ariaAttrs }"
v-else
v-bind="{ ...rootProps, ...$attrs, ...ariaAttrs }"
:id="id"
v-slot="{ segments }"
:name="name"
Expand All @@ -138,16 +297,7 @@ defineExpose({
@blur="onBlur"
@focus="onFocus"
>
<TimeFieldInput
v-for="(segment, index) in segments"
:key="`${segment.part}-${index}`"
:ref="el => (inputsRef[index] = el as ComponentPublicInstance)"
:part="segment.part"
data-slot="segment"
:class="ui.segment({ class: props.ui?.segment })"
>
{{ segment.value.trim() }}
</TimeFieldInput>
<ReuseSegmentsTemplate :segments="segments" />

<slot :ui="ui" />

Expand Down
3 changes: 2 additions & 1 deletion src/theme/input-time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export default (options: Required<ModuleOptions>) => {
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']
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'],
separatorIcon: 'shrink-0 size-4 text-muted'
},
variants: {
...fieldGroupVariant,
Expand Down
Loading