shadcn-vue
shadcn-vue copied to clipboard
[Feature]: Time picker component
Describe the feature
Hello everyone,
I wanted to implement a time picker and there was no component for it, the recently added number field wasn't what I was looking for so I did a little search and found
https://time.openstatus.dev/
It's an open source implementation using shadcn/ui for react with a simple <Input/> component so I modify it for shadcn/vue.
I succeeded and here I'm sharing the component to see if it helps someone or maybe if it gets integrated with the library itself, specially at the calendar component.
To make it work we need to follow the same steps from the openstatus component, that is:
-
Install shadcn including the
Inputcomponent (twelve-hour clocks also need theSelectcomponent) -
Copy & paste
time-picker-utils.tsx(inside@/components/ui/time-picker)
This step stays the same but you need to change the extension to .ts instead of .tsx (its just a typescript file)
- Copy & paste
time-picker-input.tsx
Alright so here's my edited component that I saved as time-picker-input.vue
<template>
<Input
:id="picker"
:name="picker"
:class="inputClasses"
:value="calculatedValue"
:defaultValue="calculatedValue"
:type="type"
inputmode="decimal"
@keydown="handleKeyDown"
/>
</template>
<script setup>
import { Input } from '@/components/ui/input';
import {
getArrowByType,
getDateByType,
setDateByType
} from './time-picker-utils';
import { cn } from '@/lib/utils';
const props = defineProps({
picker: String,
date: {
type: Date,
default: () => new Date(new Date().setHours(0, 0, 0, 0)),
},
period: String,
class: String,
type: {
type: String,
default: 'tel',
},
id: String,
name: String,
});
const emit = defineEmits(['update:date', 'rightFocus', 'leftFocus']);
const flag = ref(false);
const prevIntKey = ref('');
const inputClasses = computed(() =>
cn('w-[48px] text-center font-mono text-base tabular-nums caret-transparent focus:bg-accent focus:text-accent-foreground [&::-webkit-inner-spin-button]:appearance-none', props.class)
);
const calculatedValue = computed(() =>
getDateByType(props.date, props.picker)
);
watch(flag, (newFlag) => {
if (newFlag) {
const timer = setTimeout(() => {
flag.value = false;
}, 2000);
return () => clearTimeout(timer);
}
});
watch(() => props.period, (newPeriod) => {
if (newPeriod) {
const tempDate = new Date(props.date);
emit('update:date', setDateByType(tempDate, tempDate.getHours() % 12, props.picker, newPeriod));
}
});
const calculateNewValue = (key) => {
if (props.picker === '12hours') {
if (flag.value && prevIntKey.value === '1' && ['0', '1', '2'].includes(key)) {
const newValue = '1' + key;
prevIntKey.value = '';
return newValue;
}
if (flag.value) {
prevIntKey.value = '';
return prevIntKey.value + key;
}
prevIntKey.value = key;
return '0' + key;
}
return !flag.value ? '0' + key : calculatedValue.value.slice(1, 2) + key;
};
const handleKeyDown = (e) => {
if (e.key === 'Tab') return;
e.preventDefault();
if (e.key === 'ArrowRight') emit('rightFocus');
if (e.key === 'ArrowLeft') emit('leftFocus');
if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
const step = e.key === 'ArrowUp' ? 1 : -1;
const newValue = getArrowByType(calculatedValue.value, step, props.picker);
if (flag.value) flag.value = false;
const tempDate = new Date(props.date);
emit('update:date', setDateByType(tempDate, newValue, props.picker, props.period));
}
if (e.key >= '0' && e.key <= '9') {
const newValue = calculateNewValue(e.key);
if (flag.value && (newValue === '10' || newValue === '11')) {
emit('rightFocus');
}
flag.value = !flag.value;
const tempDate = new Date(props.date);
emit('update:date', setDateByType(tempDate, newValue, props.picker, props.period));
}
};
</script>
The authors intented to have a single Input component that will work for both hours and minutes and if its hours it could be 12 or 24 hours with arrow controls cycling if you exceeded the maximum
Several changes were made, specially the part of [date, setDate] from react that is now a v-model:date. And the emits for the onFocusRight and left. You can compare it with the original.
There's a lot of room for improvement so feel free to change anything
- (Still following the time.openstatus.dev tutorial) Define your TimePicker component (e.g.
time-picker-demo.tsx)
For this step I made a general component called time-picker.vue that you can consume as
<TimePicker
with-seconds
with-period
with-labels
v-model:date="date"
/>
const date = ref(new Date());
With those props, the default is 24 hours and minutes (no seconds nor period). I decided to make it opt-in since I considered the default to be that.
Here's the component
<template>
<div class="flex items-center gap-2">
<div class="flex flex-col items-center gap-1">
<Label v-if="withLabels" for="hours" class="text-xs">Hours</Label>
<TimePickerInput
:picker="withPeriod ? '12hours' : 'hours'"
:period="period"
:date="internalDate"
ref="hourRef"
@rightFocus="focusMinuteRef"
@update:date="updateDate"
/>
</div>
<div v-if="!withLabels">:</div>
<div class="flex flex-col items-center gap-1">
<Label v-if="withLabels" for="minutes" class="text-xs">Minutes</Label>
<TimePickerInput
picker="minutes"
:date="internalDate"
ref="minuteRef"
@leftFocus="focusHourRef"
@rightFocus="focusRightConditional"
@update:date="updateDate"
/>
</div>
<div v-if="!withLabels && withSeconds">:</div>
<div v-if="withSeconds" class="flex flex-col items-center gap-1">
<Label v-if="withLabels" for="seconds" class="text-xs">Seconds</Label>
<TimePickerInput
picker="seconds"
:date="internalDate"
ref="secondRef"
@leftFocus="focusMinuteRef"
@rightFocus="focusPeriodRef"
@update:date="updateDate"
/>
</div>
<Select v-if="withPeriod" class="w-20" v-model="period">
<SelectTrigger @keydown.arrow-left="focusLeftConditional" ref="periodRef">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="PM">
PM
</SelectItem>
<SelectItem value="AM">
AM
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</template>
<script setup>
const props = defineProps({
date: {
type: Date,
default: () => new Date(new Date().setHours(0, 0, 0, 0)),
},
withSeconds: {
type: Boolean,
default: false,
},
withPeriod: {
type: Boolean,
default: false,
},
withLabels: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:date']);
const internalDate = computed({
get: () => props.date,
set: (value) => emit('update:date', value),
});
const period = ref("PM");
const hourRef = ref(null);
const minuteRef = ref(null);
const secondRef = ref(null);
const periodRef = ref(null);
const focusMinuteRef = () => minuteRef.value?.$el.focus();
const focusHourRef = () => hourRef.value?.$el.focus();
const focusSecondRef = () => secondRef.value?.$el.focus();
const focusPeriodRef = () => periodRef.value?.$el.focus();
const focusLeftConditional = () => {
if (props.withSeconds) {
focusSecondRef();
} else {
focusMinuteRef();
}
};
const focusRightConditional = () => {
if (props.withSeconds) {
focusSecondRef();
} else {
focusPeriodRef();
}
};
const updateDate = (newDate) => {
internalDate.value = newDate;
};
</script>
- (Extra) Define an index.ts for exports
export { default as TimePickerInput } from './time-picker-input.vue'
export { default as TimePicker } from './time-picker.vue'
Overall this is the folder structure I added to @/components/ui
time-picker ├── index.ts ├── time-picker-input.vue ├── time-picker-utils.ts └── time-picker.vue
Hope this helps someone
Additional information
- [ ] I intend to submit a PR for this feature.
- [X] I have already implemented and/or tested this feature.