shadcn-vue icon indicating copy to clipboard operation
shadcn-vue copied to clipboard

[Feature]: Time picker component

Open data-diego opened this issue 1 year ago • 1 comments

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:

  1. Install shadcn including the Input component (twelve-hour clocks also need the Select component)

  2. 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)

  1. 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

  1. (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>
  1. (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.

data-diego avatar Jul 30 '24 21:07 data-diego