253 lines
6.9 KiB
Vue
253 lines
6.9 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.slice(0, 3) }}
|
|
</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 } from 'vue'
|
|
import {
|
|
getLocalizedWeekdayNames,
|
|
getLocaleFirstDay,
|
|
getLocaleWeekendDays,
|
|
reorderByFirstDay,
|
|
} from '@/utils/date'
|
|
|
|
const model = defineModel({
|
|
type: Array,
|
|
default: () => [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 },
|
|
})
|
|
|
|
// If external model provided is entirely false, keep as-is (user will see fallback styling),
|
|
// only overwrite if null/undefined.
|
|
if (!model.value) model.value = [...props.fallback]
|
|
const labelsMondayFirst = getLocalizedWeekdayNames()
|
|
const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)]
|
|
const anySelected = computed(() => model.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(model.value, firstDay.value))
|
|
const displayWorking = computed(() => reorderByFirstDay(weekendDays.value, firstDay.value))
|
|
const displayDefault = computed(() => reorderByFirstDay(props.fallback, firstDay.value))
|
|
|
|
// 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 = [...model.value]
|
|
dragVal.value = !model.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
|
|
model.value = next
|
|
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
|
|
model.value = next
|
|
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 = model.value
|
|
const allOn = current.every(Boolean)
|
|
const isTargetActive = current.every((v, i) => v === target[i])
|
|
if (allOn || isTargetActive) {
|
|
model.value = [false, false, false, false, false, false, false]
|
|
} else {
|
|
model.value = [...target]
|
|
}
|
|
}
|
|
</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>
|