486 lines
12 KiB
Vue
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>
|