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.
286 lines
8.1 KiB
Vue
286 lines
8.1 KiB
Vue
<template>
|
|
<div class="weekgrid" @pointerleave="dragging = false">
|
|
<button
|
|
v-for="(d, di) in displayLabels"
|
|
:key="d + di"
|
|
type="button"
|
|
class="day"
|
|
:class="{
|
|
on: anySelected && displayDisplayValues[di],
|
|
// Show fallback styling on the reordered fallback day when none selected
|
|
fallback: !anySelected && displayDefault[di],
|
|
pressing: isPressing(di),
|
|
preview: previewActive && inPreviewRange(di),
|
|
}"
|
|
@pointerdown="onPointerDown(di)"
|
|
@pointerenter="onDragOver(di)"
|
|
@pointerup="onPointerUp"
|
|
>
|
|
{{ d }}
|
|
</button>
|
|
<button
|
|
v-for="g in barGroups"
|
|
:key="g.start"
|
|
type="button"
|
|
tabindex="-1"
|
|
class="workday-weekend"
|
|
:style="{ gridColumn: 'span ' + g.span }"
|
|
@click.stop="toggleWeekend(g.type)"
|
|
>
|
|
<div :class="{ workday: !g.type, weekend: g.type }"></div>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, ref, watch } from 'vue'
|
|
import {
|
|
getLocalizedWeekdayNames,
|
|
getLocaleFirstDay,
|
|
getLocaleWeekendDays,
|
|
reorderByFirstDay,
|
|
} from '@/utils/date'
|
|
|
|
const model = defineModel({
|
|
type: Array,
|
|
default: () => [false, false, false, false, false, false, false],
|
|
}) // external value consumers see
|
|
|
|
// Internal state preserves the user's explicit picks even if all false
|
|
const internal = ref([false, false, false, false, false, false, false])
|
|
|
|
const props = defineProps({
|
|
weekend: { type: Array, default: undefined },
|
|
fallback: {
|
|
type: Array,
|
|
default: () => [false, false, false, false, false, false, false],
|
|
},
|
|
firstDay: { type: Number, default: null },
|
|
})
|
|
|
|
// Initialize internal from external if it has any true; else keep empty (fallback handled on emit)
|
|
if (model.value?.some?.(Boolean)) internal.value = [...model.value]
|
|
// getLocalizedWeekdayNames now returns Sunday-first already
|
|
const labels = getLocalizedWeekdayNames()
|
|
const anySelected = computed(() => internal.value.some(Boolean))
|
|
const localeFirst = getLocaleFirstDay()
|
|
const localeWeekend = getLocaleWeekendDays()
|
|
const firstDay = computed(() => (props.firstDay ?? localeFirst) % 7)
|
|
|
|
const weekendDays = computed(() => {
|
|
if (props.weekend && props.weekend.length === 7) return props.weekend
|
|
return localeWeekend
|
|
})
|
|
|
|
const displayLabels = computed(() => reorderByFirstDay(labels, firstDay.value))
|
|
const displayValuesCommitted = computed(() => reorderByFirstDay(internal.value, firstDay.value))
|
|
const displayWorking = computed(() => reorderByFirstDay(weekendDays.value, firstDay.value))
|
|
const displayDefault = computed(() => reorderByFirstDay(props.fallback, firstDay.value))
|
|
|
|
// Expose a normalized pattern (Sunday-first) that substitutes the fallback day if none selected.
|
|
// This keeps UI visually showing fallback (muted) but downstream logic can opt-in by reading this.
|
|
function computeFallbackPattern() {
|
|
const fb = props.fallback && props.fallback.length === 7 ? props.fallback : null
|
|
if (fb && fb.some(Boolean)) return [...fb]
|
|
const arr = [false, false, false, false, false, false, false]
|
|
const idx = fb ? fb.findIndex(Boolean) : -1
|
|
if (idx >= 0) arr[idx] = true
|
|
else arr[0] = true
|
|
return arr
|
|
}
|
|
function emitExternal() {
|
|
model.value = internal.value.some(Boolean) ? [...internal.value] : computeFallbackPattern()
|
|
}
|
|
emitExternal()
|
|
watch(
|
|
() => model.value,
|
|
(nv) => {
|
|
if (!nv) return
|
|
if (!nv.some(Boolean)) return
|
|
const fb = computeFallbackPattern()
|
|
const isFallback = fb.every((v, i) => v === nv[i])
|
|
// If internal is empty and model only reflects fallback, do not sync into internal
|
|
if (isFallback && !internal.value.some(Boolean)) return
|
|
internal.value = [...nv]
|
|
},
|
|
)
|
|
|
|
// Mapping from display index to original model index
|
|
const orderIndices = computed(() => Array.from({ length: 7 }, (_, i) => (i + firstDay.value) % 7))
|
|
|
|
const barGroups = computed(() => {
|
|
const arr = displayWorking.value
|
|
const groups = []
|
|
let type = arr[0]
|
|
let start = 0
|
|
for (let i = 1; i <= arr.length; i++) {
|
|
if (i === arr.length || arr[i] !== type) {
|
|
groups.push({ type, start, span: i - start })
|
|
if (i < arr.length) {
|
|
type = arr[i]
|
|
start = i
|
|
}
|
|
}
|
|
}
|
|
return groups
|
|
})
|
|
|
|
const dragging = ref(false)
|
|
const previewActive = ref(false)
|
|
const dragVal = ref(false)
|
|
const dragStart = ref(null)
|
|
const previewEnd = ref(null)
|
|
let originalValues = null
|
|
|
|
// Preview (drag) values; when none selected, still return committed (not fallback) so 'on' class
|
|
// is suppressed and only fallback styling applies via displayDefault
|
|
const displayPreviewValues = computed(() => {
|
|
if (
|
|
!dragging.value ||
|
|
!previewActive.value ||
|
|
dragStart.value == null ||
|
|
previewEnd.value == null ||
|
|
!originalValues
|
|
) {
|
|
return displayValuesCommitted.value
|
|
}
|
|
const [s, e] =
|
|
dragStart.value < previewEnd.value
|
|
? [dragStart.value, previewEnd.value]
|
|
: [previewEnd.value, dragStart.value]
|
|
return displayValuesCommitted.value.map((v, di) => (di >= s && di <= e ? dragVal.value : v))
|
|
})
|
|
const displayDisplayValues = displayPreviewValues
|
|
|
|
function inPreviewRange(di) {
|
|
if (!previewActive.value || dragStart.value == null || previewEnd.value == null) return false
|
|
const [s, e] =
|
|
dragStart.value < previewEnd.value
|
|
? [dragStart.value, previewEnd.value]
|
|
: [previewEnd.value, dragStart.value]
|
|
return di >= s && di <= e
|
|
}
|
|
function isPressing(di) {
|
|
return dragging.value && !previewActive.value && dragStart.value === di
|
|
}
|
|
|
|
function onPointerDown(di) {
|
|
originalValues = [...internal.value]
|
|
dragVal.value = !internal.value[(di + firstDay.value) % 7]
|
|
dragStart.value = di
|
|
previewEnd.value = di
|
|
dragging.value = true
|
|
previewActive.value = false
|
|
window.addEventListener('pointerup', onPointerUp, { once: true })
|
|
}
|
|
function onDragOver(di) {
|
|
if (!dragging.value) return
|
|
if (previewEnd.value === di) return
|
|
if (!previewActive.value && di !== dragStart.value) previewActive.value = true
|
|
previewEnd.value = di
|
|
}
|
|
function onPointerUp() {
|
|
if (!dragging.value) return
|
|
if (!previewActive.value) {
|
|
// simple click: toggle single
|
|
const next = [...originalValues]
|
|
next[(dragStart.value + firstDay.value) % 7] = dragVal.value
|
|
internal.value = next
|
|
emitExternal()
|
|
cleanupDrag()
|
|
} else {
|
|
commitDrag()
|
|
}
|
|
}
|
|
function commitDrag() {
|
|
if (dragStart.value == null || previewEnd.value == null || !originalValues) return cancelDrag()
|
|
const [s, e] =
|
|
dragStart.value < previewEnd.value
|
|
? [dragStart.value, previewEnd.value]
|
|
: [previewEnd.value, dragStart.value]
|
|
const next = [...originalValues]
|
|
for (let di = s; di <= e; di++) next[orderIndices.value[di]] = dragVal.value
|
|
internal.value = next
|
|
emitExternal()
|
|
cleanupDrag()
|
|
}
|
|
function cancelDrag() {
|
|
cleanupDrag()
|
|
}
|
|
function cleanupDrag() {
|
|
dragging.value = false
|
|
previewActive.value = false
|
|
dragStart.value = null
|
|
previewEnd.value = null
|
|
originalValues = null
|
|
}
|
|
function toggleWeekend(work) {
|
|
const base = weekendDays.value
|
|
const target = work ? base : base.map((v) => !v)
|
|
const current = internal.value
|
|
const allOn = current.every(Boolean)
|
|
const isTargetActive = current.every((v, i) => v === target[i])
|
|
if (allOn || isTargetActive) {
|
|
internal.value = [false, false, false, false, false, false, false]
|
|
} else {
|
|
internal.value = [...target]
|
|
}
|
|
emitExternal()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.weekgrid {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, 1fr);
|
|
grid-auto-rows: auto;
|
|
}
|
|
.workday-weekend {
|
|
height: 1em;
|
|
border: 0;
|
|
background: none;
|
|
padding: 0;
|
|
font: inherit;
|
|
cursor: pointer;
|
|
}
|
|
.workday-weekend div {
|
|
height: 0.3em;
|
|
border-radius: 0.15em;
|
|
margin: 0.1em;
|
|
}
|
|
.workday {
|
|
background: var(--workday, #888);
|
|
}
|
|
.weekend {
|
|
background: var(--weekend, #f88);
|
|
}
|
|
.day {
|
|
flex: 1;
|
|
cursor: pointer;
|
|
background: var(--panel-alt);
|
|
color: var(--ink);
|
|
font-size: 0.65rem;
|
|
font-weight: 500;
|
|
padding: 0.55rem 0.35rem;
|
|
border: none;
|
|
margin: 0 1px;
|
|
border-radius: 0.4rem;
|
|
user-select: none;
|
|
}
|
|
.day.on {
|
|
background: var(--pill-active-bg);
|
|
color: var(--pill-active-ink);
|
|
font-weight: 600;
|
|
}
|
|
.day.pressing {
|
|
filter: brightness(1.15);
|
|
}
|
|
.day.preview {
|
|
filter: brightness(1.15);
|
|
}
|
|
.day.fallback {
|
|
background: var(--muted-alt);
|
|
opacity: 0.65;
|
|
}
|
|
</style>
|