Skip to content

Commit e4c1e45

Browse files
committed
feat(InputTime): add component
1 parent 3e18df8 commit e4c1e45

File tree

8 files changed

+306
-0
lines changed

8 files changed

+306
-0
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
title: InputTime
3+
description: ''
4+
links:
5+
- label: InputTime
6+
icon: i-custom-reka-ui
7+
to: https://reka-ui.com/docs/components/input-time
8+
- label: GitHub
9+
icon: i-simple-icons-github
10+
to: https://github.com/nuxt/ui/blob/v4/src/runtime/components/InputTime.vue
11+
navigation.badge: Soon
12+
---
13+
14+
## Usage
15+
16+
## Examples
17+
18+
## API
19+
20+
### Props
21+
22+
:component-props
23+
24+
### Slots
25+
26+
:component-slots
27+
28+
### Emits
29+
30+
:component-emits
31+
32+
## Theme
33+
34+
:component-theme
35+
36+
## Changelog
37+
38+
:component-changelog

playgrounds/nuxt/app/composables/useNavigation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const components = [
3838
'input-menu',
3939
'input-number',
4040
'input-tags',
41+
'input-time',
4142
'input',
4243
'kbd',
4344
'link',
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<script setup lang="ts">
2+
import { Time } from '@internationalized/date'
3+
import theme from '#build/ui/input-number'
4+
5+
const colors = Object.keys(theme.variants.color)
6+
const sizes = Object.keys(theme.variants.size)
7+
const variants = Object.keys(theme.variants.variant)
8+
9+
const attrs = reactive({
10+
color: [theme.defaultVariants.color],
11+
size: [theme.defaultVariants.size],
12+
variant: [theme.defaultVariants.variant]
13+
})
14+
15+
const value = shallowRef(new Time(12, 30))
16+
</script>
17+
18+
<template>
19+
<Navbar>
20+
<USelect v-model="attrs.color" :items="colors" multiple />
21+
<USelect v-model="attrs.size" :items="sizes" multiple />
22+
<USelect v-model="attrs.variant" :items="variants" multiple />
23+
</Navbar>
24+
25+
<Matrix v-slot="props" :attrs="attrs">
26+
<UInputTime v-model="value" v-bind="props" />
27+
<UInputTime highlight v-bind="props" />
28+
<UInputTime disabled v-bind="props" />
29+
<UInputTime required v-bind="props" />
30+
</Matrix>
31+
</template>
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<script lang="ts">
2+
import type { TimeFieldRootProps, TimeFieldRootEmits } from 'reka-ui'
3+
import type { AppConfig } from '@nuxt/schema'
4+
import theme from '#build/ui/input-time'
5+
import type { ComponentConfig } from '../types/tv'
6+
7+
type InputTime = ComponentConfig<typeof theme, AppConfig, 'inputTime'>
8+
9+
export interface InputTimeProps extends Omit<TimeFieldRootProps, 'as' | 'locale' | 'dir'> {
10+
/**
11+
* The element or component this component should render as.
12+
* @defaultValue 'div'
13+
*/
14+
as?: any
15+
color?: InputTime['variants']['color']
16+
variant?: InputTime['variants']['variant']
17+
size?: InputTime['variants']['size']
18+
/** Highlight the ring color like a focus state. */
19+
highlight?: boolean
20+
autofocus?: boolean
21+
autofocusDelay?: number
22+
/**
23+
* The locale to use for formatting and parsing numbers.
24+
* @defaultValue UApp.locale.code
25+
*/
26+
locale?: string
27+
class?: any
28+
ui?: InputTime['slots']
29+
}
30+
31+
export interface InputTimeEmits extends TimeFieldRootEmits {
32+
change: [event: Event]
33+
blur: [event: FocusEvent]
34+
focus: [event: FocusEvent]
35+
}
36+
37+
export interface InputTimeSlots {
38+
leading(props: { ui: InputTime['ui'] }): any
39+
default(props: { ui: InputTime['ui'] }): any
40+
trailing(props: { ui: InputTime['ui'] }): any
41+
}
42+
</script>
43+
44+
<script setup lang="ts">
45+
import type { ComponentPublicInstance } from 'vue'
46+
import { computed, onMounted, ref } from 'vue'
47+
import { TimeFieldRoot, TimeFieldInput, useForwardPropsEmits, Primitive } from 'reka-ui'
48+
import { reactivePick } from '@vueuse/core'
49+
import { useAppConfig } from '#imports'
50+
import { useFieldGroup } from '../composables/useFieldGroup'
51+
import { useComponentIcons } from '../composables/useComponentIcons'
52+
import { useFormField } from '../composables/useFormField'
53+
import { useLocale } from '../composables/useLocale'
54+
import { tv } from '../utils/tv'
55+
56+
const props = withDefaults(defineProps<InputTimeProps>(), {
57+
autofocusDelay: 0
58+
})
59+
const emits = defineEmits<InputTimeEmits>()
60+
const slots = defineSlots<InputTimeSlots>()
61+
62+
const { code: codeLocale, dir } = useLocale()
63+
const appConfig = useAppConfig() as InputTime['AppConfig']
64+
65+
const rootProps = useForwardPropsEmits(reactivePick(props, 'disabled', 'id', 'name', 'required'), emits)
66+
67+
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color, size: formGroupSize, name, highlight, disabled, ariaAttrs } = useFormField<InputTimeProps>(props)
68+
const { orientation, size: fieldGroupSize } = useFieldGroup<InputTimeProps>(props)
69+
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
70+
71+
const locale = computed(() => props.locale || codeLocale.value)
72+
const inputSize = computed(() => fieldGroupSize.value || formGroupSize.value)
73+
74+
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.inputTime || {}) })({
75+
color: color.value,
76+
variant: props.variant,
77+
size: inputSize.value,
78+
highlight: highlight.value,
79+
trailing: isTrailing.value || !!slots.trailing,
80+
fieldGroup: orientation.value
81+
}))
82+
83+
const inputsRef = ref<ComponentPublicInstance[]>([])
84+
85+
function onUpdate(value: any) {
86+
// @ts-expect-error - 'target' does not exist in type 'EventInit'
87+
const event = new Event('change', { target: { value } })
88+
emits('change', event)
89+
90+
emitFormChange()
91+
emitFormInput()
92+
}
93+
94+
function onBlur(event: FocusEvent) {
95+
emitFormBlur()
96+
emits('blur', event)
97+
}
98+
99+
function onFocus(event: FocusEvent) {
100+
emitFormFocus()
101+
emits('focus', event)
102+
}
103+
104+
function autoFocus() {
105+
if (props.autofocus) {
106+
inputsRef.value[0]?.$el?.focus()
107+
}
108+
}
109+
110+
onMounted(() => {
111+
setTimeout(() => {
112+
autoFocus()
113+
}, props.autofocusDelay)
114+
})
115+
116+
defineExpose({
117+
inputsRef
118+
})
119+
</script>
120+
121+
<template>
122+
<Primitive :as="as" :class="ui.root({ class: [props.class, props.ui?.root] })">
123+
<TimeFieldRoot
124+
v-bind="{ ...rootProps, ...ariaAttrs }"
125+
:id="id"
126+
v-slot="{ segments }"
127+
:model-value="modelValue"
128+
:default-value="defaultValue"
129+
:default-placeholder="defaultPlaceholder"
130+
:placeholder="placeholder"
131+
:required="required"
132+
:disabled="disabled"
133+
:locale="locale"
134+
:name="name"
135+
:dir="dir"
136+
:class="ui.base({ class: [props.ui?.base] })"
137+
@update:model-value="onUpdate"
138+
@blur="onBlur"
139+
@focus="onFocus"
140+
>
141+
<TimeFieldInput
142+
v-for="(segment, index) in segments"
143+
:key="segment.part"
144+
:ref="el => (inputsRef[index] = el as ComponentPublicInstance)"
145+
:part="segment.part"
146+
:class="ui.segment({ class: props.ui?.segment })"
147+
>
148+
{{ segment.value }}
149+
</TimeFieldInput>
150+
151+
<slot :ui="ui" />
152+
153+
<span v-if="isLeading || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })">
154+
<slot name="leading" :ui="ui">
155+
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
156+
</slot>
157+
</span>
158+
159+
<span v-if="isTrailing || !!slots.trailing" :class="ui.trailing({ class: props.ui?.trailing })">
160+
<slot name="trailing" :ui="ui">
161+
<UIcon v-if="trailingIconName" :name="trailingIconName" :class="ui.trailingIcon({ class: props.ui?.trailingIcon })" />
162+
</slot>
163+
</span>
164+
</TimeFieldRoot>
165+
</Primitive>
166+
</template>

src/runtime/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export * from '../components/Input.vue'
5454
export * from '../components/InputMenu.vue'
5555
export * from '../components/InputNumber.vue'
5656
export * from '../components/InputTags.vue'
57+
export * from '../components/InputTime.vue'
5758
export * from '../components/Kbd.vue'
5859
export * from '../components/Link.vue'
5960
export * from '../components/Main.vue'

src/theme/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export { default as input } from './input'
5252
export { default as inputMenu } from './input-menu'
5353
export { default as inputNumber } from './input-number'
5454
export { default as inputTags } from './input-tags'
55+
export { default as inputTime } from './input-time'
5556
export { default as kbd } from './kbd'
5657
export { default as link } from './link'
5758
export { default as main } from './main'

src/theme/input-time.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { defuFn } from 'defu'
2+
import type { ModuleOptions } from '../module'
3+
import input from './input'
4+
5+
export default (options: Required<ModuleOptions>) => {
6+
return defuFn({
7+
slots: {
8+
base: () => ['w-full select-none relative group rounded-md inline-flex items-center focus:outline-none !gap-0', options.theme.transitions && 'transition-colors'],
9+
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'
10+
},
11+
variants: {
12+
variant: {
13+
outline: 'text-highlighted bg-default ring ring-inset ring-accented',
14+
soft: 'text-highlighted bg-elevated/50 hover:bg-elevated focus:bg-elevated disabled:bg-elevated/50',
15+
subtle: 'text-highlighted bg-elevated ring ring-inset ring-accented',
16+
ghost: 'text-highlighted bg-transparent hover:bg-elevated focus:bg-elevated disabled:bg-transparent dark:disabled:bg-transparent',
17+
none: 'text-highlighted bg-transparent'
18+
}
19+
},
20+
compoundVariants: [...(options.theme.colors || []).map((color: string) => ({
21+
color,
22+
variant: ['outline', 'subtle'],
23+
class: `focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-${color}`
24+
})), ...(options.theme.colors || []).map((color: string) => ({
25+
color,
26+
highlight: true,
27+
class: `ring ring-inset ring-${color}`
28+
})), {
29+
color: 'neutral',
30+
variant: ['outline', 'subtle'],
31+
class: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-inverted'
32+
}, {
33+
color: 'neutral',
34+
highlight: true,
35+
class: 'ring ring-inset ring-inverted'
36+
}]
37+
}, input(options))
38+
}

test/components/InputTime.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { axe } from 'vitest-axe'
3+
import { mountSuspended } from '@nuxt/test-utils/runtime'
4+
import InputTime from '../../src/runtime/components/InputTime.vue'
5+
import type { InputTimeProps, InputTimeSlots } from '../../src/runtime/components/InputTime.vue'
6+
import ComponentRender from '../component-render'
7+
8+
describe('InputTime', () => {
9+
const props = {}
10+
11+
it.each([
12+
// Props
13+
['with as', { props: { as: 'section' } }],
14+
['with class', { props: { class: '' } }],
15+
['with ui', { props: { ui: {} } }],
16+
// Slots
17+
['with default slot', { props, slots: { default: () => 'Default slot' } }]
18+
])('renders %s correctly', async (nameOrHtml: string, options: { props?: InputTimeProps, slots?: Partial<InputTimeSlots> }) => {
19+
const html = await ComponentRender(nameOrHtml, options, InputTime)
20+
expect(html).toMatchSnapshot()
21+
})
22+
23+
it('passes accessibility tests', async () => {
24+
const wrapper = await mountSuspended(InputTime, {
25+
props
26+
})
27+
28+
expect(await axe(wrapper.element)).toHaveNoViolations()
29+
})
30+
})

0 commit comments

Comments
 (0)