calendar/src/components/EventDialog.vue
Leo Vasanko d9352a9fb3 Event search (Ctrl+F), locale/RTL handling, weekday selector workday/weekend, refactored event handling, Firefox compatibility (#3)
Major refactoring for cleanup, with various bugfixes.

Weekday selector in Settings now shows workdays/weekend bars based on current locale (also used to choose default values). Weekday selector in event dialog uses the days set in settings, as expected.
2025-08-27 13:41:46 +01:00

982 lines
28 KiB
Vue

<script setup>
import { useCalendarStore } from '@/stores/CalendarStore'
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import BaseDialog from './BaseDialog.vue'
import WeekdaySelector from './WeekdaySelector.vue'
import Numeric from './Numeric.vue'
import {
addDaysStr,
getMondayOfISOWeek,
fromLocalString,
formatDateShort,
formatDateLong,
DEFAULT_TZ,
} from '@/utils/date'
import { getDate as getOccurrenceDate } from '@/utils/events'
import { addDays, addMonths } from 'date-fns'
const props = defineProps({
selection: { type: Object, default: () => ({ startDate: null, dayCount: 0 }) },
})
const emit = defineEmits(['clear-selection'])
const calendarStore = useCalendarStore()
const showDialog = ref(false)
// Anchoring: element of the DayCell representing the event's start date.
const anchorElement = ref(null)
const dialogMode = ref('create') // 'create' or 'edit'
const editingEventId = ref(null)
const unsavedCreateId = ref(null)
const occurrenceContext = ref(null) // { baseId, occurrenceIndex, weekday, occurrenceDate }
const initialWeekday = ref(null)
const title = computed({
get() {
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
return calendarStore.events.get(editingEventId.value).title || ''
}
return ''
},
set(v) {
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
calendarStore.events.get(editingEventId.value).title = v
calendarStore.touchEvents()
}
},
})
const recurrenceEnabled = ref(false)
const recurrenceInterval = ref(1) // N in "Every N weeks/months"
const recurrenceFrequency = ref('weeks') // 'weeks' | 'months'
const recurrenceWeekdays = ref([false, false, false, false, false, false, false])
const recurrenceOccurrences = ref(0) // 0 = unlimited
const colorId = ref(0)
const eventSaved = ref(false)
const titleInput = ref(null)
const uiDisplayFrequency = ref('weeks') // 'weeks' | 'months' | 'years'
// Helper to get starting weekday (Sunday-first index)
function getStartingWeekday(selectionData = null) {
const currentSelection = selectionData || props.selection
if (!currentSelection.start) return 0
const date = fromLocalString(currentSelection.start, DEFAULT_TZ)
return date.getDay() // 0=Sunday, 6=Saturday
}
const fallbackWeekdays = computed(() => {
let weekday = initialWeekday.value
if (weekday == null) {
weekday = getStartingWeekday()
}
const fb = [false, false, false, false, false, false, false]
fb[weekday] = true
return fb
})
// Maps UI frequency display (including years) to store frequency (weeks/months only)
const displayFrequency = computed({
get() {
return uiDisplayFrequency.value
},
set(val) {
const oldFreq = uiDisplayFrequency.value
const currentDisplayValue = displayInterval.value
uiDisplayFrequency.value = val
if (oldFreq === val) return
if (oldFreq === 'years' && val === 'months') {
recurrenceFrequency.value = 'months'
recurrenceInterval.value = currentDisplayValue
} else if (oldFreq === 'months' && val === 'years') {
recurrenceFrequency.value = 'months'
recurrenceInterval.value = currentDisplayValue * 12
} else if (val === 'years') {
recurrenceFrequency.value = 'months'
recurrenceInterval.value = 12
} else if (val === 'months') {
recurrenceFrequency.value = 'months'
if (oldFreq === 'weeks') {
recurrenceInterval.value = 1
}
} else if (val === 'weeks') {
recurrenceFrequency.value = 'weeks'
recurrenceInterval.value = 1
}
},
})
// Computed property for display interval (handles years conversion)
const displayInterval = computed({
get() {
if (uiDisplayFrequency.value === 'years') {
return recurrenceInterval.value / 12
}
return recurrenceInterval.value
},
set(val) {
if (uiDisplayFrequency.value === 'years') {
recurrenceInterval.value = val * 12
} else {
recurrenceInterval.value = val
}
},
})
const selectedColor = computed({
get() {
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
return calendarStore.events.get(editingEventId.value).colorId
}
return colorId.value
},
set(v) {
const n = parseInt(v)
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
calendarStore.events.get(editingEventId.value).colorId = n
calendarStore.touchEvents()
}
colorId.value = n
},
})
// Maps 0 (unlimited) to 'unlimited' string in store
const repeatCountBinding = computed({
get() {
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
const ev = calendarStore.events.get(editingEventId.value)
const rc = ev.recur?.count ?? 'unlimited'
return rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0
}
return recurrenceOccurrences.value
},
set(v) {
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
const ev = calendarStore.events.get(editingEventId.value)
if (!ev.recur && v !== 0) {
ev.recur = {
freq: recurrenceFrequency.value,
interval: recurrenceInterval.value,
count: 'unlimited',
weekdays: recurrenceFrequency.value === 'weeks' ? buildStoreWeekdayPattern() : null,
}
}
if (ev.recur) ev.recur.count = v === 0 ? 'unlimited' : String(v)
calendarStore.touchEvents()
}
recurrenceOccurrences.value = v
},
})
const repeat = computed({
get() {
if (!recurrenceEnabled.value) return 'none'
return recurrenceFrequency.value
},
set(val) {
if (val === 'none') {
recurrenceEnabled.value = false
return
}
recurrenceEnabled.value = true
if (val === 'weeks') {
recurrenceFrequency.value = 'weeks'
uiDisplayFrequency.value = 'weeks'
if (recurrenceInterval.value >= 12) {
recurrenceInterval.value = 1 // Reset to sensible weekly default
}
} else if (val === 'months') {
recurrenceFrequency.value = 'months'
}
},
})
function buildStoreWeekdayPattern() {
return [...recurrenceWeekdays.value]
}
function loadWeekdayPatternFromStore(storePattern) {
if (!Array.isArray(storePattern) || storePattern.length !== 7) return
recurrenceWeekdays.value = [...storePattern]
}
function resolveAnchorFromDate(dateStr) {
if (!dateStr) return null
// Expect day cells to have data-date attribute (see CalendarDay / DayCell components)
return document.querySelector(`[data-date='${dateStr}']`)
}
function openCreateDialog(selectionData = null) {
calendarStore.$history?.beginCompound()
if (unsavedCreateId.value && !eventSaved.value) {
if (calendarStore.events?.has(unsavedCreateId.value)) {
calendarStore.deleteEvent(unsavedCreateId.value)
}
unsavedCreateId.value = null
}
const currentSelection = selectionData || props.selection
let start, end
if (currentSelection.startDate && currentSelection.dayCount) {
start = currentSelection.startDate
end = addDaysStr(currentSelection.startDate, currentSelection.dayCount - 1)
} else if (currentSelection.start && currentSelection.end) {
start = currentSelection.start
end = currentSelection.end
} else {
start = null
end = null
}
occurrenceContext.value = null
initialWeekday.value = null
dialogMode.value = 'create'
recurrenceEnabled.value = false
recurrenceInterval.value = 1
recurrenceFrequency.value = 'weeks'
uiDisplayFrequency.value = 'weeks'
recurrenceWeekdays.value = [false, false, false, false, false, false, false]
recurrenceOccurrences.value = 0
colorId.value = calendarStore.selectEventColorId(start, end)
eventSaved.value = false
const startingDay = getStartingWeekday({ start, end })
recurrenceWeekdays.value[startingDay] = true
initialWeekday.value = startingDay
let days = 1
if (start && end && start <= end) {
const s = fromLocalString(start, DEFAULT_TZ)
const e = fromLocalString(end, DEFAULT_TZ)
days = Math.max(1, (e - s) / 86400000 + 1)
}
editingEventId.value = calendarStore.createEvent({
title: '',
startDate: start,
days,
colorId: colorId.value,
recur:
recurrenceEnabled.value && repeat.value !== 'none'
? {
freq: recurrenceFrequency.value,
interval: recurrenceInterval.value,
count:
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value),
weekdays: recurrenceFrequency.value === 'weeks' ? buildStoreWeekdayPattern() : null,
}
: null,
})
unsavedCreateId.value = editingEventId.value
// anchor to the starting day cell
anchorElement.value = resolveAnchorFromDate(start)
showDialog.value = true
nextTick(() => {
if (titleInput.value) {
titleInput.value.focus()
if (title.value) {
titleInput.value.select()
}
}
})
}
function openEditDialog(payload) {
calendarStore.$history?.beginCompound()
if (
dialogMode.value === 'create' &&
unsavedCreateId.value &&
!eventSaved.value &&
payload &&
unsavedCreateId.value !== payload.id
) {
if (calendarStore.events?.has(unsavedCreateId.value)) {
calendarStore.deleteEvent(unsavedCreateId.value)
}
unsavedCreateId.value = null
}
occurrenceContext.value = null
initialWeekday.value = null
if (!payload) return
const baseId = payload.id
let n = payload.n || 0
let weekday = null
let occurrenceDate = null
const event = calendarStore.getEventById(baseId)
if (!event) return
if (event.recur && n >= 0) {
const occStr = getOccurrenceDate(event, n, DEFAULT_TZ)
if (occStr) {
occurrenceDate = fromLocalString(occStr, DEFAULT_TZ)
weekday = occurrenceDate.getDay()
}
}
dialogMode.value = 'edit'
editingEventId.value = baseId
loadWeekdayPatternFromStore(event.recur?.weekdays)
initialWeekday.value =
weekday != null ? weekday : fromLocalString(event.startDate, DEFAULT_TZ).getDay()
repeat.value = event.recur ? event.recur.freq : 'none'
if (event.recur?.interval) recurrenceInterval.value = event.recur.interval
// Set UI display frequency based on loaded data
if (event.recur?.freq === 'weeks') {
uiDisplayFrequency.value = 'weeks'
} else if (event.recur?.freq === 'months') {
if (event.recur.interval && event.recur.interval % 12 === 0 && event.recur.interval >= 12) {
uiDisplayFrequency.value = 'years'
} else {
uiDisplayFrequency.value = 'months'
}
} else {
uiDisplayFrequency.value = 'weeks'
}
const rc = event.recur?.count ?? 'unlimited'
recurrenceOccurrences.value = rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0
colorId.value = event.colorId
eventSaved.value = false
if (event.recur) {
if (event.recur.freq === 'weeks' && n >= 0) {
occurrenceContext.value = { baseId, occurrenceIndex: n, weekday, occurrenceDate }
} else if (event.recur.freq === 'months' && n > 0) {
occurrenceContext.value = { baseId, occurrenceIndex: n, weekday: null, occurrenceDate }
}
}
// anchor to base event start date
anchorElement.value = resolveAnchorFromDate(event.startDate)
showDialog.value = true
nextTick(() => {
if (titleInput.value) {
titleInput.value.focus()
if (title.value) {
titleInput.value.select()
}
}
})
}
function closeDialog() {
calendarStore.$history?.endCompound()
showDialog.value = false
}
function updateEventInStore() {
if (!editingEventId.value) return
if (calendarStore.events?.has(editingEventId.value)) {
const event = calendarStore.events.get(editingEventId.value)
event.colorId = colorId.value
if (recurrenceEnabled.value && repeat.value !== 'none') {
event.recur = {
freq: recurrenceFrequency.value,
interval: recurrenceInterval.value,
count:
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value),
weekdays: recurrenceFrequency.value === 'weeks' ? buildStoreWeekdayPattern() : null,
}
} else {
event.recur = null
}
calendarStore.touchEvents()
}
}
function saveEvent() {
if (editingEventId.value) updateEventInStore()
eventSaved.value = true
if (dialogMode.value === 'create') {
unsavedCreateId.value = null
}
if (dialogMode.value === 'create') emit('clear-selection')
calendarStore.$history?.endCompound()
closeDialog()
}
function deleteEventAll() {
if (editingEventId.value) calendarStore.deleteEvent(editingEventId.value)
calendarStore.$history?.endCompound()
closeDialog()
}
function deleteEventOne() {
if (occurrenceContext.value) {
calendarStore.deleteSingleOccurrence(occurrenceContext.value)
} else if (isRepeatingBaseEdit.value && editingEventId.value) {
calendarStore.deleteFirstOccurrence(editingEventId.value)
}
calendarStore.$history?.endCompound()
closeDialog()
}
function deleteEventFrom() {
if (!occurrenceContext.value) return
calendarStore.deleteFromOccurrence(occurrenceContext.value)
calendarStore.$history?.endCompound()
closeDialog()
}
onUnmounted(() => {
if (unsavedCreateId.value && !eventSaved.value) {
if (calendarStore.events?.has(unsavedCreateId.value))
calendarStore.deleteEvent(unsavedCreateId.value)
}
})
watch([recurrenceEnabled, recurrenceInterval, recurrenceFrequency], () => {
if (editingEventId.value && showDialog.value) updateEventInStore()
})
watch(showDialog, (val, oldVal) => {
if (oldVal && !val) {
// Closed (cancel, escape, outside click) -> end compound session
calendarStore.$history?.endCompound()
if (dialogMode.value === 'create' && unsavedCreateId.value && !eventSaved.value) {
if (calendarStore.events?.has(unsavedCreateId.value)) {
calendarStore.deleteEvent(unsavedCreateId.value)
}
}
editingEventId.value = null
unsavedCreateId.value = null
}
})
watch(
recurrenceWeekdays,
() => {
if (editingEventId.value && showDialog.value && repeat.value === 'weeks') updateEventInStore()
},
{ deep: true },
)
defineExpose({
openCreateDialog,
openEditDialog,
closeDialog,
})
const isRepeatingEdit = computed(
() => dialogMode.value === 'edit' && recurrenceEnabled.value && repeat.value !== 'none',
)
const showDeleteVariants = computed(() => isRepeatingEdit.value && occurrenceContext.value)
const isRepeatingBaseEdit = computed(() => isRepeatingEdit.value && !occurrenceContext.value)
const isLastOccurrence = computed(() => {
if (!occurrenceContext.value || !editingEventId.value) return false
const event = calendarStore.getEventById(editingEventId.value)
if (!event || !event.recur) return false
if (event.recur.count === 'unlimited' || recurrenceOccurrences.value === 0) return false
const totalCount = parseInt(event.recur.count, 10) || 0
return occurrenceContext.value.occurrenceIndex === totalCount - 1
})
const formattedOccurrenceShort = computed(() => {
if (occurrenceContext.value?.occurrenceDate) {
return formatDateShort(occurrenceContext.value.occurrenceDate)
}
if (isRepeatingBaseEdit.value && editingEventId.value) {
const ev = calendarStore.getEventById(editingEventId.value)
if (ev?.startDate) {
return formatDateShort(fromLocalString(ev.startDate, DEFAULT_TZ))
}
}
return ''
})
const headerDateShort = computed(() => {
if (occurrenceContext.value?.occurrenceDate) {
return formatDateShort(occurrenceContext.value.occurrenceDate)
}
if (editingEventId.value) {
const ev = calendarStore.getEventById(editingEventId.value)
if (ev?.startDate) {
return formatDateShort(fromLocalString(ev.startDate, DEFAULT_TZ))
}
}
return ''
})
const finalOccurrenceDate = computed(() => {
if (!recurrenceEnabled.value) return null
const count = recurrenceOccurrences.value
if (!count || count < 1) return null
const base = editingEventId.value ? calendarStore.getEventById(editingEventId.value) : null
if (!base) return null
const start = fromLocalString(base.startDate, DEFAULT_TZ)
if (uiDisplayFrequency.value === 'weeks') {
// iterate days until we count 'count-1' additional occurrences (first is base if selected weekday)
const pattern = buildStoreWeekdayPattern() // Sun..Sat
// Build Monday-first pattern again for selection clarity
const monFirst = recurrenceWeekdays.value
const selectedCount = monFirst.some(Boolean)
if (!selectedCount) return null
let occs = 0
// Determine if the start day counts
const startWeekdaySun = start.getDay()
// Convert to Monday-first index
// We'll just check store pattern
if (pattern[startWeekdaySun]) occs = 1
let cursor = new Date(start)
while (occs < count && occs < 10000) {
cursor = addDays(cursor, 1)
if (pattern[cursor.getDay()]) occs++
}
if (occs === count) return cursor
return null
} else if (uiDisplayFrequency.value === 'months') {
const monthsToAdd = displayInterval.value * (count - 1)
return addMonths(start, monthsToAdd)
} else if (uiDisplayFrequency.value === 'years') {
const yearsToAdd = displayInterval.value * (count - 1)
const d = new Date(start)
d.setFullYear(d.getFullYear() + yearsToAdd)
return d
}
})
const formattedFinalOccurrence = computed(() => {
const d = finalOccurrenceDate.value
if (!d) return ''
const now = new Date()
const includeYear =
d.getFullYear() !== now.getFullYear() ||
d.getTime() - now.getTime() >= 1000 * 60 * 60 * 24 * 365
return formatDateLong(d, includeYear)
})
const recurrenceSummary = computed(() => {
if (!recurrenceEnabled.value) return 'Does not recur'
if (uiDisplayFrequency.value === 'weeks') {
return displayInterval.value === 1 ? 'Weekly' : `Every ${displayInterval.value} weeks`
} else if (uiDisplayFrequency.value === 'years') {
return displayInterval.value === 1 ? 'Annually' : `Every ${displayInterval.value} years`
}
if (recurrenceFrequency.value === 'months' && recurrenceInterval.value % 12 === 0) {
const years = recurrenceInterval.value / 12
return years === 1 ? 'Annually' : `Every ${years} years`
}
return displayInterval.value === 1 ? 'Monthly' : `Every ${displayInterval.value} months`
})
</script>
<template>
<BaseDialog v-model="showDialog" :anchor-el="anchorElement" @submit="saveEvent">
<template #title>
<div class="dialog-title-row">
{{ dialogMode === 'create' ? 'Create Event' : 'Edit Event' }}
<span> · {{ headerDateShort }}</span>
</div>
</template>
<label class="ec-field">
<input type="text" v-model="title" autocomplete="off" ref="titleInput" />
</label>
<div class="ec-color-swatches">
<label v-for="i in 8" :key="i - 1" class="swatch-label">
<input
class="swatch"
:class="'event-color-' + (i - 1)"
type="radio"
name="colorId"
:value="i - 1"
v-model="selectedColor"
/>
</label>
</div>
<div class="recurrence-block">
<div class="recurrence-header">
<label class="switch">
<input type="checkbox" v-model="recurrenceEnabled" />
<span>Repeat</span>
</label>
<span class="recurrence-summary" v-if="recurrenceEnabled">
{{ recurrenceSummary }}
<span v-if="recurrenceOccurrences > 0"> until {{ formattedFinalOccurrence }} </span>
</span>
<span class="recurrence-summary muted" v-else>Does not recur</span>
</div>
<div v-if="recurrenceEnabled" class="recurrence-form">
<div class="line compact">
<Numeric
v-model="displayInterval"
:prefix-values="[{ value: 1, display: 'Every' }]"
:min="2"
number-prefix="Every "
aria-label="Interval"
/>
<select v-model="displayFrequency" class="freq-select">
<option value="weeks">{{ displayInterval === 1 ? 'week' : 'weeks' }}</option>
<option value="months">{{ displayInterval === 1 ? 'month' : 'months' }}</option>
<option value="years">{{ displayInterval === 1 ? 'year' : 'years' }}</option>
</select>
<Numeric
class="occ-stepper"
v-model="repeatCountBinding"
:min="2"
:prefix-values="[{ value: 0, display: '' }]"
number-postfix=" times"
aria-label="Occurrences (0 = no end)"
extra-class="occ"
/>
</div>
<div v-if="uiDisplayFrequency === 'weeks'" @click.stop>
<WeekdaySelector
v-model="recurrenceWeekdays"
:fallback="fallbackWeekdays"
:first-day="calendarStore.config.first_day"
:weekend="calendarStore.weekend"
/>
</div>
</div>
</div>
<template #footer>
<template v-if="dialogMode === 'create'">
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">Delete</button>
<button type="submit" class="ec-btn save-btn">Save</button>
</template>
<template v-else>
<template v-if="showDeleteVariants">
<div class="ec-delete-group">
<button type="button" class="ec-btn delete-btn" @click="deleteEventOne">
Delete <span>{{ formattedOccurrenceShort }}</span>
</button>
<button
v-if="!isLastOccurrence"
type="button"
class="ec-btn delete-btn"
@click="deleteEventFrom"
>
+ Rest
</button>
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button>
</div>
</template>
<template v-else-if="isRepeatingBaseEdit">
<div class="ec-delete-group">
<button type="button" class="ec-btn delete-btn" @click="deleteEventOne">
Delete {{ formattedOccurrenceShort }}
</button>
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button>
</div>
</template>
<template v-else>
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">Delete</button>
</template>
<button type="button" class="ec-btn close-btn" @click="closeDialog">Close</button>
</template>
</template>
</BaseDialog>
</template>
<style scoped>
.ec-field {
display: grid;
gap: 0.25rem;
}
.ec-field > span {
font-size: 0.85em;
color: var(--muted);
}
.ec-field input[type='text'],
.ec-field input[type='time'],
.ec-field input[type='number'],
.ec-field select {
border: 1px solid var(--muted);
border-radius: 0.4rem;
padding: 0.5rem 0.6rem;
width: 100%;
background: transparent;
color: var(--ink);
}
.ec-color-swatches {
display: grid;
grid-template-columns: repeat(8, 1fr);
}
.ec-color-swatches .swatch {
display: grid;
place-items: center;
border-radius: 0.4em;
padding: 0.25em;
outline: 0.125em solid transparent;
outline-offset: 0.125em;
cursor: pointer;
appearance: none;
width: 80%;
height: 1em;
}
.ec-color-swatches .swatch:checked {
outline-color: var(--ink);
}
.ec-footer {
display: flex;
justify-content: space-between;
gap: 0.75em;
}
.ec-btn {
background: transparent;
border: none;
color: var(--ink);
padding: 0.5em 0.8em;
border-radius: 0.4em;
cursor: pointer;
transition: all 0.2s ease;
}
.ec-btn:hover {
background: var(--muted);
}
.ec-btn.save-btn {
background: var(--today);
color: #000;
border-color: transparent;
font-weight: 500;
}
.ec-btn.save-btn:hover {
background: color-mix(in srgb, var(--today) 90%, black);
}
.ec-btn.close-btn {
background: var(--panel);
border-color: var(--muted);
font-weight: 500;
}
.ec-btn.close-btn:hover {
background: var(--muted);
}
.ec-btn.delete-btn {
background: hsl(0, 70%, 50%);
color: #fff;
border-color: transparent;
font-weight: 500;
}
.ec-btn.delete-btn:hover {
background: hsl(0, 70%, 45%);
}
.ec-weekday-selector {
display: grid;
gap: 0.5em;
}
.ec-field-label {
font-size: 0.85em;
color: var(--muted);
}
.ec-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.25em;
}
.ec-weekday-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
cursor: pointer;
padding: 0.5rem 0.25rem;
border-radius: 0.3rem;
transition: background-color 0.2s ease;
}
.ec-weekday-label:hover {
background: var(--muted);
}
.ec-weekday-checkbox {
margin: 0;
}
.ec-weekday-text {
font-size: 0.8em;
font-weight: 500;
text-align: center;
}
/* New recurrence block */
.recurrence-block {
display: grid;
gap: 0.6em;
}
.recurrence-header {
display: flex;
align-items: center;
gap: 0.75em;
}
.recurrence-header .recurrence-summary {
font-size: 0.75rem;
color: var(--ink);
opacity: 0.85;
}
.recurrence-header .recurrence-summary.muted {
opacity: 0.5;
}
.switch {
display: inline-flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
font-size: 0.85rem;
}
.switch input {
width: 1rem;
height: 1rem;
}
.recurrence-form {
display: grid;
gap: 0.6em;
padding: 0.6em 0.75em 0.75em;
border: 0.0625em solid var(--muted);
border-radius: 0.5em;
background: color-mix(in srgb, var(--muted) 15%, transparent);
}
.line.compact {
display: flex;
align-items: center;
gap: 0.5em;
flex-wrap: wrap;
font-size: 0.75em;
}
.freq-select {
padding: 0.4rem 0.55rem;
font-size: 0.75rem;
border: 1px solid var(--input-border);
background: var(--panel-alt);
color: var(--ink);
border-radius: 0.45rem;
transition:
border-color 0.18s ease,
background-color 0.18s ease;
}
.freq-select:focus {
outline: none;
border-color: var(--input-focus);
background: var(--panel-accent);
color: var(--ink);
box-shadow:
0 0 0 1px var(--input-focus),
0 0 0 4px rgba(37, 99, 235, 0.15);
}
.interval-input,
.occ-input {
display: none;
}
.ec-field input[type='text'] {
border: 1px solid var(--input-border);
background: var(--panel-alt);
border-radius: 0.45rem;
padding: 0.4rem 0.5rem;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
box-shadow 0.18s ease;
}
.ec-field input[type='text']:focus {
outline: none;
border-color: var(--input-focus);
background: var(--panel-accent);
box-shadow:
0 0 0 1px var(--input-focus),
0 0 0 4px rgba(37, 99, 235, 0.15);
}
.hint {
font-size: 0.65rem;
opacity: 0.65;
}
/* Recurrence UI */
.ec-recurrence-section {
display: grid;
gap: 0.4rem;
}
.ec-recurrence-toggle {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 0.6rem 0.8rem;
border: 1px solid var(--muted);
background: var(--panel);
border-radius: 0.4rem;
cursor: pointer;
font-size: 0.9rem;
text-align: start;
transition: background-color 0.15s ease;
}
.ec-recurrence-toggle:hover {
background: var(--muted);
}
.ec-recurrence-toggle .toggle-icon {
transition: transform 0.2s ease;
font-size: 0.7rem;
color: var(--muted);
}
.ec-recurrence-toggle .toggle-icon.open {
transform: rotate(180deg);
}
.ec-recurrence-panel {
display: grid;
gap: 0.6rem;
padding: 0.6rem;
border: 1px solid var(--muted);
border-radius: 0.4rem;
background: color-mix(in srgb, var(--muted) 20%, transparent);
}
/* Repeat modes */
.ec-repeat-modes {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.ec-repeat-modes .mode-btn {
flex: 1 1 auto;
padding: 0.4rem 0.6rem;
border: 1px solid var(--muted);
background: var(--panel);
border-radius: 0.4rem;
cursor: pointer;
font-size: 0.75rem;
line-height: 1.1;
white-space: nowrap;
transition:
background-color 0.15s ease,
color 0.15s ease,
border-color 0.15s ease;
}
.ec-repeat-modes .mode-btn.active {
background: var(--today);
color: #000;
border-color: var(--today);
font-weight: 600;
}
.ec-repeat-modes .mode-btn:hover {
background: var(--muted);
}
.ec-occurrences-field {
margin-top: 0.2rem;
}
.ec-occurrences-field .ec-field input[type='number'] {
max-width: 6rem;
}
span {
unicode-bidi: isolate;
}
</style>