mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-13 18:52:14 +01:00
improve time picker parsing, fix nested escape listeners, change project member select
This commit is contained in:
@@ -82,7 +82,7 @@ const inputValue = ref(formatValue(model.value));
|
||||
type="text"
|
||||
:name="name"
|
||||
placeholder="Billable Rate"
|
||||
class="mt-2 block w-full"
|
||||
class="block w-full"
|
||||
autocomplete="teamMemberRate" />
|
||||
<div
|
||||
class="absolute top-0 right-0 h-full flex items-center px-4 font-medium pointer-events-none">
|
||||
|
||||
@@ -54,7 +54,7 @@ const emit = defineEmits(['changed']);
|
||||
@keydown.enter="updateDate"
|
||||
:class="
|
||||
twMerge(
|
||||
'bg-input-background border text-white border-input-border focus-visible:outline-0 focus-visible:ring-0 rounded-md',
|
||||
'bg-input-background border text-white border-input-border focus-visible:outline-0 focus-visible:border-input-border-active focus-visible:ring-0 rounded-md',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import {
|
||||
flip,
|
||||
limitShift,
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from '@floating-ui/vue';
|
||||
import { offset } from '@floating-ui/vue';
|
||||
import { autoUpdate } from '@floating-ui/vue';
|
||||
import { useId } from 'radix-vue';
|
||||
import { isLastLayer, layers } from '@/packages/ui/src/utils/dismissableLayer';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -24,17 +26,28 @@ const props = withDefaults(
|
||||
|
||||
const emit = defineEmits(['open', 'submit']);
|
||||
const open = defineModel({ default: false });
|
||||
const id = useId();
|
||||
|
||||
const closeOnEscape = (e: KeyboardEvent) => {
|
||||
if (open.value && e.key === 'Escape') {
|
||||
open.value = false;
|
||||
}
|
||||
if (open.value && e.key === 'Enter') {
|
||||
emit('submit');
|
||||
if (props.closeOnContentClick) open.value = false;
|
||||
if (isLastLayer(id)) {
|
||||
if (open.value && e.key === 'Escape') {
|
||||
open.value = false;
|
||||
}
|
||||
if (open.value && e.key === 'Enter') {
|
||||
emit('submit');
|
||||
if (props.closeOnContentClick) open.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(open, (value) => {
|
||||
if (value) {
|
||||
layers.value.push(id);
|
||||
} else {
|
||||
layers.value = layers.value.filter((layer) => layer !== id);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', closeOnEscape));
|
||||
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import parse from 'parse-duration';
|
||||
import { computed, ref } from 'vue';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import {
|
||||
formatHumanReadableDuration,
|
||||
getDayJsInstance,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import dayjs from 'dayjs';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { TextInput } from '@/packages/ui/src';
|
||||
const temporaryCustomTimerEntry = ref<string>('');
|
||||
|
||||
const start = defineModel('start', {
|
||||
@@ -49,7 +50,7 @@ function updateDuration() {
|
||||
start.value = newStartDate.utc().format();
|
||||
}
|
||||
// fallback to minutes if just a number is given
|
||||
temporaryCustomTimerEntry.value = '';
|
||||
updateTimeEntryInputValue();
|
||||
}
|
||||
|
||||
function isNumeric(value: string) {
|
||||
@@ -62,39 +63,24 @@ const props = defineProps<{
|
||||
|
||||
const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;
|
||||
|
||||
const currentTime = computed({
|
||||
get() {
|
||||
if (temporaryCustomTimerEntry.value !== '') {
|
||||
return temporaryCustomTimerEntry.value;
|
||||
}
|
||||
if (start.value && end.value) {
|
||||
const startTime = dayjs(start.value);
|
||||
const diff = getDayJsInstance()(end.value).diff(
|
||||
startTime,
|
||||
'seconds'
|
||||
);
|
||||
return formatHumanReadableDuration(diff);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
// setter
|
||||
set(newValue) {
|
||||
if (newValue) {
|
||||
temporaryCustomTimerEntry.value = newValue;
|
||||
} else {
|
||||
temporaryCustomTimerEntry.value = '';
|
||||
}
|
||||
},
|
||||
});
|
||||
watch([start, end], updateTimeEntryInputValue);
|
||||
onMounted(() => updateTimeEntryInputValue());
|
||||
|
||||
function updateTimeEntryInputValue() {
|
||||
if (start.value && end.value) {
|
||||
const startTime = dayjs(start.value);
|
||||
const diff = getDayJsInstance()(end.value).diff(startTime, 'seconds');
|
||||
temporaryCustomTimerEntry.value = formatHumanReadableDuration(diff);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
placeholder="00:00:00"
|
||||
<TextInput
|
||||
ref="inputField"
|
||||
@blur="updateDuration"
|
||||
@keydown.enter="updateDuration"
|
||||
v-model="currentTime"
|
||||
v-model="temporaryCustomTimerEntry"
|
||||
:class="twMerge('text-text-secondary', props.class)"
|
||||
type="text" />
|
||||
</template>
|
||||
|
||||
@@ -5,7 +5,6 @@ import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue';
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
import { type Placement } from '@floating-ui/vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const model = defineModel<string | null>({
|
||||
default: null,
|
||||
});
|
||||
@@ -41,12 +40,36 @@ const filteredItems = computed<T[]>(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const highlightedItemId = ref<string | null>(model.value);
|
||||
|
||||
watch(model, () => {
|
||||
highlightedItemId.value = model.value;
|
||||
});
|
||||
|
||||
watch(filteredItems, () => {
|
||||
if (filteredItems.value.length > 0) {
|
||||
if (
|
||||
filteredItems.value.length > 0 &&
|
||||
filteredItems.value.find(
|
||||
(item) => props.getKeyFromItem(item) === highlightedItemId.value
|
||||
) === undefined
|
||||
) {
|
||||
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||
}
|
||||
});
|
||||
|
||||
watch(highlightedItemId, () => {
|
||||
if (highlightedItemId.value) {
|
||||
const highlightedDomElement = dropdownViewport.value?.querySelector(
|
||||
`[data-select-id="${highlightedItemId.value}"]`
|
||||
) as HTMLElement;
|
||||
|
||||
highlightedDomElement?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
|
||||
function setItem(newValue: string | null) {
|
||||
@@ -89,7 +112,6 @@ function moveHighlightDown() {
|
||||
}
|
||||
}
|
||||
|
||||
const highlightedItemId = ref<string | null>(model.value);
|
||||
const highlightedItem = computed(() => {
|
||||
return props.items.find(
|
||||
(item) => props.getKeyFromItem(item) === highlightedItemId.value
|
||||
@@ -120,20 +142,16 @@ onKeyStroke('Enter', (e) => {
|
||||
watch(open, () => {
|
||||
if (open.value === true) {
|
||||
nextTick(() => {
|
||||
scrollCurrentItemInView();
|
||||
const highlightedDomElement = dropdownViewport.value?.querySelector(
|
||||
`[data-select-id="${model.value}"]`
|
||||
) as HTMLElement;
|
||||
dropdownViewport.value?.scrollTo({
|
||||
top: highlightedDomElement?.offsetTop ?? 0,
|
||||
behavior: 'instant',
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function scrollCurrentItemInView() {
|
||||
const highlightedDomElement = dropdownViewport.value?.querySelector(
|
||||
`[data-select-id="${model.value}"]`
|
||||
) as HTMLElement;
|
||||
dropdownViewport.value?.scrollTo({
|
||||
top: highlightedDomElement?.offsetTop ?? 0,
|
||||
behavior: 'instant',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -145,7 +163,10 @@ function scrollCurrentItemInView() {
|
||||
<div
|
||||
ref="dropdownViewport"
|
||||
:class="
|
||||
twMerge('w-60 max-h-60 overflow-y-scroll', props.class)
|
||||
twMerge(
|
||||
'w-60 py-1.5 max-h-60 overflow-y-scroll',
|
||||
props.class
|
||||
)
|
||||
">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
@@ -153,12 +174,14 @@ function scrollCurrentItemInView() {
|
||||
role="option"
|
||||
:data-select-id="props.getKeyFromItem(item)"
|
||||
:value="props.getKeyFromItem(item)"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
props.getKeyFromItem(item) === highlightedItemId,
|
||||
}"
|
||||
:data-item-id="props.getKeyFromItem(item)">
|
||||
<SelectDropdownItem
|
||||
@mouseenter="
|
||||
highlightedItemId = props.getKeyFromItem(item)
|
||||
"
|
||||
:highlighted="
|
||||
props.getKeyFromItem(item) === highlightedItemId
|
||||
"
|
||||
:selected="props.getKeyFromItem(item) === model"
|
||||
@click="setItem(props.getKeyFromItem(item))"
|
||||
:name="props.getNameForItem(item)"></SelectDropdownItem>
|
||||
|
||||
@@ -4,20 +4,24 @@ import { twMerge } from 'tailwind-merge';
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
selected: boolean;
|
||||
highlighted: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
twMerge(
|
||||
'flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out cursor-pointer ',
|
||||
props.selected
|
||||
? 'bg-accent-300/20'
|
||||
: 'hover:bg-card-background-active'
|
||||
)
|
||||
">
|
||||
<span>{{ name }}</span>
|
||||
<div class="px-1">
|
||||
<div
|
||||
:class="
|
||||
twMerge(
|
||||
'flex items-center space-x-3 w-full px-1.5 py-1.5 rounded text-start text-sm font-medium leading-5 text-white focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out cursor-pointer ',
|
||||
props.highlighted && 'bg-card-background-active',
|
||||
props.selected
|
||||
? 'bg-accent-300/20'
|
||||
: 'hover:bg-card-background-active'
|
||||
)
|
||||
">
|
||||
<span>{{ name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
name?: string;
|
||||
class?: string;
|
||||
}>();
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
@@ -20,7 +22,12 @@ const model = defineModel();
|
||||
<template>
|
||||
<input
|
||||
ref="input"
|
||||
class="border-input-border border bg-input-background text-white focus:ring-input-border-active focus:ring-0 focus-visible:border-input-border-active rounded-md shadow-sm"
|
||||
:class="
|
||||
twMerge(
|
||||
'border-input-border border bg-input-background text-white focus:ring-input-border-active focus:ring-0 focus-visible:border-input-border-active rounded-md shadow-sm',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
v-model="model"
|
||||
:name="name" />
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import { SelectDropdown, TextInput } from '@/packages/ui/src';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
// This has to be a localized timestamp, not UTC
|
||||
const model = defineModel<string | null>({
|
||||
@@ -36,6 +37,47 @@ function updateTime(event: Event) {
|
||||
emit('changed', model.value);
|
||||
}
|
||||
}
|
||||
// check if input is only numbers
|
||||
else if (/^\d+$/.test(newValue)) {
|
||||
if (newValue.length === 4) {
|
||||
// parse 1300 to 13:00
|
||||
const [hours, minutes] = [
|
||||
newValue.slice(0, 2),
|
||||
newValue.slice(2, 4),
|
||||
];
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(hours), 23))
|
||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
} else if (newValue.length === 3) {
|
||||
// parse 130 to 01:30
|
||||
const [hours, minutes] = [
|
||||
newValue.slice(0, 1),
|
||||
newValue.slice(1, 3),
|
||||
];
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(hours), 23))
|
||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
} else if (newValue.length === 2) {
|
||||
// parse 13 to 13:00
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(newValue), 23))
|
||||
.set('minutes', 0)
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
} else if (newValue.length === 1) {
|
||||
// parse 1 to 01:00
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(newValue), 23))
|
||||
.set('minutes', 0)
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
}
|
||||
}
|
||||
|
||||
inputValue.value = getLocalizedDayJs(model.value).format('HH:mm');
|
||||
}
|
||||
|
||||
@@ -96,7 +138,7 @@ const closestValue = computed({
|
||||
<template>
|
||||
<div class="flex min-w-0 items-center justify-center text-white">
|
||||
<SelectDropdown
|
||||
class="min-w-0 w-28"
|
||||
:class="twMerge('mine-w-0 w-24', size === 'large' && 'w-28')"
|
||||
v-model="closestValue"
|
||||
v-model:open="open"
|
||||
:get-key-from-item="(item) => item.timestamp"
|
||||
@@ -106,18 +148,24 @@ const closestValue = computed({
|
||||
<TextInput
|
||||
v-model="inputValue"
|
||||
ref="timeInput"
|
||||
class="w-28 text-center"
|
||||
:class="
|
||||
twMerge(
|
||||
'text-center w-24 px-3 py-2',
|
||||
size === 'large' && 'w-28'
|
||||
)
|
||||
"
|
||||
@blur="updateTime"
|
||||
@keydown.enter="
|
||||
updateTime($event);
|
||||
open = false;
|
||||
"
|
||||
@keydown.tab="open = false"
|
||||
@focus="($event.target as HTMLInputElement).select()"
|
||||
@mouseup="($event.target as HTMLInputElement).select()"
|
||||
@click="($event.target as HTMLInputElement).select()"
|
||||
@pointerup="($event.target as HTMLInputElement).select()"
|
||||
@focusin="open = true"
|
||||
data-testid="time_picker_hour"
|
||||
data-testid="time_picker_input"
|
||||
type="text" />
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
|
||||
@@ -57,27 +57,27 @@ watch(focused, (newValue, oldValue) => {
|
||||
class="grid grid-cols-2 divide-x divide-card-background-separator text-center py-2">
|
||||
<div class="px-2">
|
||||
<div class="font-bold text-white text-sm pb-2">Start</div>
|
||||
<div class="space-y-1">
|
||||
<div class="space-y-2">
|
||||
<TimePicker
|
||||
data-testid="time_entry_range_start"
|
||||
:focus
|
||||
@changed="updateTimeEntry"
|
||||
v-model="tempStart"></TimePicker>
|
||||
<DatePicker
|
||||
class="text-sm px-2 py-1"
|
||||
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
|
||||
@changed="updateTimeEntry"
|
||||
v-model="tempStart"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2">
|
||||
<div class="font-bold text-white text-sm pb-2">End</div>
|
||||
<div v-if="tempEnd !== null" class="space-y-1">
|
||||
<div v-if="tempEnd !== null" class="space-y-2">
|
||||
<TimePicker
|
||||
data-testid="time_entry_range_end"
|
||||
@changed="updateTimeEntry"
|
||||
v-model="tempEnd"></TimePicker>
|
||||
<DatePicker
|
||||
class="text-sm px-2 py-1"
|
||||
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
|
||||
@changed="updateTimeEntry"
|
||||
v-model="tempEnd"></DatePicker>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user