mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 20:22:15 +01:00
improve time picker parsing, fix nested escape listeners, change project member select
This commit is contained in:
@@ -24,6 +24,7 @@ test('test that updating project member billable rate works for existing time en
|
|||||||
await page.getByRole('button', { name: 'Add Member' }).click();
|
await page.getByRole('button', { name: 'Add Member' }).click();
|
||||||
|
|
||||||
await expect(page.getByText('Add Project Member').first()).toBeVisible();
|
await expect(page.getByText('Add Project Member').first()).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Select a member' }).click();
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await page.getByRole('button', { name: 'Add Project Member' }).click();
|
await page.getByRole('button', { name: 'Add Project Member' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -191,14 +191,7 @@ test('test that updating a the start of an existing time entry in the overview w
|
|||||||
'time_entry_range_selector'
|
'time_entry_range_selector'
|
||||||
);
|
);
|
||||||
await timeEntryRangeElement.click();
|
await timeEntryRangeElement.click();
|
||||||
await page
|
await page.getByTestId('time_picker_input').first().fill('1');
|
||||||
.getByTestId('time_entry_range_start')
|
|
||||||
.getByTestId('time_picker_hour')
|
|
||||||
.fill('1');
|
|
||||||
await page
|
|
||||||
.getByTestId('time_entry_range_start')
|
|
||||||
.getByTestId('time_picker_minute')
|
|
||||||
.fill('1');
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForResponse(async (response) => {
|
page.waitForResponse(async (response) => {
|
||||||
return (
|
return (
|
||||||
@@ -213,7 +206,7 @@ test('test that updating a the start of an existing time entry in the overview w
|
|||||||
}),
|
}),
|
||||||
page
|
page
|
||||||
.getByTestId('time_entry_range_end')
|
.getByTestId('time_entry_range_end')
|
||||||
.getByTestId('time_picker_minute')
|
.getByTestId('time_picker_input')
|
||||||
.press('Enter'),
|
.press('Enter'),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import ClientDropdownItem from '@/packages/ui/src/Client/ClientDropdownItem.vue';
|
|
||||||
import { useMembersStore } from '@/utils/useMembers';
|
import { useMembersStore } from '@/utils/useMembers';
|
||||||
import { UserIcon, XMarkIcon } from '@heroicons/vue/24/solid';
|
import { UserIcon, ChevronDownIcon } from '@heroicons/vue/24/solid';
|
||||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
|
||||||
import { useFocus } from '@vueuse/core';
|
import { useFocus } from '@vueuse/core';
|
||||||
import type { ProjectMember } from '@/packages/api/src';
|
import type { ProjectMember } from '@/packages/api/src';
|
||||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
import { Badge, SelectDropdown } from '@/packages/ui/src';
|
||||||
|
import type { Member } from '@/packages/api/src';
|
||||||
|
|
||||||
const membersStore = useMembersStore();
|
const membersStore = useMembersStore();
|
||||||
const { members } = storeToRefs(membersStore);
|
const { members } = storeToRefs(membersStore);
|
||||||
@@ -31,13 +30,9 @@ const searchInput = ref<HTMLInputElement | null>(null);
|
|||||||
|
|
||||||
const searchValue = ref('');
|
const searchValue = ref('');
|
||||||
|
|
||||||
function isMemberSelected(id: string) {
|
|
||||||
return model.value === id;
|
|
||||||
}
|
|
||||||
|
|
||||||
useFocus(searchInput, { initialValue: true });
|
useFocus(searchInput, { initialValue: true });
|
||||||
|
|
||||||
const filteredMembers = computed(() => {
|
const filteredMembers = computed<Member[]>(() => {
|
||||||
return members.value.filter((member) => {
|
return members.value.filter((member) => {
|
||||||
return (
|
return (
|
||||||
member.name
|
member.name
|
||||||
@@ -65,70 +60,7 @@ function resetHighlightedItem() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSearchValue(event: Event) {
|
|
||||||
const newInput = (event.target as HTMLInputElement).value;
|
|
||||||
if (newInput === ' ') {
|
|
||||||
searchValue.value = '';
|
|
||||||
const highlightedClientId = highlightedItemId.value;
|
|
||||||
if (highlightedClientId) {
|
|
||||||
const highlightedClient = members.value.find(
|
|
||||||
(member) => member.id === highlightedClientId
|
|
||||||
);
|
|
||||||
if (highlightedClient) {
|
|
||||||
model.value = highlightedClient.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
searchValue.value = newInput;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
|
||||||
|
|
||||||
function updateMember(newValue: string | null) {
|
|
||||||
if (newValue) {
|
|
||||||
model.value = newValue;
|
|
||||||
nextTick(() => {
|
|
||||||
emit('changed');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveHighlightUp() {
|
|
||||||
if (highlightedItem.value) {
|
|
||||||
const currentHightlightedIndex = filteredMembers.value.indexOf(
|
|
||||||
highlightedItem.value
|
|
||||||
);
|
|
||||||
if (currentHightlightedIndex === 0) {
|
|
||||||
highlightedItemId.value =
|
|
||||||
filteredMembers.value[filteredMembers.value.length - 1].id;
|
|
||||||
} else {
|
|
||||||
highlightedItemId.value =
|
|
||||||
filteredMembers.value[currentHightlightedIndex - 1].id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveHighlightDown() {
|
|
||||||
if (highlightedItem.value) {
|
|
||||||
const currentHightlightedIndex = filteredMembers.value.indexOf(
|
|
||||||
highlightedItem.value
|
|
||||||
);
|
|
||||||
if (currentHightlightedIndex === filteredMembers.value.length - 1) {
|
|
||||||
highlightedItemId.value = filteredMembers.value[0].id;
|
|
||||||
} else {
|
|
||||||
highlightedItemId.value =
|
|
||||||
filteredMembers.value[currentHightlightedIndex + 1].id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const highlightedItemId = ref<string | null>(null);
|
const highlightedItemId = ref<string | null>(null);
|
||||||
const highlightedItem = computed(() => {
|
|
||||||
return members.value.find(
|
|
||||||
(member) => member.id === highlightedItemId.value
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentValue = computed(() => {
|
const currentValue = computed(() => {
|
||||||
if (model.value) {
|
if (model.value) {
|
||||||
@@ -136,70 +68,27 @@ const currentValue = computed(() => {
|
|||||||
}
|
}
|
||||||
return searchValue.value;
|
return searchValue.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasMemberSelected = computed(() => {
|
|
||||||
return model.value !== '';
|
|
||||||
});
|
|
||||||
|
|
||||||
const showMembersDropdown = ref(true);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dropdown
|
<SelectDropdown
|
||||||
align="bottom-start"
|
v-model="model"
|
||||||
width="300"
|
:items="filteredMembers"
|
||||||
v-model="showMembersDropdown"
|
:get-key-from-item="(member) => member.id"
|
||||||
:closeOnContentClick="true">
|
:get-name-for-item="(member) => member.name">
|
||||||
<template #trigger>
|
<template v-slot:trigger>
|
||||||
<div class="flex relative">
|
<Badge
|
||||||
<div
|
tag="button"
|
||||||
ref="reference"
|
class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
|
||||||
class="absolute h-full items-center px-3 w-full flex justify-between">
|
<UserIcon class="relative z-10 w-4 text-muted"></UserIcon>
|
||||||
<UserIcon class="relative z-10 w-4 text-muted"></UserIcon>
|
<div v-if="currentValue" class="flex-1 truncate">
|
||||||
<button
|
{{ currentValue }}
|
||||||
v-if="hasMemberSelected"
|
|
||||||
@click="model = ''"
|
|
||||||
class="focus:text-accent-200 focus:bg-card-background text-muted">
|
|
||||||
<XMarkIcon class="relative z-10 w-4"></XMarkIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<TextInput
|
<div class="flex-1" v-else>Select a member...</div>
|
||||||
:value="currentValue"
|
<ChevronDownIcon class="w-4 text-muted"></ChevronDownIcon>
|
||||||
:disabled="disabled"
|
</Badge>
|
||||||
@input="updateSearchValue"
|
|
||||||
data-testid="member_dropdown_search"
|
|
||||||
@keydown.enter.prevent="updateMember(highlightedItemId)"
|
|
||||||
@keydown.up.prevent="moveHighlightUp"
|
|
||||||
class="relative w-full pl-10"
|
|
||||||
@keydown.down.prevent="moveHighlightDown"
|
|
||||||
placeholder="Search for a member..."
|
|
||||||
ref="searchInput" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
</SelectDropdown>
|
||||||
<div
|
|
||||||
class="py-2 text-white px-3"
|
|
||||||
v-if="filteredMembers.length === 0">
|
|
||||||
All members are already added.
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="member in filteredMembers"
|
|
||||||
:key="member.id"
|
|
||||||
role="option"
|
|
||||||
:value="member.id"
|
|
||||||
:class="{
|
|
||||||
'bg-card-background-active':
|
|
||||||
member.id === highlightedItemId,
|
|
||||||
}"
|
|
||||||
@click="updateMember(member.id)"
|
|
||||||
data-testid="client_dropdown_entries"
|
|
||||||
:data-client-id="member.id">
|
|
||||||
<ClientDropdownItem
|
|
||||||
:selected="isMemberSelected(member.id)"
|
|
||||||
:name="member.name"></ClientDropdownItem>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Dropdown>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ const roleDescription = computed(() => {
|
|||||||
v-if="billableRateSelect === 'custom-rate'">
|
v-if="billableRateSelect === 'custom-rate'">
|
||||||
<InputLabel
|
<InputLabel
|
||||||
for="memberBillableRate"
|
for="memberBillableRate"
|
||||||
|
class="mb-2"
|
||||||
value="Billable Rate" />
|
value="Billable Rate" />
|
||||||
<BillableRateInput
|
<BillableRateInput
|
||||||
focus
|
focus
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ useFocus(projectNameInput, { initialValue: true });
|
|||||||
<div class="col-span-3 sm:col-span-1 flex-1">
|
<div class="col-span-3 sm:col-span-1 flex-1">
|
||||||
<InputLabel
|
<InputLabel
|
||||||
for="billable_rate"
|
for="billable_rate"
|
||||||
|
class="mb-2"
|
||||||
value="Billable Rate"></InputLabel>
|
value="Billable Rate"></InputLabel>
|
||||||
<BillableRateInput
|
<BillableRateInput
|
||||||
@keydown.enter="submit"
|
@keydown.enter="submit"
|
||||||
|
|||||||
@@ -219,7 +219,6 @@ const billableProxy = computed({
|
|||||||
<InputLabel>Duration</InputLabel>
|
<InputLabel>Duration</InputLabel>
|
||||||
<div class="space-y-2 mt-1 flex flex-col">
|
<div class="space-y-2 mt-1 flex flex-col">
|
||||||
<DurationHumanInput
|
<DurationHumanInput
|
||||||
class="h-full text-white py-2 flex-1 rounded-r-lg text-left px-3 text-base lg:text-lg font-bold border-input-border border rounded-lg bg-card-background placeholder-muted focus:ring-0 transition"
|
|
||||||
v-model:start="localStart"
|
v-model:start="localStart"
|
||||||
v-model:end="localEnd"></DurationHumanInput>
|
v-model:end="localEnd"></DurationHumanInput>
|
||||||
<div class="text-sm flex space-x-1">
|
<div class="text-sm flex space-x-1">
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ function checkForConfirmationModal() {
|
|||||||
<div class="col-span-6 sm:col-span-4">
|
<div class="col-span-6 sm:col-span-4">
|
||||||
<InputLabel
|
<InputLabel
|
||||||
for="organizationBillableRate"
|
for="organizationBillableRate"
|
||||||
|
class="mb-2"
|
||||||
value="Organization Billable Rate" />
|
value="Organization Billable Rate" />
|
||||||
<BillableRateInput
|
<BillableRateInput
|
||||||
v-if="organization"
|
v-if="organization"
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const inputValue = ref(formatValue(model.value));
|
|||||||
type="text"
|
type="text"
|
||||||
:name="name"
|
:name="name"
|
||||||
placeholder="Billable Rate"
|
placeholder="Billable Rate"
|
||||||
class="mt-2 block w-full"
|
class="block w-full"
|
||||||
autocomplete="teamMemberRate" />
|
autocomplete="teamMemberRate" />
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 right-0 h-full flex items-center px-4 font-medium pointer-events-none">
|
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"
|
@keydown.enter="updateDate"
|
||||||
:class="
|
:class="
|
||||||
twMerge(
|
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
|
props.class
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref } from 'vue';
|
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import {
|
import {
|
||||||
flip,
|
flip,
|
||||||
limitShift,
|
limitShift,
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
} from '@floating-ui/vue';
|
} from '@floating-ui/vue';
|
||||||
import { offset } from '@floating-ui/vue';
|
import { offset } from '@floating-ui/vue';
|
||||||
import { autoUpdate } 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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -24,17 +26,28 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const emit = defineEmits(['open', 'submit']);
|
const emit = defineEmits(['open', 'submit']);
|
||||||
const open = defineModel({ default: false });
|
const open = defineModel({ default: false });
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
const closeOnEscape = (e: KeyboardEvent) => {
|
const closeOnEscape = (e: KeyboardEvent) => {
|
||||||
if (open.value && e.key === 'Escape') {
|
if (isLastLayer(id)) {
|
||||||
open.value = false;
|
if (open.value && e.key === 'Escape') {
|
||||||
}
|
open.value = false;
|
||||||
if (open.value && e.key === 'Enter') {
|
}
|
||||||
emit('submit');
|
if (open.value && e.key === 'Enter') {
|
||||||
if (props.closeOnContentClick) open.value = false;
|
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));
|
onMounted(() => document.addEventListener('keydown', closeOnEscape));
|
||||||
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));
|
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import parse from 'parse-duration';
|
import parse from 'parse-duration';
|
||||||
import { computed, ref } from 'vue';
|
import { onMounted, ref, watch } from 'vue';
|
||||||
import {
|
import {
|
||||||
formatHumanReadableDuration,
|
formatHumanReadableDuration,
|
||||||
getDayJsInstance,
|
getDayJsInstance,
|
||||||
} from '@/packages/ui/src/utils/time';
|
} from '@/packages/ui/src/utils/time';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
import { TextInput } from '@/packages/ui/src';
|
||||||
const temporaryCustomTimerEntry = ref<string>('');
|
const temporaryCustomTimerEntry = ref<string>('');
|
||||||
|
|
||||||
const start = defineModel('start', {
|
const start = defineModel('start', {
|
||||||
@@ -49,7 +50,7 @@ function updateDuration() {
|
|||||||
start.value = newStartDate.utc().format();
|
start.value = newStartDate.utc().format();
|
||||||
}
|
}
|
||||||
// fallback to minutes if just a number is given
|
// fallback to minutes if just a number is given
|
||||||
temporaryCustomTimerEntry.value = '';
|
updateTimeEntryInputValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNumeric(value: string) {
|
function isNumeric(value: string) {
|
||||||
@@ -62,39 +63,24 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;
|
const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;
|
||||||
|
|
||||||
const currentTime = computed({
|
watch([start, end], updateTimeEntryInputValue);
|
||||||
get() {
|
onMounted(() => updateTimeEntryInputValue());
|
||||||
if (temporaryCustomTimerEntry.value !== '') {
|
|
||||||
return temporaryCustomTimerEntry.value;
|
function updateTimeEntryInputValue() {
|
||||||
}
|
if (start.value && end.value) {
|
||||||
if (start.value && end.value) {
|
const startTime = dayjs(start.value);
|
||||||
const startTime = dayjs(start.value);
|
const diff = getDayJsInstance()(end.value).diff(startTime, 'seconds');
|
||||||
const diff = getDayJsInstance()(end.value).diff(
|
temporaryCustomTimerEntry.value = formatHumanReadableDuration(diff);
|
||||||
startTime,
|
}
|
||||||
'seconds'
|
}
|
||||||
);
|
|
||||||
return formatHumanReadableDuration(diff);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
// setter
|
|
||||||
set(newValue) {
|
|
||||||
if (newValue) {
|
|
||||||
temporaryCustomTimerEntry.value = newValue;
|
|
||||||
} else {
|
|
||||||
temporaryCustomTimerEntry.value = '';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<input
|
<TextInput
|
||||||
placeholder="00:00:00"
|
|
||||||
ref="inputField"
|
ref="inputField"
|
||||||
@blur="updateDuration"
|
@blur="updateDuration"
|
||||||
@keydown.enter="updateDuration"
|
@keydown.enter="updateDuration"
|
||||||
v-model="currentTime"
|
v-model="temporaryCustomTimerEntry"
|
||||||
:class="twMerge('text-text-secondary', props.class)"
|
:class="twMerge('text-text-secondary', props.class)"
|
||||||
type="text" />
|
type="text" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue';
|
|||||||
import { onKeyStroke } from '@vueuse/core';
|
import { onKeyStroke } from '@vueuse/core';
|
||||||
import { type Placement } from '@floating-ui/vue';
|
import { type Placement } from '@floating-ui/vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
const model = defineModel<string | null>({
|
const model = defineModel<string | null>({
|
||||||
default: 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, () => {
|
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]);
|
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']);
|
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||||
|
|
||||||
function setItem(newValue: string | null) {
|
function setItem(newValue: string | null) {
|
||||||
@@ -89,7 +112,6 @@ function moveHighlightDown() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlightedItemId = ref<string | null>(model.value);
|
|
||||||
const highlightedItem = computed(() => {
|
const highlightedItem = computed(() => {
|
||||||
return props.items.find(
|
return props.items.find(
|
||||||
(item) => props.getKeyFromItem(item) === highlightedItemId.value
|
(item) => props.getKeyFromItem(item) === highlightedItemId.value
|
||||||
@@ -120,20 +142,16 @@ onKeyStroke('Enter', (e) => {
|
|||||||
watch(open, () => {
|
watch(open, () => {
|
||||||
if (open.value === true) {
|
if (open.value === true) {
|
||||||
nextTick(() => {
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -145,7 +163,10 @@ function scrollCurrentItemInView() {
|
|||||||
<div
|
<div
|
||||||
ref="dropdownViewport"
|
ref="dropdownViewport"
|
||||||
:class="
|
: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
|
<div
|
||||||
v-for="item in filteredItems"
|
v-for="item in filteredItems"
|
||||||
@@ -153,12 +174,14 @@ function scrollCurrentItemInView() {
|
|||||||
role="option"
|
role="option"
|
||||||
:data-select-id="props.getKeyFromItem(item)"
|
:data-select-id="props.getKeyFromItem(item)"
|
||||||
:value="props.getKeyFromItem(item)"
|
:value="props.getKeyFromItem(item)"
|
||||||
:class="{
|
|
||||||
'bg-card-background-active':
|
|
||||||
props.getKeyFromItem(item) === highlightedItemId,
|
|
||||||
}"
|
|
||||||
:data-item-id="props.getKeyFromItem(item)">
|
:data-item-id="props.getKeyFromItem(item)">
|
||||||
<SelectDropdownItem
|
<SelectDropdownItem
|
||||||
|
@mouseenter="
|
||||||
|
highlightedItemId = props.getKeyFromItem(item)
|
||||||
|
"
|
||||||
|
:highlighted="
|
||||||
|
props.getKeyFromItem(item) === highlightedItemId
|
||||||
|
"
|
||||||
:selected="props.getKeyFromItem(item) === model"
|
:selected="props.getKeyFromItem(item) === model"
|
||||||
@click="setItem(props.getKeyFromItem(item))"
|
@click="setItem(props.getKeyFromItem(item))"
|
||||||
:name="props.getNameForItem(item)"></SelectDropdownItem>
|
:name="props.getNameForItem(item)"></SelectDropdownItem>
|
||||||
|
|||||||
@@ -4,20 +4,24 @@ import { twMerge } from 'tailwind-merge';
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
name: string;
|
name: string;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
highlighted: boolean;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="px-1">
|
||||||
:class="
|
<div
|
||||||
twMerge(
|
:class="
|
||||||
'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 ',
|
twMerge(
|
||||||
props.selected
|
'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 ',
|
||||||
? 'bg-accent-300/20'
|
props.highlighted && 'bg-card-background-active',
|
||||||
: 'hover:bg-card-background-active'
|
props.selected
|
||||||
)
|
? 'bg-accent-300/20'
|
||||||
">
|
: 'hover:bg-card-background-active'
|
||||||
<span>{{ name }}</span>
|
)
|
||||||
|
">
|
||||||
|
<span>{{ name }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
name?: string;
|
name?: string;
|
||||||
|
class?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const input = ref<HTMLInputElement | null>(null);
|
const input = ref<HTMLInputElement | null>(null);
|
||||||
@@ -20,7 +22,12 @@ const model = defineModel();
|
|||||||
<template>
|
<template>
|
||||||
<input
|
<input
|
||||||
ref="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"
|
v-model="model"
|
||||||
:name="name" />
|
:name="name" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@/packages/ui/src/utils/time';
|
} from '@/packages/ui/src/utils/time';
|
||||||
import { useFocus } from '@vueuse/core';
|
import { useFocus } from '@vueuse/core';
|
||||||
import { SelectDropdown, TextInput } from '@/packages/ui/src';
|
import { SelectDropdown, TextInput } from '@/packages/ui/src';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
// This has to be a localized timestamp, not UTC
|
// This has to be a localized timestamp, not UTC
|
||||||
const model = defineModel<string | null>({
|
const model = defineModel<string | null>({
|
||||||
@@ -36,6 +37,47 @@ function updateTime(event: Event) {
|
|||||||
emit('changed', model.value);
|
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');
|
inputValue.value = getLocalizedDayJs(model.value).format('HH:mm');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +138,7 @@ const closestValue = computed({
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex min-w-0 items-center justify-center text-white">
|
<div class="flex min-w-0 items-center justify-center text-white">
|
||||||
<SelectDropdown
|
<SelectDropdown
|
||||||
class="min-w-0 w-28"
|
:class="twMerge('mine-w-0 w-24', size === 'large' && 'w-28')"
|
||||||
v-model="closestValue"
|
v-model="closestValue"
|
||||||
v-model:open="open"
|
v-model:open="open"
|
||||||
:get-key-from-item="(item) => item.timestamp"
|
:get-key-from-item="(item) => item.timestamp"
|
||||||
@@ -106,18 +148,24 @@ const closestValue = computed({
|
|||||||
<TextInput
|
<TextInput
|
||||||
v-model="inputValue"
|
v-model="inputValue"
|
||||||
ref="timeInput"
|
ref="timeInput"
|
||||||
class="w-28 text-center"
|
:class="
|
||||||
|
twMerge(
|
||||||
|
'text-center w-24 px-3 py-2',
|
||||||
|
size === 'large' && 'w-28'
|
||||||
|
)
|
||||||
|
"
|
||||||
@blur="updateTime"
|
@blur="updateTime"
|
||||||
@keydown.enter="
|
@keydown.enter="
|
||||||
updateTime($event);
|
updateTime($event);
|
||||||
open = false;
|
open = false;
|
||||||
"
|
"
|
||||||
|
@keydown.tab="open = false"
|
||||||
@focus="($event.target as HTMLInputElement).select()"
|
@focus="($event.target as HTMLInputElement).select()"
|
||||||
@mouseup="($event.target as HTMLInputElement).select()"
|
@mouseup="($event.target as HTMLInputElement).select()"
|
||||||
@click="($event.target as HTMLInputElement).select()"
|
@click="($event.target as HTMLInputElement).select()"
|
||||||
@pointerup="($event.target as HTMLInputElement).select()"
|
@pointerup="($event.target as HTMLInputElement).select()"
|
||||||
@focusin="open = true"
|
@focusin="open = true"
|
||||||
data-testid="time_picker_hour"
|
data-testid="time_picker_input"
|
||||||
type="text" />
|
type="text" />
|
||||||
</template>
|
</template>
|
||||||
</SelectDropdown>
|
</SelectDropdown>
|
||||||
|
|||||||
@@ -57,27 +57,27 @@ watch(focused, (newValue, oldValue) => {
|
|||||||
class="grid grid-cols-2 divide-x divide-card-background-separator text-center py-2">
|
class="grid grid-cols-2 divide-x divide-card-background-separator text-center py-2">
|
||||||
<div class="px-2">
|
<div class="px-2">
|
||||||
<div class="font-bold text-white text-sm pb-2">Start</div>
|
<div class="font-bold text-white text-sm pb-2">Start</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-2">
|
||||||
<TimePicker
|
<TimePicker
|
||||||
data-testid="time_entry_range_start"
|
data-testid="time_entry_range_start"
|
||||||
:focus
|
:focus
|
||||||
@changed="updateTimeEntry"
|
@changed="updateTimeEntry"
|
||||||
v-model="tempStart"></TimePicker>
|
v-model="tempStart"></TimePicker>
|
||||||
<DatePicker
|
<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"
|
@changed="updateTimeEntry"
|
||||||
v-model="tempStart"></DatePicker>
|
v-model="tempStart"></DatePicker>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-2">
|
<div class="px-2">
|
||||||
<div class="font-bold text-white text-sm pb-2">End</div>
|
<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
|
<TimePicker
|
||||||
data-testid="time_entry_range_end"
|
data-testid="time_entry_range_end"
|
||||||
@changed="updateTimeEntry"
|
@changed="updateTimeEntry"
|
||||||
v-model="tempEnd"></TimePicker>
|
v-model="tempEnd"></TimePicker>
|
||||||
<DatePicker
|
<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"
|
@changed="updateTimeEntry"
|
||||||
v-model="tempEnd"></DatePicker>
|
v-model="tempEnd"></DatePicker>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, watch } from 'vue';
|
||||||
|
import { useId } from 'radix-vue';
|
||||||
|
import { isLastLayer, layers } from '@/packages/ui/src/utils/dismissableLayer';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
@@ -34,20 +36,35 @@ const close = () => {
|
|||||||
emit('close');
|
emit('close');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
const closeOnEscape = (e: KeyboardEvent) => {
|
const closeOnEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape' && props.show) {
|
if (isLastLayer(id)) {
|
||||||
close();
|
if (e.key === 'Escape' && props.show) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => document.addEventListener('keydown', closeOnEscape));
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', closeOnEscape);
|
||||||
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('keydown', closeOnEscape);
|
document.removeEventListener('keydown', closeOnEscape);
|
||||||
document.body.style.overflow = 'visible';
|
document.body.style.overflow = 'visible';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(value) => {
|
||||||
|
if (value) {
|
||||||
|
layers.value.push(id);
|
||||||
|
} else {
|
||||||
|
layers.value = layers.value.filter((layer) => layer !== id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
const maxWidthClass = computed(() => {
|
const maxWidthClass = computed(() => {
|
||||||
return {
|
return {
|
||||||
sm: 'sm:max-w-sm',
|
sm: 'sm:max-w-sm',
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const emit = defineEmits(['submit']);
|
|||||||
<div
|
<div
|
||||||
class="sm:max-w-[120px]"
|
class="sm:max-w-[120px]"
|
||||||
v-if="billableRateSelect === 'custom-rate'">
|
v-if="billableRateSelect === 'custom-rate'">
|
||||||
<InputLabel for="billableRate" value="Billable Rate" />
|
<InputLabel for="billableRate" value="Billable Rate" class="mb-2" />
|
||||||
<BillableRateInput
|
<BillableRateInput
|
||||||
@keydown.enter="emit('submit')"
|
@keydown.enter="emit('submit')"
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
|||||||
import { defineProps, ref } from 'vue';
|
import { defineProps, ref } from 'vue';
|
||||||
import { formatDate, formatStartEnd } from '@/packages/ui/src/utils/time';
|
import { formatDate, formatStartEnd } from '@/packages/ui/src/utils/time';
|
||||||
import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';
|
import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
start: string;
|
start: string;
|
||||||
@@ -27,11 +28,15 @@ const open = ref(false);
|
|||||||
<template #trigger>
|
<template #trigger>
|
||||||
<button
|
<button
|
||||||
data-testid="time_entry_range_selector"
|
data-testid="time_entry_range_selector"
|
||||||
class="text-muted w-[110px] px-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
|
:class="
|
||||||
:class="{
|
twMerge(
|
||||||
'text-sm py-2 font-medium': !showDate,
|
'text-muted w-[110px] px-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80',
|
||||||
'text-xs py-1.5 font-semibold': showDate,
|
showDate
|
||||||
}">
|
? 'text-xs py-1.5 font-semibold'
|
||||||
|
: 'text-sm py-2 font-medium',
|
||||||
|
open && 'border-card-border bg-card-background'
|
||||||
|
)
|
||||||
|
">
|
||||||
{{ formatStartEnd(start, end) }}
|
{{ formatStartEnd(start, end) }}
|
||||||
<span v-if="showDate" class="text-text-tertiary font-medium"
|
<span v-if="showDate" class="text-text-tertiary font-medium"
|
||||||
>{{ formatDate(start) }}
|
>{{ formatDate(start) }}
|
||||||
|
|||||||
@@ -71,8 +71,9 @@ function selectInput(event: Event) {
|
|||||||
<template #trigger>
|
<template #trigger>
|
||||||
<input
|
<input
|
||||||
data-testid="time_entry_duration_input"
|
data-testid="time_entry_duration_input"
|
||||||
class="text-white w-[100px] px-3 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold"
|
class="text-white w-[100px] px-3 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-white/80"
|
||||||
@focus="selectInput"
|
@focus="selectInput"
|
||||||
|
@keydown.tab="open = false"
|
||||||
@blur="updateTimerAndStartLiveTimerUpdate"
|
@blur="updateTimerAndStartLiveTimerUpdate"
|
||||||
@keydown.enter="updateTimerAndStartLiveTimerUpdate"
|
@keydown.enter="updateTimerAndStartLiveTimerUpdate"
|
||||||
v-model="currentTime" />
|
v-model="currentTime" />
|
||||||
|
|||||||
7
resources/js/packages/ui/src/utils/dismissableLayer.ts
Normal file
7
resources/js/packages/ui/src/utils/dismissableLayer.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export const layers = ref<string[]>([]);
|
||||||
|
|
||||||
|
export function isLastLayer(id: string) {
|
||||||
|
return layers.value[layers.value.length - 1] === id;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user