mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-14 03:02:15 +01:00
move ui and api to seperate packages and add npm actions for them
This commit is contained in:
96
resources/js/packages/ui/src/Input/BillableRateInput.vue
Normal file
96
resources/js/packages/ui/src/Input/BillableRateInput.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||
import {
|
||||
formatCents,
|
||||
getOrganizationCurrencySymbol,
|
||||
} from '@/packages/ui/src/utils/money';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
focus?: boolean;
|
||||
currency: string;
|
||||
}>();
|
||||
|
||||
const model = defineModel<number | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const billableRateInput = ref<HTMLInputElement | null>(null);
|
||||
useFocus(billableRateInput, { initialValue: props.focus });
|
||||
|
||||
function cleanUpDecimalValue(value: string) {
|
||||
value = value.replace(/,/g, '');
|
||||
value = value.replace(props.currency, '');
|
||||
return value.replace(/\./g, '');
|
||||
}
|
||||
|
||||
function updateRate(value: string) {
|
||||
value = value.trim();
|
||||
if (value.includes(',')) {
|
||||
const parts = value.split(',');
|
||||
const lastPart = (parts[parts.length - 1] = parts[parts.length - 1]);
|
||||
if (lastPart.length === 2) {
|
||||
// we detected a decimal number with 2 digits after the comma
|
||||
value = cleanUpDecimalValue(value);
|
||||
model.value = parseInt(value);
|
||||
}
|
||||
} else if (value.includes('.')) {
|
||||
const parts = value.split('.');
|
||||
const lastPart = (parts[parts.length - 1] = parts[parts.length - 1]);
|
||||
if (lastPart.length === 2) {
|
||||
value = cleanUpDecimalValue(value);
|
||||
model.value = parseInt(value);
|
||||
}
|
||||
} else if (value === '') {
|
||||
model.value = 0;
|
||||
} else {
|
||||
// if it doesn't contain a comma or a dot, it's probably a whole number so let's convert it to cents
|
||||
const parsedValue = parseInt(cleanUpDecimalValue(value)) * 100;
|
||||
if (parsedValue) {
|
||||
model.value = parsedValue;
|
||||
} else {
|
||||
model.value = 0;
|
||||
}
|
||||
}
|
||||
inputValue.value = formatValue(model.value);
|
||||
}
|
||||
|
||||
function formatValue(modelValue: number | null) {
|
||||
const formattedValue = formatCents(modelValue ?? 0, props.currency);
|
||||
return formattedValue
|
||||
.replace(getOrganizationCurrencySymbol(props.currency), '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
watch(model, (newValue) => {
|
||||
inputValue.value = formatValue(newValue);
|
||||
});
|
||||
|
||||
const inputValue = ref(formatValue(model.value));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<TextInput
|
||||
:id="name"
|
||||
ref="billableRateInput"
|
||||
v-model="inputValue"
|
||||
@blur="updateRate($event.target.value)"
|
||||
@keydown.enter="updateRate($event.target.value)"
|
||||
type="text"
|
||||
:name="name"
|
||||
placeholder="Billable Rate"
|
||||
class="mt-2 block w-full"
|
||||
autocomplete="teamMemberRate" />
|
||||
<div
|
||||
class="absolute top-0 right-0 h-full flex items-center px-4 font-medium pointer-events-none">
|
||||
<span>
|
||||
{{ currency }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
55
resources/js/packages/ui/src/Input/BillableToggleButton.vue
Normal file
55
resources/js/packages/ui/src/Input/BillableToggleButton.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
const active = defineModel({ default: false });
|
||||
const emit = defineEmits(['changed']);
|
||||
function toggleBillable() {
|
||||
active.value = !active.value;
|
||||
emit('changed', active.value);
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size: 'small' | 'base';
|
||||
}>(),
|
||||
{
|
||||
size: 'base',
|
||||
}
|
||||
);
|
||||
|
||||
const iconColorClasses = computed(() => {
|
||||
if (active.value) {
|
||||
return 'text-accent-300 focus:text-accent-200 hover:text-accent-200';
|
||||
} else {
|
||||
return 'text-icon-default focus:text-icon-active hover:text-icon-active';
|
||||
}
|
||||
});
|
||||
|
||||
const iconSizeClasses = computed(() => {
|
||||
if (props.size === 'small') {
|
||||
return 'w-5 h-5';
|
||||
} else {
|
||||
return 'w-5 lg:w-6 h-5 lg:h-6';
|
||||
}
|
||||
});
|
||||
|
||||
const iconSizeWrapperClasses =
|
||||
props.size === 'small' ? 'w-6 sm:w-8 h-6 sm:h-8' : 'w-11 h-11';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
@click="toggleBillable"
|
||||
:class="
|
||||
twMerge(
|
||||
iconColorClasses,
|
||||
iconSizeWrapperClasses,
|
||||
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-separator hover:bg-card-background-separator rounded-full flex items-center justify-center'
|
||||
)
|
||||
">
|
||||
<BillableIcon :class="iconSizeClasses"></BillableIcon>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
34
resources/js/packages/ui/src/Input/Checkbox.vue
Normal file
34
resources/js/packages/ui/src/Input/Checkbox.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const emit = defineEmits(['update:checked']);
|
||||
|
||||
const props = defineProps({
|
||||
checked: {
|
||||
type: [Array, Boolean],
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const proxyChecked = computed({
|
||||
get() {
|
||||
return props.checked;
|
||||
},
|
||||
|
||||
set(val) {
|
||||
emit('update:checked', val);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="proxyChecked"
|
||||
type="checkbox"
|
||||
:value="value"
|
||||
class="rounded bg-input-background border-input-border text-indigo-600 shadow-sm focus:ring-indigo-500" />
|
||||
</template>
|
||||
73
resources/js/packages/ui/src/Input/DatePicker.vue
Normal file
73
resources/js/packages/ui/src/Input/DatePicker.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import {
|
||||
getDayJsInstance,
|
||||
getLocalizedDayJs,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string;
|
||||
}>();
|
||||
|
||||
// This has to be a localized timestamp, not UTC
|
||||
const model = defineModel<string | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const tempDate = ref(getLocalizedDayJs(model.value).format('YYYY-MM-DD'));
|
||||
|
||||
watch(model, (value) => {
|
||||
tempDate.value = getLocalizedDayJs(value).format('YYYY-MM-DD');
|
||||
});
|
||||
|
||||
function updateDate(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newValue = target.value;
|
||||
const newDate = getDayJsInstance()(newValue);
|
||||
if (newDate.isValid()) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('year', newDate.year())
|
||||
.set('month', newDate.month())
|
||||
.set('date', newDate.date())
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
}
|
||||
}
|
||||
|
||||
const datePicker = ref<HTMLInputElement | null>(null);
|
||||
|
||||
function updateTempValue(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
tempDate.value = target.value;
|
||||
}
|
||||
|
||||
const emit = defineEmits(['changed']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center text-muted">
|
||||
<input
|
||||
ref="datePicker"
|
||||
@change="updateTempValue"
|
||||
@blur="updateDate"
|
||||
@keydown.enter="updateDate"
|
||||
:class="
|
||||
twMerge(
|
||||
'bg-input-background border text-white border-input-border rounded-md',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
type="date"
|
||||
id="start"
|
||||
name="trip-start"
|
||||
:value="tempDate" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
input::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
opacity: 0.2;
|
||||
}
|
||||
</style>
|
||||
39
resources/js/packages/ui/src/Input/DateRangePicker.vue
Normal file
39
resources/js/packages/ui/src/Input/DateRangePicker.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { CalendarIcon } from '@heroicons/vue/20/solid';
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import { formatDate } from '@/packages/ui/src/utils/time';
|
||||
|
||||
const start = defineModel('start', { default: '' });
|
||||
const end = defineModel('end', { default: '' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown :close-on-content-click="false" align="bottom-end">
|
||||
<template #trigger>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-input-background border border-input-border font-medium rounded-lg flex items-center space-x-2">
|
||||
<CalendarIcon class="w-5"></CalendarIcon>
|
||||
<div class="text-white">
|
||||
{{ formatDate(start) }}
|
||||
<span class="px-1.5 text-muted">-</span>
|
||||
{{ formatDate(end) }}
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="overflow-hidden w-[280px] px-3 py-1.5">
|
||||
<div class="flex space-x-3 items-center justify-between">
|
||||
<div class="text-sm font-medium text-muted">Start Date</div>
|
||||
<DatePicker v-model="start"></DatePicker>
|
||||
</div>
|
||||
<div class="mt-2 flex space-x-3 items-center justify-between">
|
||||
<div class="text-sm font-medium text-muted">End Date</div>
|
||||
<DatePicker v-model="end"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
99
resources/js/packages/ui/src/Input/Dropdown.vue
Normal file
99
resources/js/packages/ui/src/Input/Dropdown.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import {
|
||||
flip,
|
||||
type Placement,
|
||||
type ReferenceElement,
|
||||
useFloating,
|
||||
} from '@floating-ui/vue';
|
||||
import { offset } from '@floating-ui/vue';
|
||||
import { autoUpdate } from '@floating-ui/vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
align: Placement;
|
||||
closeOnContentClick: boolean;
|
||||
}>(),
|
||||
{
|
||||
align: 'bottom-start',
|
||||
closeOnContentClick: true,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits(['open', 'submit']);
|
||||
const open = defineModel({ default: false });
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', closeOnEscape));
|
||||
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));
|
||||
|
||||
function onContentClick() {
|
||||
if (props.closeOnContentClick === true) {
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
open.value = !open.value;
|
||||
if (open.value === true) {
|
||||
emit('open');
|
||||
}
|
||||
}
|
||||
|
||||
function onBackgroundClick() {
|
||||
emit('submit');
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
const reference = ref<null | ReferenceElement>(null);
|
||||
const floating = ref(null);
|
||||
const { floatingStyles } = useFloating(reference, floating, {
|
||||
placement: props.align,
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [flip(), offset(10)],
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-w-0">
|
||||
<div @click.prevent="toggleOpen" ref="reference" class="min-w-0">
|
||||
<slot name="trigger" />
|
||||
</div>
|
||||
|
||||
<!-- Full Screen Dropdown Overlay -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-show="open"
|
||||
class="fixed inset-0 z-40"
|
||||
@click.prevent="onBackgroundClick" />
|
||||
<transition
|
||||
enter-active-class="transition-opacity ease-out duration-200"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition-opacity ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95">
|
||||
<div
|
||||
v-if="open"
|
||||
class="z-50"
|
||||
ref="floating"
|
||||
:style="floatingStyles"
|
||||
@click="onContentClick">
|
||||
<div
|
||||
class="rounded-lg ring-1 relative ring-black ring-opacity-5 border border-card-border overflow-none shadow-dropdown bg-card-background">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
13
resources/js/packages/ui/src/Input/InputError.vue
Normal file
13
resources/js/packages/ui/src/Input/InputError.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
message: String,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-show="message">
|
||||
<p class="text-sm text-red-400">
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
12
resources/js/packages/ui/src/Input/InputLabel.vue
Normal file
12
resources/js/packages/ui/src/Input/InputLabel.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
value: String,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="block font-medium text-sm text-white">
|
||||
<span v-if="value">{{ value }}</span>
|
||||
<span v-else><slot /></span>
|
||||
</label>
|
||||
</template>
|
||||
149
resources/js/packages/ui/src/Input/SelectDropdown.vue
Normal file
149
resources/js/packages/ui/src/Input/SelectDropdown.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { type Component, computed, ref, watch } from 'vue';
|
||||
import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue';
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
import { type Placement } from '@floating-ui/vue';
|
||||
|
||||
const model = defineModel<string | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: T[];
|
||||
getKeyFromItem: (item: T) => string | null;
|
||||
getNameForItem: (item: T) => string;
|
||||
align?: Placement;
|
||||
}>(),
|
||||
{
|
||||
align: 'bottom-start',
|
||||
}
|
||||
);
|
||||
|
||||
const open = ref(false);
|
||||
const dropdownViewport = ref<Component | null>(null);
|
||||
|
||||
const searchValue = ref('');
|
||||
|
||||
// DropdownMultiselect
|
||||
const filteredItems = computed<T[]>(() => {
|
||||
return props.items.filter((item: T) => {
|
||||
return props
|
||||
.getNameForItem(item)
|
||||
.toLowerCase()
|
||||
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
});
|
||||
});
|
||||
|
||||
watch(filteredItems, () => {
|
||||
if (filteredItems.value.length > 0) {
|
||||
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
|
||||
function setItem(newValue: string | null) {
|
||||
model.value = newValue;
|
||||
emit('changed');
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function moveHighlightUp() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
if (currentHightlightedIndex === 0) {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[filteredItems.value.length - 1]
|
||||
);
|
||||
} else {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[currentHightlightedIndex - 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveHighlightDown() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
if (currentHightlightedIndex === filteredItems.value.length - 1) {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[0]
|
||||
);
|
||||
} else {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[currentHightlightedIndex + 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const highlightedItemId = ref<string | null>(null);
|
||||
const highlightedItem = computed(() => {
|
||||
return props.items.find(
|
||||
(item) => props.getKeyFromItem(item) === highlightedItemId.value
|
||||
);
|
||||
});
|
||||
|
||||
onKeyStroke('ArrowDown', (e) => {
|
||||
if (open.value === true) {
|
||||
moveHighlightDown();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
onKeyStroke('ArrowUp', (e) => {
|
||||
if (open.value === true) {
|
||||
moveHighlightUp();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
onKeyStroke('Enter', (e) => {
|
||||
if (open.value === true) {
|
||||
setItem(highlightedItemId.value);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
watch(open, () => {
|
||||
if (open.value === true) {
|
||||
highlightedItemId.value = model.value;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown v-model="open" :align="align" :closeOnContentClick="false">
|
||||
<template #trigger>
|
||||
<slot name="trigger"> </slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<div ref="dropdownViewport" class="w-60">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="props.getKeyFromItem(item) ?? 'none'"
|
||||
role="option"
|
||||
:value="props.getKeyFromItem(item)"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
props.getKeyFromItem(item) === highlightedItemId,
|
||||
}"
|
||||
:data-item-id="props.getKeyFromItem(item)">
|
||||
<SelectDropdownItem
|
||||
:selected="props.getKeyFromItem(item) === model"
|
||||
@click="setItem(props.getKeyFromItem(item))"
|
||||
:name="props.getNameForItem(item)"></SelectDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
24
resources/js/packages/ui/src/Input/SelectDropdownItem.vue
Normal file
24
resources/js/packages/ui/src/Input/SelectDropdownItem.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
selected: 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>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
26
resources/js/packages/ui/src/Input/TextInput.vue
Normal file
26
resources/js/packages/ui/src/Input/TextInput.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
defineProps<{
|
||||
name?: string;
|
||||
}>();
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
if (input.value?.hasAttribute('autofocus')) {
|
||||
input.value?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ focus: () => input.value?.focus() });
|
||||
const model = defineModel();
|
||||
</script>
|
||||
|
||||
<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"
|
||||
v-model="model"
|
||||
:name="name" />
|
||||
</template>
|
||||
121
resources/js/packages/ui/src/Input/TimePicker.vue
Normal file
121
resources/js/packages/ui/src/Input/TimePicker.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import {
|
||||
getDayJsInstance,
|
||||
getLocalizedDayJs,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
|
||||
// This has to be a localized timestamp, not UTC
|
||||
const model = defineModel<string | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size: 'base' | 'large';
|
||||
focus: boolean;
|
||||
}>(),
|
||||
{
|
||||
size: 'base',
|
||||
focus: false,
|
||||
}
|
||||
);
|
||||
|
||||
const hours = ref(
|
||||
model.value ? getLocalizedDayJs(model.value).format('HH') : null
|
||||
);
|
||||
const minutes = ref(
|
||||
model.value ? getLocalizedDayJs(model.value).format('mm') : null
|
||||
);
|
||||
watch(
|
||||
() => model.value,
|
||||
() => {
|
||||
hours.value = model.value
|
||||
? getLocalizedDayJs(model.value).format('HH')
|
||||
: null;
|
||||
minutes.value = model.value
|
||||
? getLocalizedDayJs(model.value).format('mm')
|
||||
: null;
|
||||
}
|
||||
);
|
||||
|
||||
function updateMinutes(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newValue = target.value;
|
||||
if (!isNaN(parseInt(newValue))) {
|
||||
model.value = getDayJsInstance()(model.value)
|
||||
.set('minutes', Math.min(parseInt(newValue), 59))
|
||||
.format();
|
||||
}
|
||||
minutes.value = model.value
|
||||
? getLocalizedDayJs(model.value).format('mm')
|
||||
: null;
|
||||
}
|
||||
|
||||
function updateHours(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newValue = target.value;
|
||||
if (newValue.endsWith(':')) {
|
||||
minutesInput.value?.focus();
|
||||
} else if (!isNaN(parseInt(newValue))) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(newValue), 23))
|
||||
.format();
|
||||
}
|
||||
hours.value = model.value
|
||||
? getLocalizedDayJs(model.value).format('HH')
|
||||
: null;
|
||||
}
|
||||
|
||||
const hoursInput = ref<HTMLInputElement | null>(null);
|
||||
const minutesInput = ref<HTMLInputElement | null>(null);
|
||||
const emit = defineEmits(['changed']);
|
||||
|
||||
useFocus(hoursInput, { initialValue: props.focus });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center text-white">
|
||||
<div
|
||||
:class="
|
||||
twMerge(
|
||||
'border bg-input-background rounded-md border-input-border overflow-hidden',
|
||||
props.size === 'large' ? 'py-1.5 px-2' : ''
|
||||
)
|
||||
">
|
||||
<input
|
||||
v-model="hours"
|
||||
ref="hoursInput"
|
||||
@input="updateHours"
|
||||
@keydown.enter="emit('changed')"
|
||||
@focus="($event.target as HTMLInputElement).select()"
|
||||
data-testid="time_picker_hour"
|
||||
type="text"
|
||||
:class="
|
||||
twMerge(
|
||||
'border-none bg-transparent px-1 py-0.5 w-[30px] text-center focus:ring-0 focus:bg-card-background-active',
|
||||
props.size === 'large' ? 'text-base' : 'text-sm'
|
||||
)
|
||||
" />
|
||||
<span>:</span>
|
||||
<input
|
||||
v-model="minutes"
|
||||
ref="minutesInput"
|
||||
@keydown.enter="emit('changed')"
|
||||
@input="updateMinutes"
|
||||
@focus="($event.target as HTMLInputElement).select()"
|
||||
data-testid="time_picker_minute"
|
||||
type="text"
|
||||
:class="
|
||||
twMerge(
|
||||
'border-none bg-transparent px-1 py-1 w-[30px] text-center focus:ring-0 focus:bg-card-background-active',
|
||||
props.size === 'large' ? 'text-base' : 'text-sm'
|
||||
)
|
||||
" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
89
resources/js/packages/ui/src/Input/TimeRangeSelector.vue
Normal file
89
resources/js/packages/ui/src/Input/TimeRangeSelector.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, ref, watch } from 'vue';
|
||||
import TimePicker from '@/packages/ui/src/Input/TimePicker.vue';
|
||||
import { useFocusWithin } from '@vueuse/core';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import {
|
||||
getDayJsInstance,
|
||||
getLocalizedDayJs,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const props = defineProps<{
|
||||
start: string;
|
||||
end: string | null;
|
||||
focus?: boolean;
|
||||
}>();
|
||||
|
||||
// The timestamps for the changed event are UTC
|
||||
const emit = defineEmits(['changed']);
|
||||
|
||||
const tempStart = ref(
|
||||
props.start ? getLocalizedDayJs(props.start).format() : dayjs().format()
|
||||
);
|
||||
const tempEnd = ref(props.end ? getLocalizedDayJs(props.end).format() : null);
|
||||
|
||||
watch(props, () => {
|
||||
tempStart.value = getLocalizedDayJs(props.start).format();
|
||||
tempEnd.value = getLocalizedDayJs(props.end).format();
|
||||
});
|
||||
function updateTimeEntry() {
|
||||
const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format();
|
||||
const tempEndUtc = tempEnd.value
|
||||
? getDayJsInstance()(tempEnd.value).utc().format()
|
||||
: null;
|
||||
if (tempStartUtc !== props.start || tempEndUtc !== props.end) {
|
||||
emit(
|
||||
'changed',
|
||||
getDayJsInstance()(tempStart.value).utc().format(),
|
||||
getDayJsInstance()(tempEnd.value).utc().format()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const dropdownContent = ref();
|
||||
const { focused } = useFocusWithin(dropdownContent);
|
||||
|
||||
watch(focused, (newValue, oldValue) => {
|
||||
if (oldValue === true && newValue === false) {
|
||||
updateTimeEntry();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="dropdownContent"
|
||||
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">
|
||||
<TimePicker
|
||||
data-testid="time_entry_range_start"
|
||||
:focus
|
||||
@changed="updateTimeEntry"
|
||||
v-model="tempStart"></TimePicker>
|
||||
<DatePicker
|
||||
class="text-sm px-2 py-1"
|
||||
@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">
|
||||
<TimePicker
|
||||
data-testid="time_entry_range_end"
|
||||
@changed="updateTimeEntry"
|
||||
v-model="tempEnd"></TimePicker>
|
||||
<DatePicker
|
||||
class="text-sm px-2 py-1"
|
||||
@changed="updateTimeEntry"
|
||||
v-model="tempEnd"></DatePicker>
|
||||
</div>
|
||||
<div class="text-muted" v-else>-- : --</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
Reference in New Issue
Block a user