Skip to content
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

[Feature]: Time picker component #689

Open
1 of 2 tasks
data-diego opened this issue Jul 30, 2024 · 0 comments
Open
1 of 2 tasks

[Feature]: Time picker component #689

data-diego opened this issue Jul 30, 2024 · 0 comments

Comments

@data-diego
Copy link

data-diego commented Jul 30, 2024

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.
  • I have already implemented and/or tested this feature.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant