Files
lucia-frontend/src/components/DurationInput.vue
2026-03-09 18:04:43 +08:00

486 lines
12 KiB
Vue

<template>
<div class="relative">
<div
class="w-32 px-1 border border-neutral-500 cursor-pointer"
@click="openTimeSelect = !openTimeSelect"
:id="size"
>
<div v-if="totalSeconds === 0" class="text-center">
<p>0</p>
</div>
<!-- This section shows the fixed time display, not the popup -->
<div
id="cyp-timerange-show"
v-else
class="flex justify-center items-center gap-1"
>
<p v-show="days != 0">{{ days }}d</p>
<p v-show="hours != 0">{{ hours }}h</p>
<p v-show="minutes != 0">{{ minutes }}m</p>
<p v-show="seconds != 0">{{ seconds }}s</p>
</div>
</div>
<!-- The following section is the popup that appears when the user clicks to open -->
<div
class="duration-container absolute left-0 top-full translate-y-2 dhms-input-popup-container"
v-show="openTimeSelect"
v-closable="{ id: size, handler: onClose }"
>
<div class="duration-box" v-for="(unit, index) in inputTypes" :key="unit">
<input
id="input_duration_dhms"
type="text"
class="duration duration-val input-dhms-field"
:data-index="index"
:data-tunit="unit"
:data-max="tUnits[unit].max"
:data-min="tUnits[unit].min"
:maxlength="tUnits[unit].dsp === 'd' ? 3 : 2"
@focus="onFocus"
@change="onChange"
@keyup="onKeyUp"
v-model="inputTimeFields[index]"
/>
<label class="duration" for="input_duration_dhms">{{
tUnits[unit].dsp
}}</label>
</div>
</div>
</div>
</template>
<script setup>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module components/durationjs
* Duration input component with day/hour/minute/second
* fields and bounded min/max validation.
*/
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import emitter from "@/utils/emitter";
const props = defineProps({
max: {
type: Number,
default: 0,
required: true,
validator(value) {
return value >= 0;
},
},
min: {
type: Number,
default: 0,
required: true,
validator(value) {
return value >= 0;
},
},
updateMax: {
type: Number,
required: false,
validator(value) {
return value >= 0;
},
},
updateMin: {
type: Number,
required: false,
validator(value) {
return value >= 0;
},
},
size: {
type: String,
default: "md",
required: true,
},
value: {
type: Number,
required: false,
validator(value) {
return value >= 0;
},
},
});
const emit = defineEmits(["total-seconds"]);
const display = ref("dhms");
const seconds = ref(0);
const minutes = ref(0);
const hours = ref(0);
const days = ref(0);
const maxDays = ref(0);
const minDays = ref(0);
const totalSeconds = ref(0);
const maxTotal = ref(null);
const minTotal = ref(null);
const inputTypes = ref([]);
const lastInput = ref(null);
const openTimeSelect = ref(false);
const tUnits = computed({
get() {
return {
s: { dsp: "s", inc: 1, val: seconds.value, max: 59, rate: 1, min: 0 },
m: { dsp: "m", inc: 1, val: minutes.value, max: 59, rate: 60, min: 0 },
h: { dsp: "h", inc: 1, val: hours.value, max: 23, rate: 3600, min: 0 },
d: {
dsp: "d",
inc: 1,
val: days.value,
max: maxDays.value,
rate: 86400,
min: minDays.value,
},
};
},
set(newValues) {
for (const unit in newValues) {
switch (unit) {
case "s":
seconds.value = newValues[unit].val;
break;
case "m":
minutes.value = newValues[unit].val;
break;
case "h":
hours.value = newValues[unit].val;
break;
case "d":
days.value = newValues[unit].val;
break;
}
const input = document.querySelector(`[data-tunit="${unit}"]`);
if (input) {
input.value = newValues[unit].val.toString();
}
}
},
});
const inputTimeFields = computed(() => {
const paddedTimeFields = [];
inputTypes.value.forEach((inputTypeUnit) => {
paddedTimeFields.push(
tUnits.value[inputTypeUnit].val.toString().padStart(2, "0"),
);
});
return paddedTimeFields;
});
/** Closes the time selection dropdown. */
function onClose() {
openTimeSelect.value = false;
}
/**
* Selects all text in the focused input field.
* @param {FocusEvent} event - The focus event.
*/
function onFocus(event) {
lastInput.value = event.target;
lastInput.value.select();
}
/**
* Validates and updates the duration value when an input changes.
* @param {Event} event - The change event from a duration input.
*/
function onChange(event) {
const baseInputValue = event.target.value;
let decoratedInputValue;
if (isNaN(event.target.value)) {
event.target.value = "00";
} else {
event.target.value = event.target.value.toString();
}
decoratedInputValue = event.target.value.toString();
const inputValue = parseInt(event.target.value, 10);
const max = parseInt(event.target.dataset.max, 10);
const min = parseInt(event.target.dataset.min, 10);
if (inputValue > max) {
decoratedInputValue = max.toString().padStart(2, "0");
} else if (inputValue < min) {
decoratedInputValue = min.toString();
}
const dsp = event.target.dataset.tunit;
tUnits.value[dsp].val = decoratedInputValue;
switch (dsp) {
case "d":
days.value = decoratedInputValue;
break;
case "h":
hours.value = decoratedInputValue;
break;
case "m":
minutes.value = decoratedInputValue;
break;
case "s":
seconds.value = decoratedInputValue;
break;
}
calculateTotalSeconds();
}
/**
* Handles arrow key up/down to increment/decrement duration values.
* @param {KeyboardEvent} event - The keyup event.
*/
function onKeyUp(event) {
event.target.value = event.target.value.replace(/\D/g, "");
if (event.keyCode === 38 || event.keyCode === 40) {
actionUpDown(event.target, event.keyCode === 38, true);
}
}
/**
* Increments or decrements a duration unit value with carry-over.
* @param {HTMLInputElement} input - The input element to update.
* @param {boolean} goUp - True to increment, false to decrement.
* @param {boolean} [selectIt=false] - Whether to select the input text after.
*/
function actionUpDown(input, goUp, selectIt = false) {
const tUnit = input.dataset.tunit;
let newVal = getNewValue(input);
if (goUp) {
newVal = handleArrowUp(newVal, tUnit, input);
} else {
newVal = handleArrowDown(newVal, tUnit);
}
updateInputValue(input, newVal, tUnit);
if (selectIt) {
input.select();
}
calculateTotalSeconds();
}
function getNewValue(input) {
const newVal = parseInt(input.value, 10);
return isNaN(newVal) ? 0 : newVal;
}
function handleArrowUp(newVal, tUnit, input) {
newVal += tUnits.value[tUnit].inc;
if (newVal > tUnits.value[tUnit].max) {
if (tUnits.value[tUnit].dsp === "d") {
totalSeconds.value = maxTotal.value;
} else {
newVal = newVal % (tUnits.value[tUnit].max + 1);
incrementPreviousUnit(input);
}
}
return newVal;
}
function handleArrowDown(newVal, tUnit) {
newVal -= tUnits.value[tUnit].inc;
if (newVal < 0) {
newVal = tUnits.value[tUnit].max + 1 - tUnits.value[tUnit].inc;
}
return newVal;
}
function incrementPreviousUnit(input) {
if (input.dataset.index > 0) {
const prevUnit = document.querySelector(
`input[data-index="${parseInt(input.dataset.index) - 1}"]`,
);
if (prevUnit) actionUpDown(prevUnit, true);
}
}
function updateInputValue(input, newVal, tUnit) {
input.value = newVal.toString();
switch (tUnit) {
case "d":
days.value = input.value;
break;
case "h":
hours.value = input.value;
break;
case "m":
minutes.value = input.value;
break;
case "s":
seconds.value = input.value;
break;
}
}
/**
* Converts total seconds to day/hour/minute/second components.
* @param {number} totalSec - The total seconds to convert.
* @param {string} [size] - 'max' or 'min' to set boundary days.
*/
function secondToDate(totalSec, size) {
totalSec = parseInt(totalSec);
if (!isNaN(totalSec)) {
seconds.value = totalSec % 60;
minutes.value = (Math.floor(totalSec - seconds.value) / 60) % 60;
hours.value = Math.floor(totalSec / 3600) % 24;
days.value = Math.floor(totalSec / (3600 * 24));
if (size === "max") {
maxDays.value = Math.floor(totalSec / (3600 * 24));
} else if (size === "min") {
minDays.value = Math.floor(totalSec / (3600 * 24));
}
}
}
/** Calculates total seconds from all duration units and emits the result. */
function calculateTotalSeconds() {
let total = 0;
for (const unit in tUnits.value) {
const val = parseInt(tUnits.value[unit].val, 10);
if (!isNaN(val)) {
total += val * tUnits.value[unit].rate;
}
}
if (total >= maxTotal.value) {
total = maxTotal.value;
secondToDate(maxTotal.value, "max");
} else if (total <= minTotal.value) {
total = minTotal.value;
secondToDate(minTotal.value, "min");
} else if (props.size === "min" && total <= maxTotal.value) {
maxDays.value = Math.floor(maxTotal.value / (3600 * 24));
}
totalSeconds.value = total;
emit("total-seconds", total);
}
/** Initializes the duration display based on min/max boundaries and preset value. */
async function createData() {
const size = props.size;
if (maxTotal.value !== null && minTotal.value !== null) {
switch (size) {
case "max":
secondToDate(minTotal.value, "min");
secondToDate(maxTotal.value, "max");
totalSeconds.value = maxTotal.value;
if (props.value !== null) {
totalSeconds.value = props.value;
secondToDate(props.value);
}
break;
case "min":
secondToDate(maxTotal.value, "max");
secondToDate(minTotal.value, "min");
totalSeconds.value = minTotal.value;
if (props.value !== null) {
totalSeconds.value = props.value;
secondToDate(props.value);
}
break;
}
}
}
// created
emitter.on("reset", () => {
createData();
});
onBeforeUnmount(() => {
emitter.off("reset");
});
// mounted
onMounted(() => {
inputTypes.value = display.value.split("");
});
const vClosable = {
mounted(el, { value }) {
const handleOutsideClick = function (e) {
let target = e.target;
while (target && target.id !== value.id) {
target = target.parentElement;
}
const isClickOutside = target?.id !== value.id && !el.contains(e.target);
if (isClickOutside) {
value.handler();
}
e.stopPropagation();
};
document.addEventListener("click", handleOutsideClick);
el._handleOutsideClick = handleOutsideClick;
},
unmounted(el) {
document.removeEventListener("click", el._handleOutsideClick);
},
};
</script>
<style scoped>
.duration-container {
margin: 4px;
border-radius: 4px;
background: var(--10, #fff);
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.25);
height: 36px;
width: 221px;
}
.duration-box {
float: left;
overflow: auto;
height: var(--main-input-height);
padding: 4px;
}
.duration-box > .duration {
border: 1px solid var(--main-bg-light);
border-right: 0;
border-left: 0;
background-color: transparent;
color: var(--main-bg-light);
}
.duration-box > .duration:nth-child(1) {
border-left: 1px solid var(--main-bg-light);
border-top-left-radius: var(--main-input-br);
border-bottom-left-radius: var(--main-input-br);
}
.duration-box > .duration:nth-last-child(1) {
border-right: 1px solid var(--main-bg-light);
border-top-right-radius: var(--main-input-br);
border-bottom-right-radius: var(--main-input-br);
}
.duration {
float: left;
display: block;
overflow: auto;
height: var(--main-input-height);
outline: none;
font-size: 14px;
margin: 0px 2px;
}
.duration-box > label.duration {
line-height: 28px;
width: 12px;
overflow: hidden;
}
.duration-box > input[type="text"].duration {
text-align: right;
width: 26px;
padding: 3px 2px 0px 0px;
}
.duration-box > input[type="button"].duration {
width: 60px;
cursor: pointer;
}
</style>