import { computed, nextTick, onMounted, onUnmounted, ref, type Ref } from 'vue' import type { DataTableHeaders, TableColumnHeader } from './types' interface UsePinnedColumnsOptions { displayHeaders: Ref reactiveColumnWidths: Ref> pinnedColumns: Ref | undefined> pinnedColumnKey: Ref stickySelect: Ref showSelect: Ref showSelectSingle: Ref tableRef: Ref<{ $el?: Element } | undefined> } export function usePinnedColumns({ displayHeaders, reactiveColumnWidths, pinnedColumns, pinnedColumnKey, stickySelect, showSelect, showSelectSingle, tableRef, }: UsePinnedColumnsOptions) { const tableWrapperEl = ref(null) // Contenu masqué à gauche / à droite de la zone qui défile horizontalement const canScrollLeft = ref(false) const canScrollRight = ref(false) // Position verticale de la zone qui défile dans `.v-table` (hors en-tête de tableau et pied de page) const scrollArea = ref<{ top: number, height: number } | null>(null) // Stocker la référence de la fonction pour éviter les fuites mémoire const updatePinnedShadowsRef = ref<(() => void) | null>(null) const parseWidthPx = (val: unknown): number => { if (typeof val === 'number' && Number.isFinite(val)) return val if (typeof val === 'string') { const trimmed = val.trim() if (trimmed.endsWith('px')) { const n = Number.parseFloat(trimmed.slice(0, -2)) return Number.isFinite(n) ? n : 0 } const n = Number.parseFloat(trimmed) return Number.isFinite(n) ? n : 0 } return 0 } const normalizedPinnedColumns = computed(() => { const raw = pinnedColumns.value ?? (pinnedColumnKey.value ? [pinnedColumnKey.value] : []) return raw.map((c) => { if (typeof c === 'string') return { key: c, side: 'left' as const } return { key: c.key, side: c.side ?? 'left' as const } }) }) const pinnedLeftKeys = computed(() => normalizedPinnedColumns.value.filter(c => c.side !== 'right').map(c => c.key), ) const pinnedRightKeys = computed(() => normalizedPinnedColumns.value.filter(c => c.side === 'right').map(c => c.key), ) const stickySelectActive = computed(() => Boolean(stickySelect.value) && (showSelect.value || showSelectSingle.value)) const hasPinnedSelectLeft = computed(() => pinnedLeftKeys.value.includes('data-table-select') || stickySelectActive.value) const getColumnWidthPx = (headersList: TableColumnHeader[], key: string): number => { if (key === 'data-table-select' || key === 'data-table-expand' || key === 'data-table-group') return 48 const storedWidth = reactiveColumnWidths.value[key] if (storedWidth != null) return parseWidthPx(storedWidth) const h = headersList.find(x => (x.key ?? x.value) === key) return parseWidthPx(h?.width ?? h?.minWidth ?? h?.maxWidth) } const pinnedMeta = computed(() => { const headersList = displayHeaders.value if (!headersList) return { left: {} as Record, right: {} as Record, totalLeft: 0, totalRight: 0 } const left: Record = {} let accLeft = 0 if (hasPinnedSelectLeft.value) { left['data-table-select'] = 0 accLeft = getColumnWidthPx(headersList as TableColumnHeader[], 'data-table-select') } for (const h of headersList) { const key = (h.key ?? h.value) as string | undefined if (!key) continue if (pinnedLeftKeys.value.includes(key)) { left[key] = accLeft accLeft += getColumnWidthPx(headersList as TableColumnHeader[], key) } } const right: Record = {} let accRight = 0 for (const h of [...headersList].reverse()) { const key = (h.key ?? h.value) as string | undefined if (!key) continue if (pinnedRightKeys.value.includes(key)) { right[key] = accRight accRight += getColumnWidthPx(headersList as TableColumnHeader[], key) } } return { left, right, totalLeft: accLeft, totalRight: accRight } }) const updatePinnedShadows = () => { const el = tableWrapperEl.value if (!el) return const max = el.scrollWidth - el.clientWidth canScrollLeft.value = el.scrollLeft > 0 canScrollRight.value = max > 0 && el.scrollLeft < max - 1 scrollArea.value = { top: el.offsetTop, height: el.clientHeight } } // Les colonnes épinglées d'un côté signalent elles-mêmes le contenu masqué de ce côté (ombre de la colonne). // Sans colonne épinglée, un indicateur au bord de la zone qui défile prend le relais. const showPinnedLeftShadow = computed(() => pinnedMeta.value.totalLeft > 0 && canScrollLeft.value) const showPinnedRightShadow = computed(() => pinnedMeta.value.totalRight > 0 && canScrollRight.value) const showScrollLeftIndicator = computed(() => pinnedMeta.value.totalLeft === 0 && canScrollLeft.value) const showScrollRightIndicator = computed(() => pinnedMeta.value.totalRight === 0 && canScrollRight.value) const scrollAreaVars = computed((): Record => { if (!scrollArea.value) return {} return { '--sy-table-scroll-area-top': `${scrollArea.value.top}px`, '--sy-table-scroll-area-height': `${scrollArea.value.height}px`, } }) // Fonction pour trouver le wrapper du tableau avec retry const findTableWrapper = (): HTMLElement | null => { const el = tableRef.value?.$el as HTMLElement | undefined return el?.querySelector('.v-table__wrapper') as HTMLElement | null } // Initialiser la référence de la fonction updatePinnedShadowsRef.value = updatePinnedShadows // Une mise à jour par frame au plus, toujours exécutée après le dernier événement : // l'état reflète la position finale du défilement. let pendingFrame = 0 const updatePinnedShadowsThrottled = () => { if (pendingFrame) return pendingFrame = requestAnimationFrame(() => { pendingFrame = 0 updatePinnedShadows() }) } // Recalcule quand la zone ou son contenu change de taille (chargement des données, colonnes redimensionnées) let resizeObserver: ResizeObserver | null = null const pinnedEdgeVars = computed>(() => { const { totalLeft, totalRight } = pinnedMeta.value return { ...(totalLeft > 0 ? { '--sy-pinned-left-edge': `${totalLeft}px` } : {}), ...(totalRight > 0 ? { '--sy-pinned-right-edge': `${totalRight}px` } : {}), } }) // Only cellProps are needed here — headerProps are applied inline in the #headers slot template const displayHeadersWithPinned = computed(() => { const headersList = displayHeaders.value if (!headersList) return headersList const leftOffsets = pinnedMeta.value.left const rightOffsets = pinnedMeta.value.right if (Object.keys(leftOffsets).length === 0 && Object.keys(rightOffsets).length === 0) return headersList return headersList.map((h) => { const key = (h.key ?? h.value) as string | undefined if (!key) return h const left = leftOffsets[key] const right = rightOffsets[key] if (left === undefined && right === undefined) return h const cellProps = (h.cellProps ?? {}) as Record const cellStyle = (cellProps.style ?? {}) as Record const sideClass = left !== undefined ? 'sy-table__pinned--left' : 'sy-table__pinned--right' const offset = left ?? right ?? 0 return { ...h, cellProps: { ...cellProps, class: [ 'sy-table__pinned', sideClass, 'v-data-table-column--fixed', cellProps.class, ].filter(Boolean), style: { ...cellStyle, '--sy-pinned-offset': `${offset}px`, }, }, } }) }) onMounted(() => { nextTick(() => { let attempts = 0 const maxAttempts = 5 const tryFindWrapper = () => { tableWrapperEl.value = findTableWrapper() if (tableWrapperEl.value || attempts >= maxAttempts) { if (tableWrapperEl.value && updatePinnedShadowsRef.value) { updatePinnedShadowsRef.value() tableWrapperEl.value.addEventListener('scroll', updatePinnedShadowsThrottled, { passive: true }) if (typeof ResizeObserver !== 'undefined') { resizeObserver = new ResizeObserver(updatePinnedShadowsThrottled) resizeObserver.observe(tableWrapperEl.value) const tableEl = tableWrapperEl.value.querySelector('table') if (tableEl) resizeObserver.observe(tableEl) } } if (updatePinnedShadowsRef.value) { window.addEventListener('resize', updatePinnedShadowsThrottled) } } else { attempts++ setTimeout(tryFindWrapper, 50) } } tryFindWrapper() }) }) onUnmounted(() => { if (tableWrapperEl.value && updatePinnedShadowsRef.value) { tableWrapperEl.value.removeEventListener('scroll', updatePinnedShadowsThrottled) } if (updatePinnedShadowsRef.value) { window.removeEventListener('resize', updatePinnedShadowsThrottled) } resizeObserver?.disconnect() resizeObserver = null if (pendingFrame) { cancelAnimationFrame(pendingFrame) pendingFrame = 0 } updatePinnedShadowsRef.value = null }) return { showPinnedLeftShadow, showPinnedRightShadow, showScrollLeftIndicator, showScrollRightIndicator, hasPinnedSelectLeft, pinnedMeta, pinnedEdgeVars, scrollAreaVars, displayHeadersWithPinned, } }