fix: time slot dropdown group headers and dimming via boundary detection
VAutocomplete ignores interleaved fake header items — they were filtered out before reaching the template. Replace with Approach A: keep only real selectable items sorted by group, detect group boundaries in the #item template by comparing adjacent groupName values, and render VListSubheader before each new group. - Remove _isGroupHeader from TimeSlotDropdownItem interface - Rename groupTimeSlots → sortedItems (returns only selectable items) - Add hasGroups computed for conditional header rendering - Add isNewGroup(index) boundary detection in CreateShiftDialog - Add scoped .time-slot-dimmed CSS class (opacity: 0.65) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
apps/app/auto-imports.d.ts
vendored
2
apps/app/auto-imports.d.ts
vendored
@@ -323,6 +323,7 @@ declare global {
|
||||
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
|
||||
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
|
||||
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
|
||||
const useTimeSlotDropdown: typeof import('./src/composables/useTimeSlotDropdown')['useTimeSlotDropdown']
|
||||
const useTimeout: typeof import('@vueuse/core')['useTimeout']
|
||||
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
|
||||
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
|
||||
@@ -681,6 +682,7 @@ declare module 'vue' {
|
||||
readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>
|
||||
readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>
|
||||
readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']>
|
||||
readonly useTimeSlotDropdown: UnwrapRef<typeof import('./src/composables/useTimeSlotDropdown')['useTimeSlotDropdown']>
|
||||
readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>
|
||||
readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
|
||||
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>
|
||||
|
||||
@@ -36,7 +36,7 @@ const { data: eventDetail } = useEventDetail(orgIdRef, eventIdRef)
|
||||
const sectionRef = computed(() => props.section ?? null)
|
||||
|
||||
// Determine dropdown scenario
|
||||
const { scenario, showInfoTooltip, tooltipText, fetchParams, groupTimeSlots } = useTimeSlotDropdown(
|
||||
const { scenario, showInfoTooltip, hasGroups, tooltipText, fetchParams, sortedItems } = useTimeSlotDropdown(
|
||||
eventDetail,
|
||||
sectionRef,
|
||||
)
|
||||
@@ -95,8 +95,8 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Group time slots for the dropdown
|
||||
const flattenedTimeSlots = computed(() => {
|
||||
// Build sorted dropdown items (no fake header items — groups are detected by boundary)
|
||||
const dropdownItems = computed(() => {
|
||||
// While loading, show the current shift's time slot so the dropdown doesn't flash a raw ULID
|
||||
if (!timeSlots.value?.length) {
|
||||
if (props.shift?.time_slot) {
|
||||
@@ -106,7 +106,6 @@ const flattenedTimeSlots = computed(() => {
|
||||
name: ts.name,
|
||||
timeRange: `${ts.start_time} – ${ts.end_time}`,
|
||||
displayLabel: ts.name,
|
||||
_isGroupHeader: false,
|
||||
_isDimmed: false,
|
||||
groupName: '',
|
||||
}]
|
||||
@@ -114,10 +113,16 @@ const flattenedTimeSlots = computed(() => {
|
||||
return []
|
||||
}
|
||||
|
||||
return groupTimeSlots(timeSlots.value)
|
||||
return sortedItems(timeSlots.value)
|
||||
})
|
||||
|
||||
const hasTimeSlots = computed(() => flattenedTimeSlots.value.some(i => !i._isGroupHeader))
|
||||
const hasTimeSlots = computed(() => dropdownItems.value.length > 0)
|
||||
|
||||
// Detect group boundaries: true when the current item starts a new group
|
||||
function isNewGroup(index: number): boolean {
|
||||
if (index === 0) return true
|
||||
return dropdownItems.value[index]?.groupName !== dropdownItems.value[index - 1]?.groupName
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ title: 'Concept', value: 'draft' },
|
||||
@@ -231,7 +236,7 @@ function onSubmit() {
|
||||
<VAutocomplete
|
||||
v-if="timeSlotsLoading || hasTimeSlots"
|
||||
v-model="form.time_slot_id"
|
||||
:items="flattenedTimeSlots.filter(i => !i._isGroupHeader)"
|
||||
:items="dropdownItems"
|
||||
item-title="displayLabel"
|
||||
item-value="id"
|
||||
label="Tijdslot"
|
||||
@@ -251,12 +256,20 @@ function onSubmit() {
|
||||
</InfoTooltip>
|
||||
</template>
|
||||
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<template #item="{ props: itemProps, item, index }">
|
||||
<!-- Group header: rendered when event_name changes -->
|
||||
<VListSubheader
|
||||
v-if="hasGroups && isNewGroup(index)"
|
||||
:title="item.raw.groupName"
|
||||
/>
|
||||
|
||||
<VListItem
|
||||
v-if="!item.raw._isGroupHeader"
|
||||
v-bind="itemProps"
|
||||
:class="{ 'opacity-65': item.raw._isDimmed }"
|
||||
:class="{ 'time-slot-dimmed': item.raw._isDimmed }"
|
||||
>
|
||||
<template #title>
|
||||
{{ item.raw.name }}
|
||||
</template>
|
||||
<template #append>
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
{{ item.raw.timeRange }}
|
||||
@@ -412,3 +425,10 @@ function onSubmit() {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Dimmed festival-level time slots in sub-event dropdown (scenario A/D) */
|
||||
:deep(.time-slot-dimmed) {
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,12 +5,11 @@ import type { TimeSlot } from '@/types/timeSlot'
|
||||
|
||||
export type DropdownScenario = 'flat' | 'sub_event_standard' | 'cross_event' | 'festival_standard'
|
||||
|
||||
interface TimeSlotDropdownItem {
|
||||
export interface TimeSlotDropdownItem {
|
||||
id: string
|
||||
name: string
|
||||
timeRange: string
|
||||
displayLabel: string
|
||||
_isGroupHeader: boolean
|
||||
_isDimmed: boolean
|
||||
groupName: string
|
||||
}
|
||||
@@ -46,6 +45,8 @@ export function useTimeSlotDropdown(
|
||||
|
||||
const showInfoTooltip = computed(() => scenario.value !== 'flat')
|
||||
|
||||
const hasGroups = computed(() => scenario.value !== 'flat')
|
||||
|
||||
const tooltipText = computed<TooltipContent | null>(() => {
|
||||
const eventName = event.value?.name ?? ''
|
||||
const sectionName = section.value?.name ?? ''
|
||||
@@ -86,52 +87,41 @@ export function useTimeSlotDropdown(
|
||||
}
|
||||
})
|
||||
|
||||
function groupTimeSlots(timeSlots: TimeSlot[]): TimeSlotDropdownItem[] {
|
||||
/**
|
||||
* Returns a flat array of selectable items sorted by group
|
||||
* (own event first, then others). Group boundaries are detected
|
||||
* in the template by comparing adjacent items' groupName.
|
||||
*/
|
||||
function sortedItems(timeSlots: TimeSlot[]): TimeSlotDropdownItem[] {
|
||||
if (scenario.value === 'flat') {
|
||||
return timeSlots.map(ts => toDropdownItem(ts, false))
|
||||
return timeSlots.map(ts => toDropdownItem(ts, false, ''))
|
||||
}
|
||||
|
||||
// Group by event_name
|
||||
// Classify each slot into a group and determine isOwn per group
|
||||
const groups = new Map<string, { slots: TimeSlot[]; isOwn: boolean }>()
|
||||
for (const ts of timeSlots) {
|
||||
const key = ts.event_name ?? 'Onbekend'
|
||||
if (!groups.has(key)) {
|
||||
const isOwn = ts.source === 'own' || ts.source === 'sub_event' || ts.source === 'festival'
|
||||
? (scenario.value === 'sub_event_standard'
|
||||
? ts.source === 'sub_event'
|
||||
: ts.source === 'own')
|
||||
: false
|
||||
const isOwn = scenario.value === 'sub_event_standard'
|
||||
? ts.source === 'sub_event'
|
||||
: ts.source === 'own'
|
||||
groups.set(key, { slots: [], isOwn })
|
||||
}
|
||||
groups.get(key)!.slots.push(ts)
|
||||
}
|
||||
|
||||
const items: TimeSlotDropdownItem[] = []
|
||||
|
||||
// Own group first, then others
|
||||
const sortedGroups = [...groups.entries()].sort(([, a], [, b]) => {
|
||||
// Own group first, then others alphabetically
|
||||
const sorted = [...groups.entries()].sort(([nameA, a], [nameB, b]) => {
|
||||
if (a.isOwn && !b.isOwn) return -1
|
||||
if (!a.isOwn && b.isOwn) return 1
|
||||
return 0
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
|
||||
for (const [groupName, { slots, isOwn }] of sortedGroups) {
|
||||
// Add group header
|
||||
items.push({
|
||||
id: `header-${groupName}`,
|
||||
name: groupName,
|
||||
timeRange: '',
|
||||
displayLabel: groupName,
|
||||
_isGroupHeader: true,
|
||||
_isDimmed: false,
|
||||
groupName,
|
||||
})
|
||||
|
||||
// Determine if slots should be dimmed
|
||||
const items: TimeSlotDropdownItem[] = []
|
||||
for (const [groupName, { slots, isOwn }] of sorted) {
|
||||
const isDimmed = scenario.value === 'sub_event_standard' && !isOwn
|
||||
|
||||
for (const ts of slots) {
|
||||
items.push(toDropdownItem(ts, isDimmed))
|
||||
items.push(toDropdownItem(ts, isDimmed, groupName))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,21 +131,21 @@ export function useTimeSlotDropdown(
|
||||
return {
|
||||
scenario,
|
||||
showInfoTooltip,
|
||||
hasGroups,
|
||||
tooltipText,
|
||||
fetchParams,
|
||||
groupTimeSlots,
|
||||
sortedItems,
|
||||
}
|
||||
}
|
||||
|
||||
function toDropdownItem(ts: TimeSlot, isDimmed: boolean): TimeSlotDropdownItem {
|
||||
function toDropdownItem(ts: TimeSlot, isDimmed: boolean, groupName: string): TimeSlotDropdownItem {
|
||||
const timeRange = `${ts.start_time} – ${ts.end_time}`
|
||||
return {
|
||||
id: ts.id,
|
||||
name: ts.name,
|
||||
timeRange,
|
||||
displayLabel: ts.name,
|
||||
_isGroupHeader: false,
|
||||
_isDimmed: isDimmed,
|
||||
groupName: ts.event_name ?? '',
|
||||
groupName,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user