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>
|
||||||
@@ -1,8 +1,26 @@
|
|||||||
<script setup lang="ts"></script>
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import DateTimePicker, { TabType } from '@/components/DateTimePicker.vue';
|
||||||
|
|
||||||
|
// NOTE: This is a temporary test harness for the DateTimePicker.
|
||||||
|
// It will be removed when the Calendar page is migrated for real.
|
||||||
|
const picker = ref<InstanceType<typeof DateTimePicker>>();
|
||||||
|
const testDate = ref<Date>(new Date());
|
||||||
|
|
||||||
|
const openPicker = () => {
|
||||||
|
picker.value?.modal(testDate.value, TabType.Minute);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConfirm = (d: Date) => {
|
||||||
|
testDate.value = d;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1>Congratulations</h1>
|
<div class="container" style="margin-top: 1.25rem;">
|
||||||
<p>This is calendar.</p>
|
<a class="button is-primary" @click="openPicker">{{ testDate.toLocaleString() }}</a>
|
||||||
|
</div>
|
||||||
|
<DateTimePicker ref="picker" @confirm="onConfirm" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
Reference in New Issue
Block a user