From d0fe65a1d6d0475fc0ae3cec89be72c7c1ef7769 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 24 Aug 2026 16:55:25 +0200 Subject: [PATCH] fix: simplify header reordering --- .../_components/header-composer.module.css | 110 ++++++--- .../general/_components/header-composer.tsx | 211 ++++++++++-------- 2 files changed, 203 insertions(+), 118 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.module.css b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.module.css index 8a1244039..db364ed32 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.module.css +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.module.css @@ -67,57 +67,107 @@ .sortableStrip { display: flex; align-items: stretch; - gap: var(--mantine-spacing-sm); - width: 100%; + gap: 0; + width: max-content; min-width: 100%; height: 100%; padding: 0.35rem var(--mantine-spacing-sm) 0.45rem; white-space: nowrap; } +.zoneSection { + display: flex; + align-items: flex-end; + height: 100%; + flex: 0 0 auto; +} + .zoneDelimiter { display: grid; - grid-template-rows: 1.25rem 2.5rem; - gap: 0.3rem; - min-width: 5rem; + grid-template-rows: 1.2rem 1.75rem; + place-items: center; + gap: 0.2rem; + width: 3.5rem; height: 100%; - flex: 1 1 7rem; align-content: center; - padding-inline: 0.25rem; + padding-block: 0.25rem 0.4rem; + flex: 0 0 auto; user-select: none; } -.zoneDelimiter :global(.mantine-Divider-label) { - padding-inline: 0.25rem; +.zoneLabel { color: var(--mantine-color-dimmed); font-size: 0.55rem; font-weight: 700; + line-height: 1; letter-spacing: 0.06em; text-transform: uppercase; } -.zoneBoundary { - display: flex; - align-items: center; - justify-content: center; - gap: var(--mantine-spacing-xs); - height: 2.5rem; +.zoneLine { + height: 1.5rem; + border-color: var(--mantine-color-default-border); +} + +.dropSlot { + position: relative; + display: grid; + place-items: center; + width: 1.25rem; + min-width: 1.25rem; + height: 3rem; + margin-bottom: 0.125rem; + flex: 0 0 auto; + outline: none; +} + +.dropMarker { + display: block; + width: 2px; + height: 1.4rem; + border-radius: var(--mantine-radius-xl); + background: transparent; + transition: + width 120ms ease, + height 120ms ease, + background-color 120ms ease, + box-shadow 120ms ease; +} + +.sortableStrip[data-drag-active="true"] .dropMarker { + background: color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent); +} + +.dropZoneLabel { + position: absolute; + inset-block-start: -0.9rem; + inset-inline-start: 50%; + z-index: 2; + display: none; + padding: 0.15rem 0.3rem; + color: var(--mantine-primary-color-filled); + border: 1px solid var(--mantine-primary-color-filled); + border-radius: var(--mantine-radius-sm); + background: var(--mantine-color-body); + box-shadow: var(--mantine-shadow-xs); + font-size: 0.5rem; + font-weight: 700; + line-height: 1; + letter-spacing: 0.04em; + text-transform: uppercase; + transform: translateX(-50%); pointer-events: none; } -.zoneBoundary > span { - width: 2px; - height: 1.25rem; - border-radius: var(--mantine-radius-xl); - background: var(--mantine-color-default-border); - transition: - height 120ms ease, - background-color 120ms ease; +.dropSlot[data-drop-target="true"] .dropMarker { + width: 3px; + height: 2.35rem; + background: var(--mantine-primary-color-filled); + box-shadow: 0 0 0 3px var(--mantine-primary-color-light); } -.zoneDelimiter[data-drop-target="true"] .zoneBoundary > span { - height: 1.5rem; - background: var(--mantine-primary-color-filled); +.dropSlot[data-drop-target="true"] .dropZoneLabel { + display: block; } .sortableItem { @@ -163,7 +213,6 @@ .itemButton:hover, .itemButton:focus-visible, -.sortableItem[data-drop-target="true"] .itemButton, .sortableItem[data-selected="true"] .itemButton { border-color: var(--mantine-primary-color-filled); background: var(--mantine-primary-color-light-hover); @@ -208,6 +257,8 @@ height: 2.25rem; gap: 3px; place-content: center; + align-self: flex-end; + margin: 0 0.125rem 0.125rem 0; flex: 0 0 auto; } @@ -247,11 +298,10 @@ } .stage[data-size="mobile"] .sortableStrip { - gap: var(--mantine-spacing-xs); padding-inline: var(--mantine-spacing-xs); } -.stage[data-size="mobile"] .zoneDelimiter :global(.mantine-Divider-label) { +.stage[data-size="mobile"] .zoneLabel { font-size: 0.5rem; } @@ -281,7 +331,7 @@ @media (prefers-reduced-motion: reduce) { .device, - .zoneBoundary > span, + .dropMarker, .itemButton { transition: none; } diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx index 7912e417b..95fcee824 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx @@ -1,11 +1,12 @@ "use client"; -import { useMemo, useState } from "react"; +import { Fragment, useMemo, useState } from "react"; import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"; +import { pointerIntersection } from "@dnd-kit/collision"; import { PointerActivationConstraints } from "@dnd-kit/dom"; import type { DragEndEvent } from "@dnd-kit/react"; -import { DragDropProvider, DragOverlay, KeyboardSensor, PointerSensor } from "@dnd-kit/react"; -import { isSortable, useSortable } from "@dnd-kit/react/sortable"; +import { DragDropProvider, DragOverlay, KeyboardSensor, PointerSensor, useDroppable } from "@dnd-kit/react"; +import { useSortable } from "@dnd-kit/react/sortable"; import { ActionIcon, Avatar, @@ -74,9 +75,16 @@ interface HeaderComposerProps { type PreviewSize = "desktop" | "mobile"; -type HeaderStripEntry = - | { id: string; kind: "divider"; zone: HeaderZoneId } - | { id: string; item: HeaderItem; kind: "item" }; +interface HeaderItemDragData { + kind: "item"; + itemKey: string; +} + +interface HeaderDropSlotData { + kind: "slot"; + zone: HeaderZoneId; + index: number; +} const headerSortableSensors = [ PointerSensor.configure({ @@ -86,6 +94,8 @@ const headerSortableSensors = [ ]; const headerSortableModifiers = [RestrictToHorizontalAxis]; const headerSortableGroup = "header-composer"; +const headerSortableType = "header-composer-item"; +const headerDropSlotType = "header-composer-slot"; export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHomeBoardId }: HeaderComposerProps) => { const [previewSize, setPreviewSize] = useState("desktop"); @@ -94,7 +104,7 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom const t = useI18n("management.page.user.setting.general.header"); const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]); const activeItems = getHeaderItems(value.zones); - const stripEntries = createHeaderStripEntries(value.zones); + const sortableIndices = new Map(activeItems.map((item, index) => [getHeaderItemKey(item), index])); const activeItemKeySet = new Set(activeItems.map(getHeaderItemKey)); const inactiveBuiltinIds = headerBuiltinItemIds.filter((itemId) => { const item = createBuiltinHeaderItem(itemId); @@ -139,11 +149,14 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom const handleDragEnd = ({ canceled, operation }: DragEndEvent) => { setDraggedItemKey(null); const source = operation.source; - if (canceled || !isSortable(source)) return; - if (source.initialIndex === source.index) return; + const target = operation.target; + if (canceled || !source || !target) return; - const nextEntries = moveHeaderStripEntry(stripEntries, source.initialIndex, source.index); - updateZones(createHeaderZonesFromStripEntries(nextEntries)); + const sourceData = source.data as HeaderItemDragData; + const targetData = target.data as HeaderDropSlotData; + if (sourceData.kind !== "item" || targetData.kind !== "slot") return; + + updateZones(moveHeaderItemToSlot(value.zones, sourceData.itemKey, targetData)); }; return ( @@ -195,36 +208,42 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom >
-
- {stripEntries.map((entry, index) => { - if (entry.kind === "divider") { - return ( - - ); - } - - const item = entry.item; +
+ + {headerZoneIds.map((zone) => { + const zoneLabel = t(`zones.${zone}` as never); return ( - +
+ + + {value.zones[zone].map((item, zoneIndex) => { + const itemKey = getHeaderItemKey(item); + const sortableIndex = sortableIndices.get(itemKey); + if (sortableIndex === undefined) return null; + + let boardLogo: string | null = null; + if (item.type === "board") { + boardLogo = boardsById.get(item.boardId)?.logoImageUrl ?? null; + } + + return ( + + + + + ); + })} +
); })}
@@ -376,35 +395,49 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom ); }; -interface SortableZoneDividerProps { - entry: Extract; - index: number; +interface HeaderZoneDividerProps { label: string; - showMobileBurger: boolean; } -const SortableZoneDivider = ({ entry, index, label, showMobileBurger }: SortableZoneDividerProps) => { - const { isDropTarget, ref } = useSortable({ - id: entry.id, - index, - group: headerSortableGroup, - data: { kind: entry.kind, zone: entry.zone }, - disabled: { draggable: true, droppable: false }, - transition: null, +const HeaderZoneDivider = ({ label }: HeaderZoneDividerProps) => { + return ( +
+ + {label} + + +
+ ); +}; + +interface HeaderInsertionSlotProps { + zone: HeaderZoneId; + index: number; + zoneLabel: string; +} + +const HeaderInsertionSlot = ({ zone, index, zoneLabel }: HeaderInsertionSlotProps) => { + const slotData: HeaderDropSlotData = { kind: "slot", zone, index }; + const { isDropTarget, ref } = useDroppable({ + id: `header-slot:${zone}:${index}`, + type: headerDropSlotType, + accept: headerSortableType, + collisionPriority: 100, + collisionDetector: pointerIntersection, + data: slotData, }); return (
- -
- {showMobileBurger ? : null} - -
+ {zoneLabel} +
); }; @@ -441,11 +474,13 @@ const SortableHeaderItem = ({ onSelect, }: SortableHeaderItemProps) => { const itemKey = getHeaderItemKey(item); - const { handleRef, isDragging, isDropTarget, ref } = useSortable({ + const { handleRef, isDragging, ref } = useSortable({ id: itemKey, index, group: headerSortableGroup, - data: { kind: "item", item }, + type: headerSortableType, + data: { kind: "item", itemKey } satisfies HeaderItemDragData, + disabled: { draggable: false, droppable: true }, sensors: headerSortableSensors, modifiers: headerSortableModifiers, }); @@ -455,7 +490,6 @@ const SortableHeaderItem = ({ ref={ref} className={classes.sortableItem} data-dragging={isDragging || undefined} - data-drop-target={isDropTarget || undefined} data-entry-id={itemKey} data-selected={selected || undefined} > @@ -555,37 +589,38 @@ const PreviewItem = ({ return ; }; -const createHeaderStripEntries = (zones: HeaderZones): HeaderStripEntry[] => - headerZoneIds.flatMap((zone) => [ - ...zones[zone].map((item) => ({ id: getHeaderItemKey(item), item, kind: "item" as const })), - { id: `header-divider:${zone}`, kind: "divider" as const, zone }, - ]); +const moveHeaderItemToSlot = (zones: HeaderZones, sourceItemKey: string, target: HeaderDropSlotData): HeaderZones => { + let sourceItem: HeaderItem | undefined; + let sourceZone: HeaderZoneId | undefined; + let sourceIndex = -1; -const createHeaderZonesFromStripEntries = (entries: HeaderStripEntry[]): HeaderZones => { - const zones: HeaderZones = { left: [], center: [], right: [] }; - let activeZone: HeaderZoneId = "left"; - - for (const entry of entries) { - if (entry.kind === "divider") { - const dividerIndex = headerZoneIds.indexOf(entry.zone); - const nextZone = headerZoneIds.at(dividerIndex + 1); - if (nextZone) activeZone = nextZone; - continue; - } - zones[activeZone].push(entry.item); + for (const zone of headerZoneIds) { + const itemIndex = zones[zone].findIndex((item) => getHeaderItemKey(item) === sourceItemKey); + if (itemIndex === -1) continue; + sourceItem = zones[zone][itemIndex]; + sourceZone = zone; + sourceIndex = itemIndex; + break; } - return zones; -}; + if (!sourceItem || !sourceZone) return zones; -const moveHeaderStripEntry = (entries: HeaderStripEntry[], from: number, to: number): HeaderStripEntry[] => { - if (from === to || from < 0 || from >= entries.length || to < 0 || to >= entries.length) return entries; + const nextZones: HeaderZones = { + left: zones.left.filter((item) => getHeaderItemKey(item) !== sourceItemKey), + center: zones.center.filter((item) => getHeaderItemKey(item) !== sourceItemKey), + right: zones.right.filter((item) => getHeaderItemKey(item) !== sourceItemKey), + }; - const reorderedEntries = entries.slice(); - const [movedEntry] = reorderedEntries.splice(from, 1); - if (!movedEntry || movedEntry.kind !== "item") return entries; - reorderedEntries.splice(to, 0, movedEntry); - return reorderedEntries; + let targetIndex = target.index; + if (target.zone === sourceZone && sourceIndex < targetIndex) targetIndex -= 1; + + const targetItems = nextZones[target.zone]; + targetIndex = Math.max(0, Math.min(targetIndex, targetItems.length)); + if (target.zone === sourceZone && targetIndex === sourceIndex) return zones; + + nextZones[target.zone] = [...targetItems.slice(0, targetIndex), sourceItem, ...targetItems.slice(targetIndex)]; + + return nextZones; }; const findHeaderItem = (items: HeaderItem[], id: string | null): HeaderItem | undefined => {