calendar: add hover target and staged wheel zoom
This commit is contained in:
249
frontend/app.vue
249
frontend/app.vue
@@ -2073,6 +2073,7 @@ onBeforeUnmount(() => {
|
|||||||
lifecycleClock = null;
|
lifecycleClock = null;
|
||||||
}
|
}
|
||||||
clearCalendarZoomOverlay();
|
clearCalendarZoomOverlay();
|
||||||
|
clearCalendarZoomPrime();
|
||||||
});
|
});
|
||||||
|
|
||||||
const calendarView = ref<CalendarView>("year");
|
const calendarView = ref<CalendarView>("year");
|
||||||
@@ -2130,9 +2131,19 @@ const calendarZoomOverlay = ref<{ active: boolean } & CalendarRect>({
|
|||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
});
|
});
|
||||||
|
const calendarZoomBusy = ref(false);
|
||||||
|
const calendarSceneMasked = ref(false);
|
||||||
|
const calendarZoomPrimeToken = ref("");
|
||||||
|
const calendarZoomPrimeScale = ref(1);
|
||||||
|
const calendarZoomPrimeTicks = ref(0);
|
||||||
let calendarWheelLockUntil = 0;
|
let calendarWheelLockUntil = 0;
|
||||||
let calendarZoomOverlayTimer: ReturnType<typeof setTimeout> | null = null;
|
let calendarZoomOverlayTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
const CALENDAR_ZOOM_DURATION_MS = 180;
|
let calendarZoomPrimeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let calendarZoomPrimeLastAt = 0;
|
||||||
|
const CALENDAR_ZOOM_DURATION_MS = 2400;
|
||||||
|
const CALENDAR_ZOOM_PRIME_STEPS = 2;
|
||||||
|
const CALENDAR_ZOOM_PRIME_MAX_SCALE = 1.05;
|
||||||
|
const CALENDAR_ZOOM_PRIME_RESET_MS = 900;
|
||||||
const calendarZoomOrder: CalendarHierarchyView[] = ["year", "month", "week", "day"];
|
const calendarZoomOrder: CalendarHierarchyView[] = ["year", "month", "week", "day"];
|
||||||
|
|
||||||
const normalizedCalendarView = computed<CalendarHierarchyView>(() =>
|
const normalizedCalendarView = computed<CalendarHierarchyView>(() =>
|
||||||
@@ -2153,6 +2164,61 @@ function clearCalendarZoomOverlay() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearCalendarZoomPrime() {
|
||||||
|
if (calendarZoomPrimeTimer) {
|
||||||
|
clearTimeout(calendarZoomPrimeTimer);
|
||||||
|
calendarZoomPrimeTimer = null;
|
||||||
|
}
|
||||||
|
calendarZoomPrimeToken.value = "";
|
||||||
|
calendarZoomPrimeScale.value = 1;
|
||||||
|
calendarZoomPrimeTicks.value = 0;
|
||||||
|
calendarZoomPrimeLastAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarPrimeMonthToken(monthIndex: number) {
|
||||||
|
return `year-month-${monthIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarPrimeWeekToken(startKey: string) {
|
||||||
|
return `month-week-${startKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarPrimeDayToken(key: string) {
|
||||||
|
return `week-day-${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarPrimeStyle(token: string) {
|
||||||
|
if (calendarZoomPrimeToken.value !== token) return undefined;
|
||||||
|
return {
|
||||||
|
transform: `scale(${calendarZoomPrimeScale.value})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybePrimeWheelZoom(event: WheelEvent | undefined, token: string) {
|
||||||
|
if (!event || event.deltaY >= 0) return false;
|
||||||
|
const now = Date.now();
|
||||||
|
if (calendarZoomPrimeToken.value !== token || now - calendarZoomPrimeLastAt > CALENDAR_ZOOM_PRIME_RESET_MS) {
|
||||||
|
calendarZoomPrimeTicks.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
calendarZoomPrimeToken.value = token;
|
||||||
|
calendarZoomPrimeTicks.value += 1;
|
||||||
|
calendarZoomPrimeLastAt = now;
|
||||||
|
|
||||||
|
if (calendarZoomPrimeTicks.value <= CALENDAR_ZOOM_PRIME_STEPS) {
|
||||||
|
const ratio = calendarZoomPrimeTicks.value / CALENDAR_ZOOM_PRIME_STEPS;
|
||||||
|
calendarZoomPrimeScale.value = 1 + (CALENDAR_ZOOM_PRIME_MAX_SCALE - 1) * ratio;
|
||||||
|
if (calendarZoomPrimeTimer) clearTimeout(calendarZoomPrimeTimer);
|
||||||
|
calendarZoomPrimeTimer = setTimeout(() => {
|
||||||
|
clearCalendarZoomPrime();
|
||||||
|
}, CALENDAR_ZOOM_PRIME_RESET_MS);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCalendarZoomPrime();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function queryCalendarElement(selector: string) {
|
function queryCalendarElement(selector: string) {
|
||||||
return calendarContentWrapRef.value?.querySelector<HTMLElement>(selector) ?? null;
|
return calendarContentWrapRef.value?.querySelector<HTMLElement>(selector) ?? null;
|
||||||
}
|
}
|
||||||
@@ -2229,11 +2295,20 @@ async function animateCalendarZoomIn(sourceElement: HTMLElement | null, apply: (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
primeCalendarRect(fromRect);
|
clearCalendarZoomPrime();
|
||||||
apply();
|
calendarZoomBusy.value = true;
|
||||||
await nextTick();
|
try {
|
||||||
morphCalendarRect(viewportRect);
|
primeCalendarRect(fromRect);
|
||||||
await waitCalendarZoom();
|
calendarSceneMasked.value = true;
|
||||||
|
await nextTick();
|
||||||
|
apply();
|
||||||
|
await nextTick();
|
||||||
|
morphCalendarRect(viewportRect);
|
||||||
|
await waitCalendarZoom();
|
||||||
|
} finally {
|
||||||
|
calendarSceneMasked.value = false;
|
||||||
|
calendarZoomBusy.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HTMLElement | null) {
|
async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HTMLElement | null) {
|
||||||
@@ -2243,19 +2318,28 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
primeCalendarRect(viewportRect);
|
clearCalendarZoomPrime();
|
||||||
apply();
|
calendarZoomBusy.value = true;
|
||||||
await nextTick();
|
try {
|
||||||
const targetRect = getElementRectInCalendar(resolveTarget());
|
primeCalendarRect(viewportRect);
|
||||||
if (!targetRect) {
|
calendarSceneMasked.value = true;
|
||||||
calendarZoomOverlay.value = {
|
await nextTick();
|
||||||
...calendarZoomOverlay.value,
|
apply();
|
||||||
active: false,
|
await nextTick();
|
||||||
};
|
const targetRect = getElementRectInCalendar(resolveTarget());
|
||||||
return;
|
if (!targetRect) {
|
||||||
|
calendarZoomOverlay.value = {
|
||||||
|
...calendarZoomOverlay.value,
|
||||||
|
active: false,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
morphCalendarRect(targetRect);
|
||||||
|
await waitCalendarZoom();
|
||||||
|
} finally {
|
||||||
|
calendarSceneMasked.value = false;
|
||||||
|
calendarZoomBusy.value = false;
|
||||||
}
|
}
|
||||||
morphCalendarRect(targetRect);
|
|
||||||
await waitCalendarZoom();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMonthAnchor(event?: WheelEvent) {
|
function resolveMonthAnchor(event?: WheelEvent) {
|
||||||
@@ -2290,7 +2374,9 @@ async function zoomInCalendar(event?: Event) {
|
|||||||
const wheelEvent = event instanceof WheelEvent ? event : undefined;
|
const wheelEvent = event instanceof WheelEvent ? event : undefined;
|
||||||
if (calendarView.value === "year") {
|
if (calendarView.value === "year") {
|
||||||
const monthIndex = resolveMonthAnchor(wheelEvent);
|
const monthIndex = resolveMonthAnchor(wheelEvent);
|
||||||
await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => {
|
const sourceElement = queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`);
|
||||||
|
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeMonthToken(monthIndex))) return;
|
||||||
|
await animateCalendarZoomIn(sourceElement, () => {
|
||||||
openYearMonth(monthIndex);
|
openYearMonth(monthIndex);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -2299,9 +2385,12 @@ async function zoomInCalendar(event?: Event) {
|
|||||||
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
||||||
const anchorDayKey = resolveWeekAnchor(wheelEvent);
|
const anchorDayKey = resolveWeekAnchor(wheelEvent);
|
||||||
const rowStartKey = weekRowStartForDate(anchorDayKey);
|
const rowStartKey = weekRowStartForDate(anchorDayKey);
|
||||||
await animateCalendarZoomIn(
|
const sourceElement =
|
||||||
queryCalendarElement(`[data-calendar-week-start-key="${rowStartKey}"]`) ??
|
queryCalendarElement(`[data-calendar-week-start-key="${rowStartKey}"]`) ??
|
||||||
queryCalendarElement(`[data-calendar-day-key="${anchorDayKey}"]`),
|
queryCalendarElement(`[data-calendar-day-key="${anchorDayKey}"]`);
|
||||||
|
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeWeekToken(rowStartKey))) return;
|
||||||
|
await animateCalendarZoomIn(
|
||||||
|
sourceElement,
|
||||||
() => {
|
() => {
|
||||||
openWeekView(anchorDayKey);
|
openWeekView(anchorDayKey);
|
||||||
},
|
},
|
||||||
@@ -2311,7 +2400,9 @@ async function zoomInCalendar(event?: Event) {
|
|||||||
|
|
||||||
if (calendarView.value === "week") {
|
if (calendarView.value === "week") {
|
||||||
const dayAnchor = resolveDayAnchor(wheelEvent);
|
const dayAnchor = resolveDayAnchor(wheelEvent);
|
||||||
await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`), () => {
|
const sourceElement = queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`);
|
||||||
|
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return;
|
||||||
|
await animateCalendarZoomIn(sourceElement, () => {
|
||||||
openDayView(dayAnchor);
|
openDayView(dayAnchor);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2325,6 +2416,7 @@ async function zoomToMonth(monthIndex: number) {
|
|||||||
|
|
||||||
async function zoomOutCalendar() {
|
async function zoomOutCalendar() {
|
||||||
focusedCalendarEventId.value = "";
|
focusedCalendarEventId.value = "";
|
||||||
|
clearCalendarZoomPrime();
|
||||||
|
|
||||||
if (calendarView.value === "day") {
|
if (calendarView.value === "day") {
|
||||||
const targetDayKey = selectedDateKey.value;
|
const targetDayKey = selectedDateKey.value;
|
||||||
@@ -2363,9 +2455,10 @@ async function zoomOutCalendar() {
|
|||||||
|
|
||||||
function onCalendarHierarchyWheel(event: WheelEvent) {
|
function onCalendarHierarchyWheel(event: WheelEvent) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
if (calendarZoomBusy.value) return;
|
||||||
if (now < calendarWheelLockUntil) return;
|
if (now < calendarWheelLockUntil) return;
|
||||||
if (Math.abs(event.deltaY) < 5) return;
|
if (Math.abs(event.deltaY) < 5) return;
|
||||||
calendarWheelLockUntil = now + 240;
|
calendarWheelLockUntil = now + 140;
|
||||||
|
|
||||||
if (event.deltaY < 0) {
|
if (event.deltaY < 0) {
|
||||||
void zoomInCalendar(event);
|
void zoomInCalendar(event);
|
||||||
@@ -4303,14 +4396,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
>
|
>
|
||||||
<span>→</span>
|
<span>→</span>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
|
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
|
||||||
@wheel.prevent="onCalendarHierarchyWheel"
|
@wheel.prevent="onCalendarHierarchyWheel"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
:class="normalizedCalendarView === 'day' ? 'calendar-scene cursor-zoom-out' : 'calendar-scene cursor-zoom-in'"
|
:class="[
|
||||||
@mouseleave="calendarHoveredMonthIndex = null; calendarHoveredWeekStartKey = ''; calendarHoveredDayKey = ''"
|
'calendar-scene',
|
||||||
>
|
normalizedCalendarView === 'day' ? 'cursor-zoom-out' : 'cursor-zoom-in',
|
||||||
|
calendarSceneMasked ? 'calendar-scene-hidden' : '',
|
||||||
|
]"
|
||||||
|
@mouseleave="calendarHoveredMonthIndex = null; calendarHoveredWeekStartKey = ''; calendarHoveredDayKey = ''; clearCalendarZoomPrime()"
|
||||||
|
>
|
||||||
<div v-if="calendarView === 'month'" class="space-y-1">
|
<div v-if="calendarView === 'month'" class="space-y-1">
|
||||||
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60">
|
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60">
|
||||||
<span>Sun</span>
|
<span>Sun</span>
|
||||||
@@ -4323,13 +4420,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div
|
<div
|
||||||
v-for="row in monthRows"
|
v-for="row in monthRows"
|
||||||
:key="row.key"
|
:key="row.key"
|
||||||
class="group relative"
|
class="group relative calendar-hover-targetable"
|
||||||
:data-calendar-week-start-key="row.startKey"
|
:class="[
|
||||||
@mouseenter="calendarHoveredWeekStartKey = row.startKey"
|
calendarHoveredWeekStartKey === row.startKey ? 'calendar-hover-target' : '',
|
||||||
>
|
calendarZoomPrimeToken === calendarPrimeWeekToken(row.startKey) ? 'calendar-zoom-prime-active' : '',
|
||||||
|
]"
|
||||||
|
:style="calendarPrimeStyle(calendarPrimeWeekToken(row.startKey))"
|
||||||
|
:data-calendar-week-start-key="row.startKey"
|
||||||
|
@mouseenter="calendarHoveredWeekStartKey = row.startKey"
|
||||||
|
>
|
||||||
<div class="grid grid-cols-7 gap-1">
|
<div class="grid grid-cols-7 gap-1">
|
||||||
<button
|
<button
|
||||||
v-for="cell in row.cells"
|
v-for="cell in row.cells"
|
||||||
@@ -4362,14 +4464,19 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
|
|
||||||
<div v-else-if="calendarView === 'week'" class="calendar-week-scroll overflow-x-auto pb-1">
|
<div v-else-if="calendarView === 'week'" class="calendar-week-scroll overflow-x-auto pb-1">
|
||||||
<div class="calendar-week-grid">
|
<div class="calendar-week-grid">
|
||||||
<article
|
<article
|
||||||
v-for="day in weekDays"
|
v-for="day in weekDays"
|
||||||
:key="day.key"
|
:key="day.key"
|
||||||
class="group relative flex min-h-[18rem] flex-col rounded-xl border border-base-300 bg-base-100 p-2.5 cursor-zoom-in"
|
class="group relative flex min-h-[18rem] flex-col rounded-xl border border-base-300 bg-base-100 p-2.5 cursor-zoom-in calendar-hover-targetable"
|
||||||
:class="selectedDateKey === day.key ? 'border-primary bg-primary/5' : ''"
|
:class="[
|
||||||
:data-calendar-day-key="day.key"
|
selectedDateKey === day.key ? 'border-primary bg-primary/5' : '',
|
||||||
@mouseenter="calendarHoveredDayKey = day.key"
|
calendarHoveredDayKey === day.key ? 'calendar-hover-target' : '',
|
||||||
@click="pickDate(day.key)"
|
calendarZoomPrimeToken === calendarPrimeDayToken(day.key) ? 'calendar-zoom-prime-active' : '',
|
||||||
|
]"
|
||||||
|
:style="calendarPrimeStyle(calendarPrimeDayToken(day.key))"
|
||||||
|
:data-calendar-day-key="day.key"
|
||||||
|
@mouseenter="calendarHoveredDayKey = day.key"
|
||||||
|
@click="pickDate(day.key)"
|
||||||
>
|
>
|
||||||
<div class="mb-2 flex items-start justify-between gap-2">
|
<div class="mb-2 flex items-start justify-between gap-2">
|
||||||
<p class="text-sm font-semibold leading-tight">{{ day.label }} {{ day.day }}</p>
|
<p class="text-sm font-semibold leading-tight">{{ day.label }} {{ day.day }}</p>
|
||||||
@@ -4407,13 +4514,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="calendarView === 'year'" class="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
<div v-else-if="calendarView === 'year'" class="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<button
|
<button
|
||||||
v-for="item in yearMonths"
|
v-for="item in yearMonths"
|
||||||
:key="`year-month-${item.monthIndex}`"
|
:key="`year-month-${item.monthIndex}`"
|
||||||
class="group relative rounded-xl border border-base-300 p-3 text-left transition hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in"
|
class="group relative rounded-xl border border-base-300 p-3 text-left transition hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in calendar-hover-targetable"
|
||||||
:data-calendar-month-index="item.monthIndex"
|
:class="[
|
||||||
@mouseenter="calendarHoveredMonthIndex = item.monthIndex"
|
calendarHoveredMonthIndex === item.monthIndex ? 'calendar-hover-target' : '',
|
||||||
@click="zoomToMonth(item.monthIndex)"
|
calendarZoomPrimeToken === calendarPrimeMonthToken(item.monthIndex) ? 'calendar-zoom-prime-active' : '',
|
||||||
|
]"
|
||||||
|
:style="calendarPrimeStyle(calendarPrimeMonthToken(item.monthIndex))"
|
||||||
|
:data-calendar-month-index="item.monthIndex"
|
||||||
|
@mouseenter="calendarHoveredMonthIndex = item.monthIndex"
|
||||||
|
@click="zoomToMonth(item.monthIndex)"
|
||||||
>
|
>
|
||||||
<p class="font-medium">{{ item.label }}</p>
|
<p class="font-medium">{{ item.label }}</p>
|
||||||
<p class="text-xs text-base-content/60">{{ item.count }} events</p>
|
<p class="text-xs text-base-content/60">{{ item.count }} events</p>
|
||||||
@@ -5529,6 +5641,10 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
transform-origin: center center;
|
transform-origin: center center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-scene-hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-scene.cursor-zoom-in,
|
.calendar-scene.cursor-zoom-in,
|
||||||
.calendar-scene.cursor-zoom-in * {
|
.calendar-scene.cursor-zoom-in * {
|
||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
@@ -5577,6 +5693,21 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
right: 4px;
|
right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-hover-targetable {
|
||||||
|
transform-origin: center center;
|
||||||
|
transition: transform 320ms ease, box-shadow 320ms ease, outline-color 320ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-hover-target {
|
||||||
|
outline: 2px solid color-mix(in oklab, var(--color-primary) 66%, transparent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-primary) 32%, transparent) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-zoom-prime-active {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-zoom-inline {
|
.calendar-zoom-inline {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -5660,15 +5791,15 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
.calendar-zoom-overlay {
|
.calendar-zoom-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
border: 2px solid color-mix(in oklab, var(--color-primary) 60%, transparent);
|
border: 3px solid color-mix(in oklab, var(--color-primary) 64%, transparent);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
|
background: color-mix(in oklab, var(--color-primary) 14%, transparent);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition:
|
transition:
|
||||||
left 180ms cubic-bezier(0.2, 0.85, 0.25, 1),
|
left 2400ms cubic-bezier(0.16, 0.86, 0.18, 1),
|
||||||
top 180ms cubic-bezier(0.2, 0.85, 0.25, 1),
|
top 2400ms cubic-bezier(0.16, 0.86, 0.18, 1),
|
||||||
width 180ms cubic-bezier(0.2, 0.85, 0.25, 1),
|
width 2400ms cubic-bezier(0.16, 0.86, 0.18, 1),
|
||||||
height 180ms cubic-bezier(0.2, 0.85, 0.25, 1);
|
height 2400ms cubic-bezier(0.16, 0.86, 0.18, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user