mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-13 02:32:15 +01:00
migrate select/multiselect components to Radix Vue primitives
This commit is contained in:
@@ -1,7 +1,17 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { type Component, computed, nextTick, ref, watch } from 'vue';
|
||||
import MultiselectDropdownItem from '@/packages/ui/src/Input/MultiselectDropdownItem.vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
ComboboxContent,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxRoot,
|
||||
ComboboxViewport,
|
||||
} from 'radix-vue';
|
||||
|
||||
const NONE_ID = 'none';
|
||||
|
||||
const model = defineModel<string[]>({
|
||||
default: [],
|
||||
@@ -12,164 +22,98 @@ const props = defineProps<{
|
||||
searchPlaceholder: string;
|
||||
getKeyFromItem: (item: T) => string;
|
||||
getNameForItem: (item: T) => string;
|
||||
noItemLabel?: string;
|
||||
}>();
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const open = ref(false);
|
||||
const dropdownViewport = ref<Component | null>(null);
|
||||
|
||||
const searchValue = ref('');
|
||||
const sortedItems = ref<T[]>([]);
|
||||
|
||||
function isItemSelected(id: string) {
|
||||
return model.value.includes(id);
|
||||
}
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
searchValue.value = '';
|
||||
sortedItems.value = [...props.items].sort((a, b) => {
|
||||
const aSelected = model.value.includes(props.getKeyFromItem(a)) ? 0 : 1;
|
||||
const bSelected = model.value.includes(props.getKeyFromItem(b)) ? 0 : 1;
|
||||
return aSelected - bSelected;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function addOrRemoveItemFromSelection(id: string) {
|
||||
const filteredItems = computed(() => {
|
||||
const search = searchValue.value.toLowerCase().trim();
|
||||
if (!search) return sortedItems.value;
|
||||
return sortedItems.value.filter((item) => props.getNameForItem(item).toLowerCase().includes(search));
|
||||
});
|
||||
|
||||
const showNoItem = computed(() => {
|
||||
if (!props.noItemLabel) return false;
|
||||
const search = searchValue.value.toLowerCase().trim();
|
||||
if (!search) return true;
|
||||
return props.noItemLabel.toLowerCase().includes(search);
|
||||
});
|
||||
|
||||
function toggleItem(id: string) {
|
||||
if (model.value.includes(id)) {
|
||||
model.value = model.value.filter((itemId) => itemId !== id);
|
||||
} else {
|
||||
model.value.push(id);
|
||||
model.value = [...model.value, id];
|
||||
}
|
||||
emit('changed');
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus();
|
||||
});
|
||||
|
||||
// sort tags alphabetically
|
||||
[...props.items].sort((a, b) => {
|
||||
const aIsSelected = model.value.includes(props.getKeyFromItem(a));
|
||||
const bIsSelected = model.value.includes(props.getKeyFromItem(b));
|
||||
if (aIsSelected === bIsSelected) {
|
||||
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
|
||||
}
|
||||
return model.value.includes(props.getKeyFromItem(a)) ? -1 : 1;
|
||||
});
|
||||
nextTick(() => {
|
||||
if (filteredItems.value.length > 0) {
|
||||
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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]);
|
||||
}
|
||||
});
|
||||
|
||||
function updateSearchValue(event: Event) {
|
||||
const newInput = (event.target as HTMLInputElement).value;
|
||||
if (newInput === ' ') {
|
||||
searchValue.value = '';
|
||||
const highlightedTagId = highlightedItemId.value;
|
||||
if (highlightedTagId) {
|
||||
const highlightedItem = props.items.find(
|
||||
(item) => props.getKeyFromItem(item) === highlightedTagId
|
||||
);
|
||||
if (highlightedItem) {
|
||||
addOrRemoveItemFromSelection(props.getKeyFromItem(highlightedItem));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
searchValue.value = newInput;
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
|
||||
function toggleItem(newValue: string | null) {
|
||||
if (newValue !== null) {
|
||||
if (model.value.includes(newValue)) {
|
||||
model.value = [...model.value].filter((id) => id !== newValue);
|
||||
} else {
|
||||
model.value = [...model.value, newValue];
|
||||
}
|
||||
emit('changed');
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue', 'changed', 'submit']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown v-model="open" align="start" :close-on-content-click="false">
|
||||
<Dropdown v-model="open" align="start" :close-on-content-click="false" @submit="emit('submit')">
|
||||
<template #trigger>
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<input
|
||||
ref="searchInput"
|
||||
:value="searchValue"
|
||||
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||
:placeholder="searchPlaceholder"
|
||||
@input="updateSearchValue"
|
||||
@keydown.up.prevent="moveHighlightUp"
|
||||
@keydown.down.prevent="moveHighlightDown"
|
||||
@keydown.enter="toggleItem(highlightedItemId)" />
|
||||
<div ref="dropdownViewport" class="min-w-60 max-w-80 max-h-60 overflow-y-scroll">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="props.getKeyFromItem(item)"
|
||||
role="option"
|
||||
:value="props.getKeyFromItem(item)"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
props.getKeyFromItem(item) === highlightedItemId,
|
||||
}"
|
||||
:data-item-id="props.getKeyFromItem(item)">
|
||||
<MultiselectDropdownItem
|
||||
:selected="isItemSelected(props.getKeyFromItem(item))"
|
||||
:name="props.getNameForItem(item)"
|
||||
@click="toggleItem(props.getKeyFromItem(item))"></MultiselectDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
<ComboboxRoot
|
||||
v-model:search-term="searchValue"
|
||||
:open="open"
|
||||
class="p-2"
|
||||
:filter-function="(val: string[]) => val">
|
||||
<ComboboxAnchor>
|
||||
<ComboboxInput
|
||||
class="w-full rounded-md border border-input-border bg-input-background px-3 py-1.5 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
|
||||
:placeholder="searchPlaceholder" />
|
||||
</ComboboxAnchor>
|
||||
<ComboboxContent
|
||||
:dismiss-able="false"
|
||||
position="inline"
|
||||
class="mt-2 min-w-60 max-w-80 max-h-60 overflow-y-auto">
|
||||
<ComboboxViewport>
|
||||
<ComboboxItem
|
||||
v-if="showNoItem"
|
||||
:value="NONE_ID"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
|
||||
@select.prevent="toggleItem(NONE_ID)">
|
||||
<Checkbox
|
||||
:checked="model.includes(NONE_ID)"
|
||||
aria-hidden="true"
|
||||
:tabindex="-1"
|
||||
class="pointer-events-none" />
|
||||
<span class="truncate">{{ noItemLabel }}</span>
|
||||
</ComboboxItem>
|
||||
<ComboboxItem
|
||||
v-for="item in filteredItems"
|
||||
:key="getKeyFromItem(item)"
|
||||
:value="getKeyFromItem(item)"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
|
||||
@select.prevent="toggleItem(getKeyFromItem(item))">
|
||||
<Checkbox
|
||||
:checked="model.includes(getKeyFromItem(item))"
|
||||
aria-hidden="true"
|
||||
:tabindex="-1"
|
||||
class="pointer-events-none" />
|
||||
<span class="truncate">{{ getNameForItem(item) }}</span>
|
||||
</ComboboxItem>
|
||||
</ComboboxViewport>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import { computed } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
selected: boolean;
|
||||
}>();
|
||||
|
||||
const iconClasses = computed(() => {
|
||||
if (props.selected) {
|
||||
return 'text-input-select-active';
|
||||
} else {
|
||||
return 'text-text-quaternary opacity-50';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||
<CheckCircleIcon :class="twMerge(iconClasses, 'w-5')"></CheckCircleIcon>
|
||||
<span class="flex-1 min-w-0 overflow-ellipsis overflow-hidden">{{ name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,188 +0,0 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue';
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
const model = defineModel<string | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const open = defineModel('open', {
|
||||
default: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: T[];
|
||||
getKeyFromItem: (item: T) => string | null;
|
||||
getNameForItem: (item: T) => string;
|
||||
align?: 'center' | 'end' | 'start';
|
||||
class?: string;
|
||||
}>(),
|
||||
{
|
||||
align: 'start',
|
||||
}
|
||||
);
|
||||
|
||||
const dropdownViewport = ref<HTMLDivElement | 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() || '');
|
||||
});
|
||||
});
|
||||
|
||||
const highlightedItemId = ref<string | null>(model.value);
|
||||
|
||||
watch(model, () => {
|
||||
if (model.value) {
|
||||
highlightedItemId.value = model.value;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!highlightedItemId.value) {
|
||||
resetHightlightedItem();
|
||||
}
|
||||
});
|
||||
|
||||
watch(filteredItems, () => {
|
||||
resetHightlightedItem();
|
||||
});
|
||||
|
||||
function resetHightlightedItem() {
|
||||
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) {
|
||||
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 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) {
|
||||
nextTick(() => {
|
||||
const highlightedDomElement = dropdownViewport.value?.querySelector(
|
||||
`[data-select-id="${model.value}"]`
|
||||
) as HTMLElement;
|
||||
dropdownViewport.value?.scrollTo({
|
||||
top: highlightedDomElement?.offsetTop ?? 0,
|
||||
behavior: 'instant',
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown v-model="open" :align="align" :close-on-content-click="false">
|
||||
<template #trigger>
|
||||
<slot name="trigger"> </slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
ref="dropdownViewport"
|
||||
:class="twMerge('w-60 py-1.5 max-h-60 overflow-y-scroll', props.class)">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="props.getKeyFromItem(item) ?? 'none'"
|
||||
role="option"
|
||||
:data-select-id="props.getKeyFromItem(item)"
|
||||
:value="props.getKeyFromItem(item)"
|
||||
:data-item-id="props.getKeyFromItem(item)">
|
||||
<SelectDropdownItem
|
||||
:highlighted="props.getKeyFromItem(item) === highlightedItemId"
|
||||
:selected="props.getKeyFromItem(item) === model"
|
||||
:name="props.getNameForItem(item)"
|
||||
@mouseenter="highlightedItemId = props.getKeyFromItem(item)"
|
||||
@click="setItem(props.getKeyFromItem(item))"></SelectDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
selected: boolean;
|
||||
highlighted: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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-text-primary 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>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,162 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { getDayJsInstance, getLocalizedDayJs } 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>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size?: 'base' | 'large';
|
||||
focus?: boolean;
|
||||
}>(),
|
||||
{
|
||||
size: 'base',
|
||||
focus: false,
|
||||
}
|
||||
);
|
||||
|
||||
function updateTime(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newValue = target.value.trim();
|
||||
if (newValue.split(':').length === 2) {
|
||||
const [hours, minutes] = newValue.split(':');
|
||||
if (!isNaN(parseInt(hours)) && !isNaN(parseInt(minutes))) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(hours), 23))
|
||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||
.format();
|
||||
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');
|
||||
}
|
||||
|
||||
watch(model, (value) => {
|
||||
inputValue.value = value ? getLocalizedDayJs(value).format('HH:mm') : null;
|
||||
});
|
||||
|
||||
const timeInput = ref<HTMLInputElement | null>(null);
|
||||
const emit = defineEmits(['changed']);
|
||||
|
||||
useFocus(timeInput, { initialValue: props.focus });
|
||||
|
||||
type TimeOption = {
|
||||
timestamp: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const getStartOptions = computed<TimeOption[]>(() => {
|
||||
// options for the entire day in 15 minute intervals
|
||||
const options = [];
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 15) {
|
||||
const timestamp = getLocalizedDayJs(model.value)
|
||||
.set('hour', hour)
|
||||
.set('minute', minute)
|
||||
.format();
|
||||
const name = getLocalizedDayJs(model.value)
|
||||
.set('hour', hour)
|
||||
.set('minute', minute)
|
||||
.format('HH:mm');
|
||||
options.push({ timestamp, name });
|
||||
}
|
||||
}
|
||||
return options;
|
||||
});
|
||||
const inputValue = ref(model.value ? getLocalizedDayJs(model.value).format('HH:mm') : null);
|
||||
const open = ref(false);
|
||||
const closestValue = computed({
|
||||
get() {
|
||||
const target = getDayJsInstance()(model.value);
|
||||
let closestDiff: number | null = null;
|
||||
let closest = target;
|
||||
for (const option of getStartOptions.value) {
|
||||
const diff = Math.abs(getDayJsInstance()(option.timestamp).diff(target));
|
||||
if (closestDiff === null || diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
closest = getDayJsInstance()(option.timestamp);
|
||||
}
|
||||
}
|
||||
return closest.format();
|
||||
},
|
||||
set(value: string) {
|
||||
model.value = value;
|
||||
emit('changed', model.value);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 items-center justify-center text-text-primary">
|
||||
<SelectDropdown
|
||||
v-model="closestValue"
|
||||
v-model:open="open"
|
||||
:class="twMerge('mine-w-0 w-24', size === 'large' && 'w-28')"
|
||||
:get-key-from-item="(item: TimeOption) => item.timestamp"
|
||||
:get-name-for-item="(item: TimeOption) => item.name"
|
||||
:items="getStartOptions">
|
||||
<template #trigger>
|
||||
<TextInput
|
||||
ref="timeInput"
|
||||
v-model="inputValue"
|
||||
:class="twMerge('text-center w-24 px-3 py-2', size === 'large' && 'w-28')"
|
||||
data-testid="time_picker_input"
|
||||
type="text"
|
||||
@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" />
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
Reference in New Issue
Block a user