Files
CRM_Montzar/apps/web/src/components/JalaliDateInput.vue
T
MahdiandCursor fdf1fbe4aa feat(activities): peek at a card from the board
Let people read notes, postpone, and mark an activity done beside the card instead of opening the full page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 20:10:16 +03:30

1282 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Button as AButton, Input as AInput } from 'ant-design-vue';
import {
CalendarOutlined,
ClockCircleOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons-vue';
import {
addJalaliMonths,
dateToJalali,
formatJalaliNumeric,
formatLocalDateInput,
formatLocalDateTimeInput,
jalaliMonthLength,
jalaliMonthName,
jalaliToDate,
parseLocalInput,
toFaDigits,
} from '@/lib/jalali';
const model = defineModel<string>({ default: '' });
const props = withDefaults(
defineProps<{
mode?: 'date' | 'datetime' | 'time';
id?: string;
required?: boolean;
disabled?: boolean;
placeholder?: string;
clearable?: boolean;
icon?: 'calendar' | 'clock' | 'bell';
/** اگر داده شود، متن باکس به‌جای تاریخ خام همین مقدار است. */
displayText?: string;
/** نمایی که با باز شدن پنل دیده می‌شود. */
startView?: 'days' | 'months' | 'years';
}>(),
{
mode: 'date',
required: false,
disabled: false,
clearable: true,
icon: undefined,
displayText: '',
startView: 'days',
},
);
const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] as const;
const MOBILE_MQ = '(max-width: 720px)';
const open = ref(false);
const triggerRef = ref<HTMLElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const clockRef = ref<HTMLElement | null>(null);
const hourColRef = ref<HTMLElement | null>(null);
const minuteColRef = ref<HTMLElement | null>(null);
const top = ref(0);
const left = ref(0);
/** بالای مودال Ant (حدود ۱۰۰۰) تا تقویم پشت پنجره نماند */
const popupZ = ref(120);
const todayJ = dateToJalali(new Date());
const cursor = ref<{ jy: number; jm: number }>({ jy: todayJ.jy, jm: todayJ.jm });
const draftHour = ref(9);
const draftMinute = ref(0);
const clockFace = ref<'hour' | 'minute'>('hour');
const isMobile = ref(false);
const dragging = ref(false);
/** نمای تقویم: روزها ← کلیک عنوان → ماه‌ها ← کلیک عنوان → سال‌ها */
const calView = ref<'days' | 'months' | 'years'>('days');
const YEAR_PAGE = 12;
const yearPageStart = ref(todayJ.jy - 5);
const MONTHS = Array.from({ length: 12 }, (_, i) => i + 1);
type Cell = {
key: string;
jy: number;
jm: number;
jd: number;
inMonth: boolean;
isToday: boolean;
isSelected: boolean;
};
const parsed = computed(() => (props.mode === 'time' ? null : parseLocalInput(model.value)));
const triggerIcon = computed(() => {
if (props.icon) return props.icon;
if (props.mode === 'time') return 'clock';
if (props.icon === 'bell') return 'bell';
return 'calendar';
});
const display = computed(() => {
if (props.displayText) return props.displayText;
if (props.mode === 'time') {
const match = /^(\d{1,2}):(\d{2})$/.exec(model.value);
if (!match) return '';
return `${toFaDigits(match[1].padStart(2, '0'))}:${toFaDigits(match[2])}`;
}
if (!parsed.value) return '';
const date = formatJalaliNumeric(parsed.value);
if (props.mode === 'date') return date;
const hh = String(parsed.value.getHours()).padStart(2, '0');
const mm = String(parsed.value.getMinutes()).padStart(2, '0');
return `${date} ${toFaDigits(hh)}:${toFaDigits(mm)}`;
});
const emptyLabel = computed(
() =>
props.placeholder ||
(props.mode === 'datetime'
? 'انتخاب تاریخ و ساعت'
: props.mode === 'time'
? 'انتخاب ساعت'
: 'انتخاب تاریخ'),
);
const monthTitle = computed(
() => `${jalaliMonthName(cursor.value.jm)} ${toFaDigits(cursor.value.jy)}`,
);
const navTitle = computed(() => {
if (calView.value === 'months') return toFaDigits(cursor.value.jy);
if (calView.value === 'years') {
return `${toFaDigits(yearPageStart.value)} – ${toFaDigits(yearPageStart.value + YEAR_PAGE - 1)}`;
}
return monthTitle.value;
});
const navLabels = computed(() => {
if (calView.value === 'months') return { forward: 'سال بعد', back: 'سال قبل' };
if (calView.value === 'years') return { forward: 'سال‌های بعد', back: 'سال‌های قبل' };
return { forward: 'ماه بعد', back: 'ماه قبل' };
});
const yearOptions = computed(() =>
Array.from({ length: YEAR_PAGE }, (_, i) => yearPageStart.value + i),
);
const selectedJ = computed(() => (parsed.value ? dateToJalali(parsed.value) : null));
const hours = Array.from({ length: 24 }, (_, i) => i);
const minuteMarks = Array.from({ length: 12 }, (_, i) => i * 5);
const columnMinutes = Array.from({ length: 60 }, (_, i) => i);
const clockMinutes = computed(() => {
if (!minuteMarks.includes(draftMinute.value)) {
return [...minuteMarks, draftMinute.value].sort((a, b) => a - b);
}
return minuteMarks;
});
const draftTimeLabel = computed(
() =>
`${toFaDigits(String(draftHour.value).padStart(2, '0'))}:${toFaDigits(String(draftMinute.value).padStart(2, '0'))}`,
);
const hourHandInner = computed(() => draftHour.value >= 12);
const clockHandDeg = computed(() =>
clockFace.value === 'hour' ? (draftHour.value % 12) * 30 : draftMinute.value * 6,
);
function pad2(n: number) {
return String(n).padStart(2, '0');
}
function faPad(n: number) {
return toFaDigits(pad2(n));
}
function dialPoint(index: number, total: number, radius: number) {
const angle = (index / total) * 2 * Math.PI - Math.PI / 2;
return {
left: `calc(50% + ${Math.cos(angle) * radius}px)`,
top: `calc(50% + ${Math.sin(angle) * radius}px)`,
};
}
function hourDialStyle(hour: number) {
const ring = hour < 12 ? 92 : 58;
return dialPoint(hour % 12, 12, ring);
}
function minuteDialStyle(minute: number) {
const index = minuteMarks.includes(minute)
? minuteMarks.indexOf(minute)
: ((minute % 60) + 60) % 60 / 5;
return dialPoint(index, 12, 92);
}
function setHour(hour: number, opts?: { keepFace?: boolean }) {
draftHour.value = hour;
applyTime();
if (isMobile.value && !opts?.keepFace) clockFace.value = 'minute';
}
function setMinute(minute: number) {
draftMinute.value = minute;
applyTime();
}
function valueFromPointer(ev: PointerEvent) {
const el = clockRef.value;
if (!el) return;
const rect = el.getBoundingClientRect();
const dx = ev.clientX - (rect.left + rect.width / 2);
const dy = ev.clientY - (rect.top + rect.height / 2);
const dist = Math.hypot(dx, dy);
let deg = (Math.atan2(dy, dx) * 180) / Math.PI + 90;
if (deg < 0) deg += 360;
const index = Math.round(deg / 30) % 12;
if (clockFace.value === 'hour') {
const inner = dist < rect.width * 0.28;
setHour(inner ? index + 12 : index, { keepFace: dragging.value });
return;
}
setMinute((index * 5) % 60);
}
function onClockPointerDown(ev: PointerEvent) {
dragging.value = true;
clockRef.value?.setPointerCapture(ev.pointerId);
valueFromPointer(ev);
}
function onClockPointerMove(ev: PointerEvent) {
if (!dragging.value) return;
valueFromPointer(ev);
}
function onClockPointerUp(ev: PointerEvent) {
if (!dragging.value) return;
dragging.value = false;
clockRef.value?.releasePointerCapture(ev.pointerId);
if (clockFace.value === 'hour') clockFace.value = 'minute';
}
function syncViewport() {
isMobile.value = window.matchMedia(MOBILE_MQ).matches;
}
function scrollListToSelected(list: HTMLElement | null) {
const item = list?.querySelector<HTMLElement>('.on');
if (!list || !item) return;
list.scrollTop = item.offsetTop - list.clientHeight / 2 + item.clientHeight / 2;
}
function scrollTimeColumns() {
scrollListToSelected(hourColRef.value);
scrollListToSelected(minuteColRef.value);
}
const cells = computed<Cell[]>(() => {
const { jy, jm } = cursor.value;
const first = jalaliToDate(jy, jm, 1);
const firstDow = (first.getDay() + 1) % 7;
const today = dateToJalali(new Date());
const selected = parsed.value ? dateToJalali(parsed.value) : null;
const out: Cell[] = [];
const prev = addJalaliMonths(jy, jm, -1);
const prevLen = jalaliMonthLength(prev.jy, prev.jm);
for (let i = 0; i < firstDow; i += 1) {
const jd = prevLen - firstDow + i + 1;
out.push(makeCell(prev.jy, prev.jm, jd, false, today, selected));
}
const len = jalaliMonthLength(jy, jm);
for (let jd = 1; jd <= len; jd += 1) {
out.push(makeCell(jy, jm, jd, true, today, selected));
}
const next = addJalaliMonths(jy, jm, 1);
let n = 1;
while (out.length % 7 !== 0) {
out.push(makeCell(next.jy, next.jm, n, false, today, selected));
n += 1;
}
return out;
});
function makeCell(
jy: number,
jm: number,
jd: number,
inMonth: boolean,
today: { jy: number; jm: number; jd: number },
selected: { jy: number; jm: number; jd: number } | null,
): Cell {
return {
key: `${jy}-${jm}-${jd}`,
jy,
jm,
jd,
inMonth,
isToday: today.jy === jy && today.jm === jm && today.jd === jd,
isSelected: Boolean(selected && selected.jy === jy && selected.jm === jm && selected.jd === jd),
};
}
function emitDate(d: Date) {
model.value = props.mode === 'datetime' ? formatLocalDateTimeInput(d) : formatLocalDateInput(d);
}
function emitTime() {
const pad = (n: number) => String(n).padStart(2, '0');
model.value = `${pad(draftHour.value)}:${pad(draftMinute.value)}`;
}
function syncDraftFrom(d: Date | null) {
if (props.mode === 'time') {
const match = /^(\d{1,2}):(\d{2})$/.exec(model.value);
draftHour.value = match ? Number(match[1]) : 10;
draftMinute.value = match ? Number(match[2]) : 30;
return;
}
const source = d ?? new Date();
const j = dateToJalali(source);
cursor.value = { jy: j.jy, jm: j.jm };
draftHour.value = props.mode === 'datetime' ? source.getHours() : 9;
draftMinute.value = props.mode === 'datetime' ? source.getMinutes() : 0;
}
function place() {
const trigger = triggerRef.value;
const panel = panelRef.value;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width =
panel?.offsetWidth ||
(props.mode === 'time' ? (isMobile.value ? 340 : 280) : 328);
const height =
panel?.offsetHeight ||
(props.mode === 'time' ? (isMobile.value ? 460 : 320) : 340);
const margin = 8;
let nextLeft = rect.left;
if (getComputedStyle(document.documentElement).direction === 'rtl') {
nextLeft = rect.right - width;
}
nextLeft = Math.min(
Math.max(margin, nextLeft),
Math.max(margin, window.innerWidth - width - margin),
);
const spaceBelow = window.innerHeight - rect.bottom - margin * 2;
const spaceAbove = rect.top - margin * 2;
let nextTop: number;
if (height <= spaceBelow) {
nextTop = rect.bottom + margin;
} else if (height <= spaceAbove) {
nextTop = rect.top - height - margin;
} else if (spaceBelow >= spaceAbove) {
nextTop = Math.max(margin, window.innerHeight - height - margin);
} else {
nextTop = margin;
}
left.value = nextLeft;
top.value = nextTop;
}
function liftAboveOverlays() {
let z = 120;
for (const el of document.querySelectorAll('.ant-modal-wrap, .ant-drawer, .ant-popover, [role="dialog"], [data-lead-filter]')) {
const zi = Number.parseInt(window.getComputedStyle(el).zIndex, 10);
if (Number.isFinite(zi)) z = Math.max(z, zi + 20);
}
popupZ.value = z;
}
function onWindowChange() {
syncViewport();
if (open.value) place();
}
async function show() {
if (props.disabled) return;
syncDraftFrom(parsed.value);
calView.value = props.startView;
clockFace.value = 'hour';
syncViewport();
liftAboveOverlays();
open.value = true;
await nextTick();
place();
await nextTick();
scrollTimeColumns();
place();
}
function hide() {
open.value = false;
}
function toggle() {
if (open.value) hide();
else void show();
}
function goMonth(delta: number) {
const next = addJalaliMonths(cursor.value.jy, cursor.value.jm, delta);
cursor.value = next;
void nextTick(place);
}
function navStep(delta: number) {
if (calView.value === 'months') {
cursor.value = { jy: cursor.value.jy + delta, jm: cursor.value.jm };
} else if (calView.value === 'years') {
yearPageStart.value += delta * YEAR_PAGE;
} else {
goMonth(delta);
}
}
function onTitleClick() {
if (calView.value === 'days') {
calView.value = 'months';
} else if (calView.value === 'months') {
yearPageStart.value = cursor.value.jy - 5;
calView.value = 'years';
}
void nextTick(place);
}
function pickMonth(jm: number) {
cursor.value = { jy: cursor.value.jy, jm };
calView.value = 'days';
void nextTick(place);
}
function pickYear(jy: number) {
cursor.value = { jy, jm: cursor.value.jm };
calView.value = 'months';
void nextTick(place);
}
function pickDay(cell: Cell) {
if (!cell.inMonth) {
cursor.value = { jy: cell.jy, jm: cell.jm };
}
const d = jalaliToDate(cell.jy, cell.jm, cell.jd);
if (props.mode === 'datetime') {
d.setHours(draftHour.value, draftMinute.value, 0, 0);
emitDate(d);
return;
}
emitDate(d);
hide();
}
function applyTime() {
if (props.mode === 'time') {
emitTime();
return;
}
const source = parsed.value ?? jalaliToDate(cursor.value.jy, cursor.value.jm, 1);
const j = dateToJalali(source);
const d = jalaliToDate(j.jy, j.jm, j.jd);
d.setHours(draftHour.value, draftMinute.value, 0, 0);
emitDate(d);
}
function pickToday() {
const d = new Date();
if (props.mode === 'time') {
draftHour.value = d.getHours();
draftMinute.value = d.getMinutes();
emitTime();
return;
}
if (props.mode === 'date') {
d.setHours(0, 0, 0, 0);
}
syncDraftFrom(d);
emitDate(d);
if (props.mode === 'date') hide();
}
function clear() {
model.value = '';
hide();
}
function onDocPointer(ev: PointerEvent) {
const target = ev.target as Node | null;
if (!target) return;
if (triggerRef.value?.contains(target) || panelRef.value?.contains(target)) return;
hide();
}
function onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape' && open.value) {
ev.stopPropagation();
ev.preventDefault();
hide();
}
}
watch(open, (value) => {
if (value) {
window.addEventListener('resize', onWindowChange);
window.addEventListener('scroll', onWindowChange, true);
document.addEventListener('pointerdown', onDocPointer, true);
window.addEventListener('keydown', onKeydown, true);
return;
}
dragging.value = false;
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
document.removeEventListener('pointerdown', onDocPointer, true);
window.removeEventListener('keydown', onKeydown, true);
});
watch(isMobile, () => {
if (open.value) void nextTick(place);
});
watch(
() => model.value,
() => {
if (props.mode === 'time') syncDraftFrom(null);
},
{ immediate: true },
);
onMounted(() => {
syncViewport();
window.matchMedia(MOBILE_MQ).addEventListener('change', syncViewport);
});
onBeforeUnmount(() => {
window.matchMedia(MOBILE_MQ).removeEventListener('change', syncViewport);
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
document.removeEventListener('pointerdown', onDocPointer, true);
window.removeEventListener('keydown', onKeydown, true);
});
</script>
<template>
<div
ref="triggerRef"
class="wrap"
:class="{ disabled, 'time-only': mode === 'time' }"
style="width: 100%"
>
<a-input
:id="id"
readonly
:value="display"
:placeholder="emptyLabel"
:disabled="disabled"
:aria-expanded="open"
:aria-required="required || undefined"
style="width: 100%; cursor: pointer"
@click="toggle"
>
<template #suffix>
<ClockCircleOutlined v-if="triggerIcon === 'clock'" />
<CalendarOutlined v-else />
</template>
</a-input>
<Teleport to="body">
<div
v-if="open"
id="jalali-picker"
ref="panelRef"
class="panel"
:class="{
'panel-time': mode === 'time',
'is-mobile': isMobile,
'is-desktop': !isMobile,
}"
role="dialog"
:style="{ top: `${top}px`, left: `${left}px`, zIndex: popupZ }"
>
<header v-if="mode !== 'time'" class="nav">
<a-button type="text" class="nav-btn" :aria-label="navLabels.forward" @click="navStep(1)">
<template #icon><RightOutlined /></template>
</a-button>
<a-button
type="text"
class="nav-title"
:class="{ static: calView === 'years' }"
:aria-label="calView === 'days' ? 'انتخاب ماه و سال' : calView === 'months' ? 'انتخاب سال' : undefined"
@click="onTitleClick"
>
{{ navTitle }}
</a-button>
<a-button type="text" class="nav-btn next" :aria-label="navLabels.back" @click="navStep(-1)">
<template #icon><LeftOutlined /></template>
</a-button>
</header>
<div v-if="mode !== 'time' && calView === 'months'" class="pick-grid">
<a-button
v-for="m in MONTHS"
:key="`pm-${m}`"
type="text"
class="pick-cell"
:class="{
on: selectedJ?.jy === cursor.jy && selectedJ?.jm === m,
today: todayJ.jy === cursor.jy && todayJ.jm === m,
}"
@click="pickMonth(m)"
>
{{ jalaliMonthName(m) }}
</a-button>
</div>
<div v-else-if="mode !== 'time' && calView === 'years'" class="pick-grid">
<a-button
v-for="y in yearOptions"
:key="`py-${y}`"
type="text"
class="pick-cell"
:class="{ on: selectedJ?.jy === y, today: todayJ.jy === y }"
@click="pickYear(y)"
>
{{ toFaDigits(y) }}
</a-button>
</div>
<template v-if="mode !== 'time' && calView === 'days'">
<div class="week">
<span v-for="day in WEEKDAYS" :key="day">{{ day }}</span>
</div>
<div class="grid">
<a-button
v-for="cell in cells"
:key="cell.key"
type="text"
class="day"
:class="{ mute: !cell.inMonth, today: cell.isToday, on: cell.isSelected }"
@click="pickDay(cell)"
>
{{ toFaDigits(cell.jd) }}
</a-button>
</div>
</template>
<div v-if="mode === 'datetime' || mode === 'time'" class="time-box">
<template v-if="isMobile">
<p class="md-label">{{ mode === 'time' ? 'انتخاب ساعت' : 'ساعت' }}</p>
<div class="md-digits" dir="ltr">
<a-button
type="text"
class="md-chip"
:class="{ on: clockFace === 'hour' }"
aria-label="ساعت"
@click="clockFace = 'hour'"
>
{{ faPad(draftHour) }}
</a-button>
<span class="md-colon">:</span>
<a-button
type="text"
class="md-chip"
:class="{ on: clockFace === 'minute' }"
aria-label="دقیقه"
@click="clockFace = 'minute'"
>
{{ faPad(draftMinute) }}
</a-button>
</div>
<div
ref="clockRef"
class="md-clock"
role="slider"
:aria-label="clockFace === 'hour' ? 'ساعت' : 'دقیقه'"
@pointerdown.prevent="onClockPointerDown"
@pointermove.prevent="onClockPointerMove"
@pointerup.prevent="onClockPointerUp"
@pointercancel.prevent="onClockPointerUp"
>
<span
class="md-arm"
:class="{ short: clockFace === 'hour' && hourHandInner }"
:style="{ transform: `rotate(${clockHandDeg}deg)` }"
>
<i class="md-line" />
<i class="md-knob" />
</span>
<i class="md-hub" />
<template v-if="clockFace === 'hour'">
<a-button
v-for="h in hours"
:key="`h-${h}`"
type="text"
class="md-tick"
:class="{ on: draftHour === h, inner: h >= 12 }"
:style="hourDialStyle(h)"
tabindex="-1"
@click.stop="setHour(h)"
>
{{ toFaDigits(h) }}
</a-button>
</template>
<template v-else>
<a-button
v-for="m in clockMinutes"
:key="`m-${m}`"
type="text"
class="md-tick"
:class="{ on: draftMinute === m }"
:style="minuteDialStyle(m)"
tabindex="-1"
@click.stop="setMinute(m)"
>
{{ faPad(m) }}
</a-button>
</template>
</div>
</template>
<div v-else class="time-cols">
<p v-if="mode === 'time'" class="cols-title">{{ draftTimeLabel }}</p>
<div class="cols">
<div class="col">
<span class="col-label">ساعت</span>
<div ref="hourColRef" class="col-list" role="listbox" aria-label="ساعت">
<a-button
v-for="h in hours"
:key="`col-h-${h}`"
type="text"
class="col-item"
:class="{ on: draftHour === h }"
@click="setHour(h)"
>
{{ faPad(h) }}
</a-button>
</div>
</div>
<div class="col">
<span class="col-label">دقیقه</span>
<div ref="minuteColRef" class="col-list" role="listbox" aria-label="دقیقه">
<a-button
v-for="m in columnMinutes"
:key="`col-m-${m}`"
type="text"
class="col-item"
:class="{ on: draftMinute === m }"
@click="setMinute(m)"
>
{{ faPad(m) }}
</a-button>
</div>
</div>
</div>
</div>
</div>
<footer class="foot">
<a-button type="link" @click="pickToday">
{{ mode === 'time' ? 'الان' : 'امروز' }}
</a-button>
<a-button
v-if="clearable && !required && model"
type="link"
@click="clear"
>
پاک کردن
</a-button>
<a-button type="primary" @click="hide">تأیید</a-button>
</footer>
</div>
</Teleport>
</div>
</template>
<style scoped>
.wrap {
position: relative;
width: 100%;
min-width: 0;
}
.trigger {
width: 100%;
min-height: var(--control-h);
height: var(--control-h);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.45rem;
border: 1px solid var(--line, #e2e8f0);
border-radius: var(--radius);
padding: 0 0.75rem;
background: #fff;
color: #0f172a;
font: inherit;
text-align: start;
cursor: pointer;
}
.clock-trigger {
justify-content: flex-start;
gap: 0.8rem;
min-height: 52px;
border-radius: var(--radius);
padding: 0.45rem 0.85rem 0.45rem 0.45rem;
background: linear-gradient(180deg, #ffffff 0%, #f8fffd 100%);
}
.clock-face {
position: relative;
width: 42px;
height: 42px;
border-radius: 50%;
flex-shrink: 0;
background:
radial-gradient(circle at center, #fff 0 38%, transparent 39%),
conic-gradient(from 0deg, #ecfdf5, #ccfbf1, #ecfdf5);
border: 2px solid #99f6e4;
box-shadow: inset 0 0 0 1px rgba(13, 148, 136, 0.08);
}
.hand {
position: absolute;
left: 50%;
bottom: 50%;
transform-origin: center bottom;
border-radius: var(--radius);
background: #0f766e;
}
.hour-hand {
width: 2.5px;
height: 11px;
margin-inline-start: -1.25px;
}
.minute-hand {
width: 2px;
height: 15px;
margin-inline-start: -1px;
background: #14b8a6;
}
.hub {
position: absolute;
inset: 50% auto auto 50%;
width: 5px;
height: 5px;
margin: -2.5px;
border-radius: 50%;
background: #0f766e;
}
.clock-text {
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
font-size: 1.05rem;
color: #0f172a;
}
.trigger:focus {
outline: 2px solid var(--accent-soft, #ccfbf1);
border-color: var(--accent, #0d9488);
}
.trigger span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trigger .empty {
color: #94a3b8;
font-weight: 600;
font-size: 0.92rem;
}
.trigger :deep(.ico) {
color: #0d9488;
flex-shrink: 0;
}
.disabled .trigger {
opacity: 0.55;
cursor: not-allowed;
}
.panel {
position: fixed;
z-index: 120;
box-sizing: border-box;
width: min(20.5rem, calc(100vw - 16px));
/* تقویم باید یکجا دیده شود؛ اسکرول داخل پنل گیج‌کننده است */
max-height: none;
overflow: hidden;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
padding: 0.75rem 0.8rem 0.7rem;
font-family: IRANSans, Tahoma, sans-serif;
}
@media (max-height: 420px) {
.panel {
max-height: calc(100vh - 16px);
overflow: auto;
}
}
.panel-time {
width: min(22.5rem, calc(100vw - 16px));
}
.panel-time.is-desktop {
width: min(17.5rem, calc(100vw - 16px));
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.55rem;
}
.nav-title {
font-size: 0.92rem;
font-weight: 700;
color: #0f172a;
}
.nav-title:not(.static):hover {
color: #0f766e;
}
.nav-title.static {
cursor: default;
}
.pick-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.35rem;
padding: 0.25rem 0;
}
.panel :deep(.pick-cell.ant-btn) {
width: 100% !important;
min-width: 0 !important;
height: 44px !important;
padding-inline: 0 !important;
border-radius: var(--radius);
color: #0f172a;
font-size: 0.85rem;
}
.panel :deep(.pick-cell.today) {
box-shadow: inset 0 0 0 1px #99f6e4;
}
.panel :deep(.pick-cell.on),
.panel :deep(.pick-cell.ant-btn:hover) {
background: #0d9488;
color: #fff;
}
.nav-btn {
width: 32px;
height: 32px;
border: 0;
border-radius: var(--radius);
background: #f1f5f9;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.nav-btn.next :deep(.ico) {
transform: scaleX(-1);
}
.week,
.grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.15rem;
width: 100%;
min-width: 0;
}
.week span {
text-align: center;
color: #94a3b8;
font-size: 0.72rem;
padding-bottom: 0.25rem;
}
.panel :deep(.day.ant-btn) {
width: 100% !important;
min-width: 0 !important;
height: 34px !important;
padding: 0 !important;
border: 0;
border-radius: var(--radius);
background: none;
color: #0f172a;
font: inherit;
font-size: 0.82rem;
line-height: 1;
cursor: pointer;
}
.panel :deep(.day.ant-btn > span) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
}
.panel :deep(.day.mute) {
color: #cbd5e1;
}
.panel :deep(.day.today) {
box-shadow: inset 0 0 0 1px #99f6e4;
}
.panel :deep(.day.on),
.panel :deep(.day.ant-btn:hover) {
background: #0d9488;
color: #fff;
}
.panel :deep(.day.mute:hover) {
background: #f1f5f9;
color: #64748b;
}
.time-box {
margin-top: 0.55rem;
}
.grid + .time-box {
margin-top: 0.7rem;
padding-top: 0.65rem;
border-top: 1px solid #e2e8f0;
}
.md-label {
margin: 0 0 0.55rem;
color: #64748b;
font-size: 0.78rem;
font-weight: 700;
}
.md-digits {
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
margin-bottom: 0.85rem;
}
.md-chip {
min-width: 5.1rem;
height: 4.1rem;
border: 0;
border-radius: var(--radius);
background: #f1f5f9;
color: #0f172a;
font: inherit;
font-size: 2.35rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
cursor: pointer;
}
.md-chip.on {
background: #ccfbf1;
color: #0f766e;
box-shadow: inset 0 0 0 2px #99f6e4;
}
.md-colon {
font-size: 2.2rem;
font-weight: 600;
color: #0f172a;
line-height: 1;
padding-bottom: 0.15rem;
}
.md-clock {
position: relative;
width: 236px;
height: 236px;
margin: 0 auto;
border-radius: 50%;
background: #e2e8f0;
touch-action: none;
user-select: none;
}
.md-arm {
position: absolute;
left: 50%;
top: 50%;
width: 36px;
height: 110px;
margin: -110px 0 0 -18px;
transform-origin: center bottom;
pointer-events: none;
z-index: 1;
}
.md-arm.short {
height: 76px;
margin-top: -76px;
}
.md-line {
position: absolute;
left: 50%;
bottom: 0;
width: 2px;
height: calc(100% - 18px);
margin-left: -1px;
background: #0d9488;
}
.md-knob {
position: absolute;
top: 0;
left: 0;
width: 36px;
height: 36px;
border-radius: 50%;
background: #0d9488;
}
.md-clock > .md-hub {
position: absolute;
inset: 50% auto auto 50%;
width: 8px;
height: 8px;
margin: -4px;
border-radius: 50%;
background: #0d9488;
z-index: 3;
pointer-events: none;
}
.md-tick {
position: absolute;
z-index: 2;
width: 36px;
height: 36px;
margin: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: #334155;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
transform: translate(-50%, -50%);
display: grid;
place-items: center;
padding: 0;
}
.md-tick.inner {
font-size: 0.74rem;
color: #64748b;
}
.md-tick.on {
background: transparent;
color: #fff;
}
.time-cols {
display: grid;
gap: 0.45rem;
}
.cols-title {
margin: 0;
text-align: center;
font-size: 1.2rem;
font-weight: 600;
color: #0f766e;
letter-spacing: 0.04em;
}
.cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.55rem;
}
.col {
display: grid;
gap: 0.3rem;
min-width: 0;
}
.col-label {
text-align: center;
font-size: 0.75rem;
color: #64748b;
font-weight: 700;
}
.col-list {
height: 220px;
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #f8fafc;
padding: 0.25rem;
scrollbar-width: thin;
overscroll-behavior: contain;
}
.col-item {
width: 100%;
height: 36px;
border: 0;
border-radius: var(--radius);
background: transparent;
color: #334155;
font: inherit;
font-weight: 700;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.col-item:hover {
background: #e2e8f0;
}
.col-item.on {
background: #0d9488;
color: #fff;
}
.foot {
display: flex;
align-items: center;
gap: 0.55rem;
margin-top: 0.7rem;
}
.link,
.done {
border: 0;
background: none;
font: inherit;
cursor: pointer;
}
.link {
color: #0f766e;
font-size: 0.8rem;
font-weight: 700;
}
.done {
margin-inline-start: auto;
background: #14b8a6;
color: #fff;
border-radius: var(--radius);
padding: 0.4rem 0.8rem;
font-weight: 600;
font-size: 0.8rem;
}
.panel.is-mobile .time-box {
padding-top: 0.15rem;
}
</style>