Skip to content

feat: time-field #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 7 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
1 change: 1 addition & 0 deletions playground-vue/src/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const components = [
'textarea',
'toast',
'tooltip',
'time-field',
'tree'
]

Expand Down
1 change: 1 addition & 0 deletions playground/app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const components = [
'tabs',
'table',
'textarea',
'time-field',
'toast',
'tooltip',
'tree'
Expand Down
171 changes: 171 additions & 0 deletions playground/app/pages/components/time-field.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { upperFirst } from 'scule'
import { Time } from '@internationalized/date'

// Define proper types for variants and sizes
const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const
const variants = ['outline', 'soft', 'subtle', 'ghost', 'none'] as const

// 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-8">
<div class="flex flex-col gap-4 w-48">
<h3 class="text-lg font-medium">
Default TimeField
</h3>
<UTimeField v-model="defaultTime" />
</div>

<div class="flex items-center gap-2 flex-wrap justify-center">
<div v-for="variant in variants" :key="variant" class="flex flex-col items-center">
<p class="text-sm mb-2">
{{ upperFirst(variant) }}
</p>
<UTimeField
v-model="defaultTime"
:variant="variant"
class="w-48"
/>
</div>
</div>

<div class="flex items-center gap-2 flex-wrap justify-center">
<div v-for="variant in variants" :key="variant" class="flex flex-col items-center">
<p class="text-sm mb-2">
{{ upperFirst(variant) }} (Neutral)
</p>
<UTimeField
v-model="defaultTime"
:variant="variant"
color="neutral"
class="w-48"
/>
</div>
</div>

<div class="flex items-center gap-2 flex-wrap justify-center">
<div v-for="variant in variants" :key="variant" class="flex flex-col items-center">
<p class="text-sm mb-2">
{{ upperFirst(variant) }} (Error)
</p>
<UTimeField
v-model="defaultTime"
:variant="variant"
color="error"
highlight
class="w-48"
/>
</div>
</div>

<div class="flex flex-col gap-4">
<h3 class="text-lg font-medium">
TimeField Variants
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<p class="text-sm">
Disabled
</p>
<UTimeField v-model="defaultTime" disabled />
</div>
<div class="flex flex-col gap-2">
<p class="text-sm">
Required
</p>
<UTimeField v-model="defaultTime" required />
</div>
<div class="flex flex-col gap-2">
<p class="text-sm">
Readonly
</p>
<UTimeField v-model="defaultTime" readonly />
</div>
<div class="flex flex-col gap-2">
<p class="text-sm">
Hour only
</p>
<UTimeField v-model="hourOnlyTime" granularity="hour" />
</div>
<div class="flex flex-col gap-2">
<p class="text-sm">
With seconds
</p>
<UTimeField v-model="secondsTime" granularity="second" />
</div>
<div class="flex flex-col gap-2">
<p class="text-sm">
24 hour cycle
</p>
<UTimeField v-model="cycle24Time" :hour-cycle="24" />
</div>
<div class="flex flex-col gap-2">
<p class="text-sm">
Loading
</p>
<UTimeField v-model="defaultTime" loading />
</div>
<div class="flex flex-col gap-2">
<p class="text-sm">
Loading (trailing)
</p>
<UTimeField v-model="defaultTime" loading trailing />
</div>
<div class="flex flex-col gap-2">
<p class="text-sm">
With icons
</p>
<UTimeField v-model="defaultTime" icon="i-lucide-clock" trailing-icon="i-lucide-chevron-down" />
</div>
</div>
</div>

<div class="flex flex-col gap-4">
<h3 class="text-lg font-medium">
TimeField Sizes
</h3>
<div class="flex flex-wrap gap-4 justify-center">
<div v-for="size in sizes" :key="size" class="flex flex-col items-center">
<p class="text-sm mb-2">
{{ upperFirst(size) }}
</p>
<UTimeField v-model="defaultTime" :size="size" />
</div>
</div>
</div>

<div class="flex flex-col gap-4">
<h3 class="text-lg font-medium">
TimeField with Leading Icon
</h3>
<div class="flex flex-wrap gap-4 justify-center">
<div v-for="size in sizes" :key="size" class="flex flex-col items-center">
<p class="text-sm mb-2">
{{ upperFirst(size) }}
</p>
<UTimeField v-model="defaultTime" icon="i-lucide-clock" :size="size" />
</div>
</div>
</div>

<div class="flex flex-col gap-4">
<h3 class="text-lg font-medium">
TimeField with Trailing Icon
</h3>
<div class="flex flex-wrap gap-4 justify-center">
<div v-for="size in sizes" :key="size" class="flex flex-col items-center">
<p class="text-sm mb-2">
{{ upperFirst(size) }}
</p>
<UTimeField v-model="defaultTime" icon="i-lucide-clock" trailing :size="size" />
</div>
</div>
</div>
</div>
</template>
201 changes: 201 additions & 0 deletions src/runtime/components/TimeField.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
<script lang="ts">
import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/time-field'
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import type { ComponentConfig } from '../types/utils'

type TimeField = ComponentConfig<typeof theme, AppConfig, 'timeField'>

export interface TimeFieldProps extends UseComponentIconsProps {
/**
* The element or component this component should render as.
* @defaultValue 'div'
*/
as?: any
id?: string
name?: string
/** The placeholder text when the input is empty. */
placeholder?: string
/**
* @defaultValue 'primary'
*/
color?: TimeField['variants']['color']
/**
* @defaultValue 'outline'
*/
variant?: TimeField['variants']['variant']
/**
* @defaultValue 'md'
*/
size?: TimeField['variants']['size']
required?: boolean
disabled?: boolean
readonly?: boolean
/** Highlight the ring color like a focus state. */
highlight?: boolean
/**
* The granularity to use for formatting times.
* @defaultValue 'minute'
*/
granularity?: 'hour' | 'minute' | 'second'
/**
* The hour cycle used for formatting times.
*/
hourCycle?: 12 | 24
/**
* Whether to hide the time zone segment of the field
*/
hideTimeZone?: boolean
/**
* The locale to use for formatting dates
*/
locale?: string
/**
* The minimum time that can be selected
*/
minValue?: any
/**
* The maximum time that can be selected
*/
maxValue?: any
class?: any
ui?: TimeField['slots']
}

export interface TimeFieldEmits {
(e: 'update:modelValue' | 'update:placeholder', payload: any): void
(e: 'blur', event: FocusEvent): void
(e: 'change', event: Event): void
}

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

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

defineOptions({ inheritAttrs: false })

const props = withDefaults(defineProps<TimeFieldProps>(), {
granularity: 'minute',
hideTimeZone: false
})
const emits = defineEmits<TimeFieldEmits>()
const slots = defineSlots<TimeFieldSlots>()

const [modelValue] = defineModel<any>()

// Default placeholder - when needed, use a Time object of 12:00
const defaultPlaceholder = new Time(12, 0, 0)

const appConfig = useAppConfig() as TimeField['AppConfig']
const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, emitFormFocus, ariaAttrs } = useFormField<TimeFieldProps>(props, { deferInputValidation: true })
const { orientation, size: buttonGroupSize } = useButtonGroup<TimeFieldProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)

const fieldSize = computed(() => buttonGroupSize.value || formGroupSize.value)

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

const fieldRef = ref<HTMLElement | null>(null)

function updateValue(value: any) {
modelValue.value = value
emitFormInput()
emitFormChange()
}

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

defineExpose({
fieldRef
})
</script>

<template>
<Primitive :as="as" :class="ui.root({ class: [props.class, props.ui?.root] })">
<TimeFieldRoot
:id="id"
ref="fieldRef"
:name="name"
:model-value="modelValue"
v-slot="{ segments }"

Check warning on line 146 in src/runtime/components/TimeField.vue

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Attribute "v-slot" should go before ":model-value"
:default-placeholder="defaultPlaceholder"
:granularity="granularity"
:hour-cycle="hourCycle"
:hide-time-zone="hideTimeZone"
:locale="locale"
:min-value="minValue"
:max-value="maxValue"
:disabled="disabled"
:readonly="readonly"
:required="required"
v-bind="{ ...ariaAttrs }"
class="flex h-full w-full items-center"
@update:model-value="updateValue"
@update:placeholder="$emit('update:placeholder', $event)"
@blur="onBlur"
@focus="emitFormFocus"
>
<!-- Leading Icon/Slot -->
<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>

<!-- Time Field Segments -->
<div class="flex flex-1 items-center justify-center">
<template v-for="segment in segments" :key="segment.part">
<TimeFieldInput
v-if="segment.part === 'literal'"
:part="segment.part"
:class="ui.base({ class: props.ui?.base })"
>
{{ segment.value }}
</TimeFieldInput>
<TimeFieldInput
v-else
:part="segment.part"
:class="ui.base({ class: props.ui?.base })"
>
{{ segment.value }}
</TimeFieldInput>
</template>
</div>

<slot />

<!-- Trailing Icon/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 @@ -46,6 +46,7 @@ export * from '../components/Switch.vue'
export * from '../components/Table.vue'
export * from '../components/Tabs.vue'
export * from '../components/Textarea.vue'
export * from '../components/TimeField.vue'
export * from '../components/Toast.vue'
export * from '../components/Toaster.vue'
export * from '../components/Tooltip.vue'
Expand Down
Loading