refactor: migrate frontend datetimepicker
This commit is contained in:
@@ -0,0 +1,703 @@
|
||||
<script lang="ts">
|
||||
export enum TabType {
|
||||
Year = 0,
|
||||
Month = 1,
|
||||
Day = 2,
|
||||
Hour = 3,
|
||||
Minute = 4,
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import {
|
||||
MIN_YEAR,
|
||||
MAX_YEAR,
|
||||
MIN_DATETIME,
|
||||
MAX_DATETIME,
|
||||
MONTH_DAY_COUNT,
|
||||
dayOfWeek,
|
||||
isLeapYear,
|
||||
} from '@/utils/datetime';
|
||||
import { MONTH_NAMES, WEEK_NAMES, universalGetMonth } from '@/utils/i18n';
|
||||
|
||||
// region: Constants
|
||||
|
||||
const ALL_TABS: TabType[] = [TabType.Year, TabType.Month, TabType.Day, TabType.Hour, TabType.Minute];
|
||||
|
||||
const DIAL_PLATE_WIDTH = 200;
|
||||
const DIAL_PLATE_RADIUS = DIAL_PLATE_WIDTH / 2;
|
||||
const DIAL_PLATE_HOUR_INNER_PERCENT = 0.6;
|
||||
const DIAL_PLATE_HOUR_OUTER_PERCENT = 0.8;
|
||||
const DIAL_PLATE_HOUR_DISTINGUISH_PERCENT = 0.7;
|
||||
const DIAL_PLATE_MINUTE_PERCENT = 0.8;
|
||||
const DIAL_PLATE_HOUR_RESOLUTION = Math.PI * 2 / 12;
|
||||
const DIAL_PLATE_MINUTE_RESOLUTION = Math.PI * 2 / 60;
|
||||
|
||||
// endregion
|
||||
|
||||
// region: State
|
||||
|
||||
const isVisible = ref<boolean>(false);
|
||||
const mode = ref<TabType>(TabType.Day);
|
||||
const currentTab = ref<TabType>(TabType.Day);
|
||||
|
||||
const internalDateTime = ref<Date>(new Date());
|
||||
const displayCacheDateTime = ref<Date>(new Date());
|
||||
|
||||
const enableHourDrag = ref<boolean>(false);
|
||||
const enableMinuteDrag = ref<boolean>(false);
|
||||
|
||||
const hourSvgEl = ref<SVGSVGElement | null>(null);
|
||||
const minuteSvgEl = ref<SVGSVGElement | null>(null);
|
||||
const pickerBodyEl = ref<HTMLElement | null>(null);
|
||||
const svgFontEm = ref<number>(1);
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Exposed API
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm', date: Date): void
|
||||
(e: 'cancel'): void
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Open the picker seeded with `initialDate`, with the deepest selectable tab
|
||||
* set to `modeType`.
|
||||
*
|
||||
* NOTE: The DateTimePicker ALWAYS edits the passed Date via LOCAL accessors
|
||||
* (getFullYear / getMonth / getDate / getHours / getMinutes). It performs NO
|
||||
* timezone conversion. If the value represents a wall-clock in a non-browser
|
||||
* timezone, the CALLER must pre-construct a Date whose LOCAL components equal
|
||||
* that wall-clock, and recompute any timestamp from the components afterwards.
|
||||
*/
|
||||
const modal = (initialDate: Date, modeType: TabType) => {
|
||||
internalDateTime.value = new Date(initialDate.getTime());
|
||||
displayCacheDateTime.value = new Date(initialDate.getTime());
|
||||
mode.value = modeType;
|
||||
currentTab.value = modeType;
|
||||
isVisible.value = true;
|
||||
}
|
||||
|
||||
const confirm = () => {
|
||||
isVisible.value = false;
|
||||
emit('confirm', new Date(internalDateTime.value.getTime()));
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
isVisible.value = false;
|
||||
emit('cancel');
|
||||
}
|
||||
|
||||
defineExpose({ modal })
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Helpers
|
||||
|
||||
const cloneDate = (d: Date): Date => new Date(d.getTime());
|
||||
|
||||
const clampDateTime = (d: Date): void => {
|
||||
if (d < MIN_DATETIME) d.setTime(MIN_DATETIME.getTime());
|
||||
if (d >= MAX_DATETIME) d.setTime(MAX_DATETIME.getTime());
|
||||
}
|
||||
|
||||
const switchTab = (newTab: TabType): void => {
|
||||
displayCacheDateTime.value = new Date(internalDateTime.value.getTime());
|
||||
currentTab.value = newTab;
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Header
|
||||
|
||||
const visibleTabs = computed<TabType[]>(() => ALL_TABS.filter(t => t <= mode.value));
|
||||
|
||||
const isYearTab = computed<boolean>(() => currentTab.value === TabType.Year);
|
||||
const isMonthTab = computed<boolean>(() => currentTab.value === TabType.Month);
|
||||
const isDayTab = computed<boolean>(() => currentTab.value === TabType.Day);
|
||||
const isHourTab = computed<boolean>(() => currentTab.value === TabType.Hour);
|
||||
const isMinuteTab = computed<boolean>(() => currentTab.value === TabType.Minute);
|
||||
|
||||
const tabLabel = (t: TabType): string => {
|
||||
if (t === TabType.Year) return 'Year';
|
||||
if (t === TabType.Month) return 'Month';
|
||||
if (t === TabType.Day) return 'Day';
|
||||
if (t === TabType.Hour) return 'Hour';
|
||||
return 'Minute';
|
||||
}
|
||||
|
||||
const headerValue = (t: TabType): number => {
|
||||
if (t === TabType.Year) return internalDateTime.value.getFullYear();
|
||||
if (t === TabType.Month) return internalDateTime.value.getMonth() + 1;
|
||||
if (t === TabType.Day) return internalDateTime.value.getDate();
|
||||
if (t === TabType.Hour) return internalDateTime.value.getHours();
|
||||
return internalDateTime.value.getMinutes();
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Year Panel
|
||||
|
||||
const yearPanelStartYear = computed<number>(() => {
|
||||
return Math.floor((displayCacheDateTime.value.getFullYear() - MIN_YEAR) / 12) * 12 + MIN_YEAR;
|
||||
})
|
||||
|
||||
interface Cell {
|
||||
value: number | null;
|
||||
picked: boolean;
|
||||
}
|
||||
|
||||
const yearCells = computed<Cell[]>(() => {
|
||||
const start = yearPanelStartYear.value;
|
||||
const pickedYear = internalDateTime.value.getFullYear();
|
||||
const cells: Cell[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const v = start + i;
|
||||
cells.push({ value: v < MAX_YEAR ? v : null, picked: v === pickedYear && v < MAX_YEAR });
|
||||
}
|
||||
return cells;
|
||||
})
|
||||
|
||||
const yearRows = computed<Cell[][]>(() => {
|
||||
const cells = yearCells.value;
|
||||
return [cells.slice(0, 4), cells.slice(4, 8), cells.slice(8, 12)];
|
||||
})
|
||||
|
||||
const yearPanelTitle = computed<string>(() => {
|
||||
const start = yearPanelStartYear.value;
|
||||
const end = start + 12 < MAX_YEAR ? start + 12 : MAX_YEAR;
|
||||
return `${start} - ${end}`;
|
||||
})
|
||||
|
||||
const prevNextYear = (isPrev: boolean): void => {
|
||||
const d = cloneDate(displayCacheDateTime.value);
|
||||
d.setFullYear(d.getFullYear() + (isPrev ? -12 : 12));
|
||||
clampDateTime(d);
|
||||
displayCacheDateTime.value = d;
|
||||
}
|
||||
|
||||
const clickYear = (cell: Cell): void => {
|
||||
if (cell.value === null) return;
|
||||
const d = cloneDate(internalDateTime.value);
|
||||
d.setFullYear(cell.value);
|
||||
clampDateTime(d);
|
||||
internalDateTime.value = d;
|
||||
if (mode.value !== TabType.Year) switchTab(TabType.Month);
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Month Panel
|
||||
|
||||
interface MonthCell {
|
||||
idx: number;
|
||||
name: string;
|
||||
picked: boolean;
|
||||
}
|
||||
|
||||
const monthCells = computed<MonthCell[]>(() => {
|
||||
const sameYear = displayCacheDateTime.value.getFullYear() === internalDateTime.value.getFullYear();
|
||||
const pickedMonth = internalDateTime.value.getMonth();
|
||||
return MONTH_NAMES.map((name, idx) => ({
|
||||
idx,
|
||||
name,
|
||||
picked: sameYear && idx === pickedMonth,
|
||||
}));
|
||||
})
|
||||
|
||||
const monthRows = computed<MonthCell[][]>(() => {
|
||||
const cells = monthCells.value;
|
||||
return [cells.slice(0, 4), cells.slice(4, 8), cells.slice(8, 12)];
|
||||
})
|
||||
|
||||
const monthPanelTitle = computed<string>(() => `${displayCacheDateTime.value.getFullYear()}`);
|
||||
|
||||
const prevNextMonth = (isPrev: boolean): void => {
|
||||
const d = cloneDate(displayCacheDateTime.value);
|
||||
d.setFullYear(d.getFullYear() + (isPrev ? -1 : 1));
|
||||
clampDateTime(d);
|
||||
displayCacheDateTime.value = d;
|
||||
}
|
||||
|
||||
const clickMonth = (cell: MonthCell): void => {
|
||||
const d = cloneDate(internalDateTime.value);
|
||||
d.setFullYear(displayCacheDateTime.value.getFullYear(), cell.idx);
|
||||
clampDateTime(d);
|
||||
internalDateTime.value = d;
|
||||
if (mode.value !== TabType.Month) switchTab(TabType.Day);
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Day Panel
|
||||
|
||||
const dayCells = computed<Cell[]>(() => {
|
||||
const year = displayCacheDateTime.value.getFullYear();
|
||||
const month1 = displayCacheDateTime.value.getMonth() + 1;
|
||||
const offset = dayOfWeek(year, month1, 1);
|
||||
const days = MONTH_DAY_COUNT[month1 - 1]! + ((month1 === 2 && isLeapYear(year)) ? 1 : 0);
|
||||
const sameYearMonth = displayCacheDateTime.value.getFullYear() === internalDateTime.value.getFullYear()
|
||||
&& displayCacheDateTime.value.getMonth() === internalDateTime.value.getMonth();
|
||||
const pickedDay = sameYearMonth ? internalDateTime.value.getDate() : -1;
|
||||
const cells: Cell[] = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const counter = i - offset;
|
||||
if (counter < 0 || counter >= days) {
|
||||
cells.push({ value: null, picked: false });
|
||||
} else {
|
||||
const day = counter + 1;
|
||||
cells.push({ value: day, picked: day === pickedDay });
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
})
|
||||
|
||||
const dayRows = computed<Cell[][]>(() => {
|
||||
const cells = dayCells.value;
|
||||
const rows: Cell[][] = [];
|
||||
for (let i = 0; i < 6; i++) rows.push(cells.slice(i * 7, i * 7 + 7));
|
||||
return rows;
|
||||
})
|
||||
|
||||
const dayPanelTitle = computed<string>(() => {
|
||||
return `${displayCacheDateTime.value.getFullYear()} - ${universalGetMonth(displayCacheDateTime.value.getMonth())}`;
|
||||
})
|
||||
|
||||
const prevNextDay = (isPrev: boolean): void => {
|
||||
const d = cloneDate(displayCacheDateTime.value);
|
||||
d.setMonth(d.getMonth() + (isPrev ? -1 : 1));
|
||||
clampDateTime(d);
|
||||
displayCacheDateTime.value = d;
|
||||
}
|
||||
|
||||
const clickDay = (cell: Cell): void => {
|
||||
if (cell.value === null) return;
|
||||
const d = cloneDate(internalDateTime.value);
|
||||
d.setFullYear(
|
||||
displayCacheDateTime.value.getFullYear(),
|
||||
displayCacheDateTime.value.getMonth(),
|
||||
cell.value);
|
||||
clampDateTime(d);
|
||||
internalDateTime.value = d;
|
||||
if (mode.value !== TabType.Day) switchTab(TabType.Hour);
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Hour / Minute Dial
|
||||
|
||||
const hourHand = computed<{ x: number; y: number }>(() => {
|
||||
const h = displayCacheDateTime.value.getHours();
|
||||
const angle = (3 - h) * DIAL_PLATE_HOUR_RESOLUTION;
|
||||
const radius = DIAL_PLATE_RADIUS * (h < 12 ? DIAL_PLATE_HOUR_OUTER_PERCENT : DIAL_PLATE_HOUR_INNER_PERCENT);
|
||||
return {
|
||||
x: Math.cos(angle) * radius + DIAL_PLATE_RADIUS,
|
||||
y: -Math.sin(angle) * radius + DIAL_PLATE_RADIUS,
|
||||
};
|
||||
})
|
||||
|
||||
const minuteHand = computed<{ x: number; y: number }>(() => {
|
||||
const m = displayCacheDateTime.value.getMinutes();
|
||||
const angle = (15 - m) * DIAL_PLATE_MINUTE_RESOLUTION;
|
||||
const radius = DIAL_PLATE_RADIUS * DIAL_PLATE_MINUTE_PERCENT;
|
||||
return {
|
||||
x: Math.cos(angle) * radius + DIAL_PLATE_RADIUS,
|
||||
y: -Math.sin(angle) * radius + DIAL_PLATE_RADIUS,
|
||||
};
|
||||
})
|
||||
|
||||
// static numeric labels around each dial (positions match the legacy template)
|
||||
const hourOuterLabels = computed(() => Array.from({ length: 12 }, (_, h) => {
|
||||
const a = h * DIAL_PLATE_HOUR_RESOLUTION;
|
||||
return { label: String(h), x: 100 + 80 * Math.sin(a), y: 100 - 80 * Math.cos(a) };
|
||||
}))
|
||||
const hourInnerLabels = computed(() => Array.from({ length: 12 }, (_, h) => {
|
||||
const a = h * DIAL_PLATE_HOUR_RESOLUTION;
|
||||
return { label: String(h + 12), x: 100 + 60 * Math.sin(a), y: 100 - 60 * Math.cos(a) };
|
||||
}))
|
||||
const minuteLabels = computed(() => Array.from({ length: 12 }, (_, i) => {
|
||||
const m = i * 5;
|
||||
const a = m * DIAL_PLATE_MINUTE_RESOLUTION;
|
||||
return { label: String(m), x: 100 + 80 * Math.sin(a), y: 100 - 80 * Math.cos(a) };
|
||||
}))
|
||||
|
||||
const getUniformedXY = (e: MouseEvent | TouchEvent, el: SVGSVGElement): { x: number; y: number } => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const halfWidth = rect.width / 2;
|
||||
const halfHeight = rect.height / 2;
|
||||
const halfSquare = Math.min(rect.width, rect.height) / 2;
|
||||
let realX = 0, realY = 0;
|
||||
if ('targetTouches' in e) {
|
||||
const te = e as TouchEvent;
|
||||
if (te.targetTouches.length >= 1) {
|
||||
realX = te.targetTouches[0]!.clientX;
|
||||
realY = te.targetTouches[0]!.clientY;
|
||||
}
|
||||
} else {
|
||||
const me = e as MouseEvent;
|
||||
realX = me.clientX;
|
||||
realY = me.clientY;
|
||||
}
|
||||
const x = (realX - rect.left - halfWidth) / halfSquare * DIAL_PLATE_RADIUS;
|
||||
const y = -((realY - rect.top - halfHeight) / halfSquare * DIAL_PLATE_RADIUS);
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
const startDragHour = (): void => { enableHourDrag.value = true; }
|
||||
|
||||
const draggingHour = (e: MouseEvent | TouchEvent): void => {
|
||||
if (!enableHourDrag.value || !hourSvgEl.value) return;
|
||||
|
||||
const { x, y } = getUniformedXY(e, hourSvgEl.value);
|
||||
|
||||
const distance = Math.sqrt(x * x + y * y);
|
||||
if (distance < 0.001) return;
|
||||
let angle = Math.acos(x / distance);
|
||||
if (y < 0) angle = Math.PI * 2 - angle; // correct negative y axis angle
|
||||
|
||||
angle += DIAL_PLATE_HOUR_RESOLUTION / 2;
|
||||
if (angle > Math.PI * 2) angle -= Math.PI * 2;
|
||||
|
||||
let number = Math.floor(angle / DIAL_PLATE_HOUR_RESOLUTION);
|
||||
if (number >= 12) number = 11; // prevent unexpected result at the edge.
|
||||
number = (15 - number) % 12;
|
||||
if (distance < DIAL_PLATE_RADIUS * DIAL_PLATE_HOUR_DISTINGUISH_PERCENT) number += 12;
|
||||
|
||||
// judge
|
||||
if (displayCacheDateTime.value.getHours() !== number) {
|
||||
const d = cloneDate(displayCacheDateTime.value);
|
||||
d.setHours(number);
|
||||
displayCacheDateTime.value = d;
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const stopDragHour = (): void => {
|
||||
if (!enableHourDrag.value) return;
|
||||
enableHourDrag.value = false;
|
||||
const d = cloneDate(internalDateTime.value);
|
||||
d.setHours(displayCacheDateTime.value.getHours());
|
||||
clampDateTime(d);
|
||||
internalDateTime.value = d;
|
||||
if (mode.value !== TabType.Hour) switchTab(TabType.Minute);
|
||||
}
|
||||
|
||||
const startDragMinute = (): void => { enableMinuteDrag.value = true; }
|
||||
|
||||
const draggingMinute = (e: MouseEvent | TouchEvent): void => {
|
||||
if (!enableMinuteDrag.value || !minuteSvgEl.value) return;
|
||||
|
||||
const { x, y } = getUniformedXY(e, minuteSvgEl.value);
|
||||
|
||||
const distance = Math.sqrt(x * x + y * y);
|
||||
if (distance < 0.001) return;
|
||||
let angle = Math.acos(x / distance);
|
||||
if (y < 0) angle = Math.PI * 2 - angle; // correct negative y axis angle
|
||||
|
||||
angle += DIAL_PLATE_MINUTE_RESOLUTION / 2; // correct offset
|
||||
if (angle > Math.PI * 2) angle -= Math.PI * 2;
|
||||
|
||||
let number = Math.floor(angle / DIAL_PLATE_MINUTE_RESOLUTION);
|
||||
if (number >= 60) number = 59; // prevent unexpected result at the edge.
|
||||
number = (75 - number) % 60;
|
||||
|
||||
// judge
|
||||
if (displayCacheDateTime.value.getMinutes() !== number) {
|
||||
const d = cloneDate(displayCacheDateTime.value);
|
||||
d.setMinutes(number);
|
||||
displayCacheDateTime.value = d;
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const stopDragMinute = (): void => {
|
||||
if (!enableMinuteDrag.value) return;
|
||||
enableMinuteDrag.value = false;
|
||||
const d = cloneDate(internalDateTime.value);
|
||||
d.setMinutes(displayCacheDateTime.value.getMinutes());
|
||||
clampDateTime(d);
|
||||
internalDateTime.value = d;
|
||||
// Minute is the deepest tab; no further advance.
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region: SVG font scaling
|
||||
|
||||
const recomputeSvgFont = (): void => {
|
||||
const el = currentTab.value === TabType.Hour ? hourSvgEl.value
|
||||
: currentTab.value === TabType.Minute ? minuteSvgEl.value
|
||||
: null;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const minSide = Math.min(rect.width, rect.height);
|
||||
if (minSide > 0) svgFontEm.value = DIAL_PLATE_WIDTH / minSide;
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
const onWindowResize = (): void => recomputeSvgFont();
|
||||
|
||||
onMounted(() => {
|
||||
if (pickerBodyEl.value && typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(() => recomputeSvgFont());
|
||||
resizeObserver.observe(pickerBodyEl.value);
|
||||
}
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
})
|
||||
|
||||
watch(currentTab, (t) => {
|
||||
if (t === TabType.Hour || t === TabType.Minute) {
|
||||
// immediately trigger once svg resize
|
||||
nextTick(() => recomputeSvgFont());
|
||||
}
|
||||
})
|
||||
|
||||
// endregion
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal" :class="{ 'is-active': isVisible }"
|
||||
style="float: left; position: fixed; top: 0; bottom: 0; left: 0; right: 0;">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card" style="height: 70%;">
|
||||
<header class="modal-card-head pickerHeader">
|
||||
<div v-for="t in visibleTabs" :key="t" @click="switchTab(t)">
|
||||
<small>{{ tabLabel(t) }}</small>
|
||||
<span>{{ headerValue(t) }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div ref="pickerBodyEl" class="modal-card-body pickerContainer">
|
||||
<div v-show="isYearTab">
|
||||
<nav class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div class="level-item control">
|
||||
<a class="button" @click="prevNextYear(true)">
|
||||
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-left" /></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item">{{ yearPanelTitle }}</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item control">
|
||||
<a class="button" @click="prevNextYear(false)">
|
||||
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right" /></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="perfectTable">
|
||||
<div v-for="(row, ri) in yearRows" :key="ri">
|
||||
<div v-for="(cell, ci) in row" :key="ci" :class="{ picked: cell.picked }" @click="clickYear(cell)">
|
||||
<template v-if="cell.value !== null">{{ cell.value }}</template>
|
||||
<template v-else> </template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="isMonthTab">
|
||||
<nav class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div class="level-item control">
|
||||
<a class="button" @click="prevNextMonth(true)">
|
||||
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-left" /></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item">{{ monthPanelTitle }}</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item control">
|
||||
<a class="button" @click="prevNextMonth(false)">
|
||||
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right" /></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="perfectTable">
|
||||
<div v-for="(row, ri) in monthRows" :key="ri">
|
||||
<div v-for="cell in row" :key="cell.idx" :class="{ picked: cell.picked }" @click="clickMonth(cell)">
|
||||
{{ cell.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="isDayTab">
|
||||
<nav class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div class="level-item control">
|
||||
<a class="button" @click="prevNextDay(true)">
|
||||
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-left" /></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item">{{ dayPanelTitle }}</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item control">
|
||||
<a class="button" @click="prevNextDay(false)">
|
||||
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right" /></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="perfectTable">
|
||||
<div>
|
||||
<div v-for="(w, i) in WEEK_NAMES" :key="i">{{ w }}</div>
|
||||
</div>
|
||||
<div v-for="(row, ri) in dayRows" :key="ri">
|
||||
<div v-for="(cell, ci) in row" :key="ci" :class="{ picked: cell.picked }" @click="clickDay(cell)">
|
||||
<template v-if="cell.value !== null">{{ cell.value }}</template>
|
||||
<template v-else> </template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg v-show="isHourTab" ref="hourSvgEl" xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="xMidYMid" viewBox="0 0 200 200" :style="{ fontSize: svgFontEm + 'em' }"
|
||||
@mousedown="startDragHour" @mousemove="draggingHour" @mouseup="stopDragHour"
|
||||
@mouseleave="stopDragHour" @touchstart="startDragHour" @touchmove="draggingHour"
|
||||
@touchend="stopDragHour">
|
||||
<circle cx="100" cy="100" r="100" type="background"></circle>
|
||||
<line x1="100" y1="100" :x2="hourHand.x" :y2="hourHand.y"></line>
|
||||
<circle :cx="hourHand.x" :cy="hourHand.y" r="1em" type="symbol"></circle>
|
||||
<text v-for="(lb, i) in hourOuterLabels" :key="'o' + i" :x="lb.x" :y="lb.y">{{ lb.label }}</text>
|
||||
<text v-for="(lb, i) in hourInnerLabels" :key="'i' + i" :x="lb.x" :y="lb.y">{{ lb.label }}</text>
|
||||
</svg>
|
||||
|
||||
<svg v-show="isMinuteTab" ref="minuteSvgEl" xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="xMidYMid" viewBox="0 0 200 200" :style="{ fontSize: svgFontEm + 'em' }"
|
||||
@mousedown="startDragMinute" @mousemove="draggingMinute" @mouseup="stopDragMinute"
|
||||
@mouseleave="stopDragMinute" @touchstart="startDragMinute" @touchmove="draggingMinute"
|
||||
@touchend="stopDragMinute">
|
||||
<circle cx="100" cy="100" r="100" type="background"></circle>
|
||||
<line x1="100" y1="100" :x2="minuteHand.x" :y2="minuteHand.y"></line>
|
||||
<circle :cx="minuteHand.x" :cy="minuteHand.y" r="1em" type="symbol"></circle>
|
||||
<text v-for="(lb, i) in minuteLabels" :key="i" :x="lb.x" :y="lb.y">{{ lb.label }}</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<footer class="modal-card-foot">
|
||||
<a class="button is-success" @click="confirm"><span>OK</span></a>
|
||||
<a class="button" @click="cancel"><span>Cancel</span></a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
div.perfectTable {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
/* div.perfectTable > div > div:nth-child(1) {
|
||||
border-left: 1px solid black;
|
||||
}
|
||||
|
||||
div.perfectTable > div:nth-child(1) > div {
|
||||
border-top: 1px solid black;
|
||||
} */
|
||||
|
||||
div.perfectTable > div > div {
|
||||
/* border-top: 0 solid black;
|
||||
border-left: 0 solid black;
|
||||
border-right: 1px solid black;
|
||||
border-bottom: 1px solid black;
|
||||
|
||||
padding: 0.75em; */
|
||||
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
div.perfectTable > div {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
}
|
||||
|
||||
div.perfectTable > div > div.picked {
|
||||
background: hsl(171, 100%, 41%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
div.pickerContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-flow: row;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
div.pickerContainer > div {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
div.pickerContainer > svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
div.pickerContainer > svg > text {
|
||||
dominant-baseline: middle;
|
||||
text-anchor: middle;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
div.pickerContainer > svg > circle[type=background] {
|
||||
stroke-width: 0;
|
||||
fill: #d0d0d0;
|
||||
}
|
||||
|
||||
div.pickerContainer > svg > circle[type=symbol] {
|
||||
stroke-width: 0;
|
||||
fill: hsl(171, 100%, 41%); /* $primary */
|
||||
}
|
||||
|
||||
div.pickerContainer > svg > line {
|
||||
stroke-width: 0.125em;
|
||||
stroke: hsl(171, 100%, 41%); /* $primary */
|
||||
}
|
||||
|
||||
|
||||
header.pickerHeader {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
|
||||
flex-grow: 0;
|
||||
flex-basis: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
header.pickerHeader > div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user