276 lines
7.4 KiB
Vue
276 lines
7.4 KiB
Vue
<template>
|
|
<div class="chart-range-slider">
|
|
<div ref="trackRef" class="track" @pointerdown="handleTrackPointerDown">
|
|
<div
|
|
class="selection-hit"
|
|
:style="selectionHitStyle"
|
|
@pointerdown.stop="handleSelectionPointerDown"
|
|
/>
|
|
<div
|
|
class="selection"
|
|
:style="selectionStyle"
|
|
/>
|
|
<button
|
|
class="thumb start"
|
|
:style="startThumbStyle"
|
|
type="button"
|
|
aria-label="调整开始日期"
|
|
@pointerdown.stop="event => handleThumbPointerDown('start', event)"
|
|
/>
|
|
<button
|
|
class="thumb end"
|
|
:style="endThumbStyle"
|
|
type="button"
|
|
aria-label="调整结束日期"
|
|
@pointerdown.stop="event => handleThumbPointerDown('end', event)"
|
|
/>
|
|
</div>
|
|
<div class="label" v-text="label" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
type DragMode = "start" | "end" | "range";
|
|
|
|
const props = withDefaults(defineProps<{
|
|
label?: string;
|
|
max: number;
|
|
min?: number;
|
|
minRange?: number;
|
|
modelValue: number[];
|
|
}>(), {
|
|
label: "",
|
|
minRange: 0,
|
|
min: 0
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
"update:modelValue": [value: number[]];
|
|
change: [value: number[]];
|
|
}>();
|
|
|
|
const minTouchWidth = 44;
|
|
const trackRef = ref<HTMLElement>();
|
|
const draggingMode = ref<DragMode>();
|
|
const dragStartX = ref(0);
|
|
const dragStartValue = ref<[number, number]>([0, 0]);
|
|
|
|
const safeMin = computed(() => Math.min(props.min, props.max));
|
|
const safeMax = computed(() => Math.max(props.min, props.max));
|
|
const valueRange = computed(() => Math.max(1, safeMax.value - safeMin.value));
|
|
const safeMinRange = computed(() => Math.min(Math.max(0, Math.round(props.minRange)), valueRange.value));
|
|
const normalizedValue = computed<[number, number]>(() => normalizeRange(props.modelValue));
|
|
const startPercent = computed(() => valueToPercent(normalizedValue.value[0]));
|
|
const endPercent = computed(() => valueToPercent(normalizedValue.value[1]));
|
|
const selectionStyle = computed(() => ({
|
|
left: `${startPercent.value}%`,
|
|
width: `${Math.max(0, endPercent.value - startPercent.value)}%`
|
|
}));
|
|
const selectionHitStyle = computed(() => {
|
|
const rect = trackRef.value?.getBoundingClientRect();
|
|
const selectionWidth = Math.max(0, endPercent.value - startPercent.value);
|
|
if (!rect || rect.width < minTouchWidth) {
|
|
return selectionStyle.value;
|
|
}
|
|
const touchWidthPercent = minTouchWidth / rect.width * 100;
|
|
const width = Math.max(selectionWidth, touchWidthPercent);
|
|
const center = (startPercent.value + endPercent.value) / 2;
|
|
return {
|
|
left: `${clamp(center - width / 2, 0, 100 - width)}%`,
|
|
width: `${width}%`
|
|
};
|
|
});
|
|
const startThumbStyle = computed(() => ({
|
|
left: `${startPercent.value}%`
|
|
}));
|
|
const endThumbStyle = computed(() => ({
|
|
left: `${endPercent.value}%`
|
|
}));
|
|
|
|
onBeforeUnmount(() => {
|
|
stopDocumentDrag();
|
|
});
|
|
|
|
function handleThumbPointerDown(mode: "start" | "end", event: PointerEvent): void {
|
|
startDocumentDrag(mode, event);
|
|
}
|
|
|
|
function handleSelectionPointerDown(event: PointerEvent): void {
|
|
startDocumentDrag("range", event);
|
|
}
|
|
|
|
function handleTrackPointerDown(event: PointerEvent): void {
|
|
const value = pointToValue(event.clientX);
|
|
const [start, end] = normalizedValue.value;
|
|
const targetMode: DragMode = Math.abs(value - start) <= Math.abs(value - end) ? "start" : "end";
|
|
updateByMode(targetMode, value);
|
|
startDocumentDrag(targetMode, event);
|
|
}
|
|
|
|
function startDocumentDrag(mode: DragMode, event: PointerEvent): void {
|
|
draggingMode.value = mode;
|
|
dragStartX.value = event.clientX;
|
|
dragStartValue.value = normalizedValue.value;
|
|
window.addEventListener("pointermove", handleDocumentPointerMove);
|
|
window.addEventListener("pointerup", handleDocumentPointerUp);
|
|
window.addEventListener("pointercancel", handleDocumentPointerUp);
|
|
}
|
|
|
|
function handleDocumentPointerMove(event: PointerEvent): void {
|
|
const mode = draggingMode.value;
|
|
if (!mode) {
|
|
return;
|
|
}
|
|
if (mode === "range") {
|
|
const nextValue = dragStartValue.value[0] + pointDeltaToStep(event.clientX - dragStartX.value);
|
|
const width = dragStartValue.value[1] - dragStartValue.value[0];
|
|
const start = clamp(nextValue, safeMin.value, safeMax.value - width);
|
|
commitRange([start, start + width]);
|
|
return;
|
|
}
|
|
updateByMode(mode, pointToValue(event.clientX));
|
|
}
|
|
|
|
function handleDocumentPointerUp(): void {
|
|
stopDocumentDrag();
|
|
}
|
|
|
|
function stopDocumentDrag(): void {
|
|
window.removeEventListener("pointermove", handleDocumentPointerMove);
|
|
window.removeEventListener("pointerup", handleDocumentPointerUp);
|
|
window.removeEventListener("pointercancel", handleDocumentPointerUp);
|
|
draggingMode.value = undefined;
|
|
}
|
|
|
|
function updateByMode(mode: "start" | "end", value: number): void {
|
|
const [start, end] = normalizedValue.value;
|
|
if (mode === "start") {
|
|
commitRange([Math.min(value, end - safeMinRange.value), end]);
|
|
return;
|
|
}
|
|
commitRange([start, Math.max(start + safeMinRange.value, value)]);
|
|
}
|
|
|
|
function commitRange(value: [number, number]): void {
|
|
const normalized = normalizeRange(value);
|
|
emit("update:modelValue", normalized);
|
|
emit("change", normalized);
|
|
}
|
|
|
|
function normalizeRange(value: number[]): [number, number] {
|
|
const rawStart = value[0] ?? safeMin.value;
|
|
const rawEnd = value[1] ?? rawStart;
|
|
const start = clamp(Math.round(Math.min(rawStart, rawEnd)), safeMin.value, safeMax.value);
|
|
const end = clamp(Math.round(Math.max(rawStart, rawEnd)), start + safeMinRange.value, safeMax.value);
|
|
if (safeMax.value < start + safeMinRange.value) {
|
|
return [safeMin.value, safeMax.value];
|
|
}
|
|
return [Math.min(start, end - safeMinRange.value), end];
|
|
}
|
|
|
|
function pointToValue(clientX: number): number {
|
|
const rect = trackRef.value?.getBoundingClientRect();
|
|
if (!rect || rect.width <= 0) {
|
|
return safeMin.value;
|
|
}
|
|
const percent = clamp((clientX - rect.left) / rect.width, 0, 1);
|
|
return Math.round(safeMin.value + percent * valueRange.value);
|
|
}
|
|
|
|
function pointDeltaToStep(deltaX: number): number {
|
|
const rect = trackRef.value?.getBoundingClientRect();
|
|
if (!rect || rect.width <= 0) {
|
|
return 0;
|
|
}
|
|
return Math.round(deltaX / rect.width * valueRange.value);
|
|
}
|
|
|
|
function valueToPercent(value: number): number {
|
|
return (clamp(value, safeMin.value, safeMax.value) - safeMin.value) / valueRange.value * 100;
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
</script>
|
|
|
|
<style scoped lang="less">
|
|
.chart-range-slider {
|
|
padding: .25rem .25rem 0 .25rem;
|
|
|
|
.track {
|
|
height: 2.25rem;
|
|
display: flex;
|
|
position: relative;
|
|
align-items: center;
|
|
touch-action: none;
|
|
|
|
&::before {
|
|
left: 0;
|
|
right: 0;
|
|
height: .25rem;
|
|
content: "";
|
|
position: absolute;
|
|
background: rgba(148, 163, 184, .24);
|
|
border-radius: 999rem;
|
|
}
|
|
}
|
|
|
|
.selection {
|
|
height: .5rem;
|
|
z-index: 1;
|
|
position: absolute;
|
|
background: var(--td-brand-color);
|
|
border-radius: 999rem;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.selection-hit {
|
|
height: 2.25rem;
|
|
z-index: 3;
|
|
position: absolute;
|
|
touch-action: none;
|
|
}
|
|
|
|
.thumb {
|
|
top: 50%;
|
|
margin: 0;
|
|
padding: 0;
|
|
width: 1.25rem;
|
|
height: 1.25rem;
|
|
z-index: 2;
|
|
border: .125rem solid var(--td-brand-color);
|
|
position: absolute;
|
|
background: #fff;
|
|
transform: translate(-50%, -50%);
|
|
box-shadow: 0 .125rem .5rem rgba(15, 23, 42, .18);
|
|
border-radius: 999rem;
|
|
touch-action: none;
|
|
}
|
|
|
|
.label {
|
|
color: #52607a;
|
|
font-size: .75rem;
|
|
text-align: center;
|
|
white-space: nowrap;
|
|
}
|
|
}
|
|
|
|
:global(html.theme-dark) {
|
|
.chart-range-slider {
|
|
.track::before {
|
|
background: rgba(148, 163, 184, .28);
|
|
}
|
|
|
|
.thumb {
|
|
background: #10161f;
|
|
box-shadow: 0 .125rem .5rem rgba(2, 6, 23, .38);
|
|
}
|
|
|
|
.label {
|
|
color: #94a3b8;
|
|
}
|
|
}
|
|
}
|
|
</style>
|