From 29c827f1bee7b8ae989a5e3a82ba77a7ec317c11 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 24 Aug 2026 17:40:26 +0200 Subject: [PATCH] fix: simplify header reordering --- .../_components/header-composer.module.css | 73 ++----- .../general/_components/header-composer.tsx | 204 +++++++----------- 2 files changed, 95 insertions(+), 182 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 db364ed32..af342fb7d 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 @@ -75,13 +75,6 @@ white-space: nowrap; } -.zoneSection { - display: flex; - align-items: flex-end; - height: 100%; - flex: 0 0 auto; -} - .zoneDelimiter { display: grid; grid-template-rows: 1.2rem 1.75rem; @@ -93,6 +86,7 @@ padding-block: 0.25rem 0.4rem; flex: 0 0 auto; user-select: none; + transition: background-color 120ms ease; } .zoneLabel { @@ -102,72 +96,28 @@ line-height: 1; letter-spacing: 0.06em; text-transform: uppercase; + transition: color 120ms ease; } .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; + border-color 120ms ease; } -.sortableStrip[data-drag-active="true"] .dropMarker { - background: color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent); +.zoneDelimiter[data-drop-target="true"] { + background: color-mix(in srgb, var(--mantine-primary-color-light) 48%, transparent); } -.dropZoneLabel { - position: absolute; - inset-block-start: -0.9rem; - inset-inline-start: 50%; - z-index: 2; - display: none; - padding: 0.15rem 0.3rem; +.zoneDelimiter[data-drop-target="true"] .zoneLabel { 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; } -.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); -} - -.dropSlot[data-drop-target="true"] .dropZoneLabel { - display: block; +.zoneDelimiter[data-drop-target="true"] .zoneLine { + height: 1.75rem; + border-color: var(--mantine-primary-color-filled); } .sortableItem { @@ -213,6 +163,7 @@ .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); @@ -331,7 +282,9 @@ @media (prefers-reduced-motion: reduce) { .device, - .dropMarker, + .zoneDelimiter, + .zoneLabel, + .zoneLine, .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 95fcee824..ba89666e7 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,12 +1,11 @@ "use client"; -import { Fragment, useMemo, useState } from "react"; +import { 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, useDroppable } from "@dnd-kit/react"; -import { useSortable } from "@dnd-kit/react/sortable"; +import { DragDropProvider, DragOverlay, KeyboardSensor, PointerSensor } from "@dnd-kit/react"; +import { isSortable, useSortable } from "@dnd-kit/react/sortable"; import { ActionIcon, Avatar, @@ -75,16 +74,9 @@ interface HeaderComposerProps { type PreviewSize = "desktop" | "mobile"; -interface HeaderItemDragData { - kind: "item"; - itemKey: string; -} - -interface HeaderDropSlotData { - kind: "slot"; - zone: HeaderZoneId; - index: number; -} +type HeaderStripEntry = + | { id: string; kind: "divider"; zone: HeaderZoneId } + | { id: string; item: HeaderItem; kind: "item" }; const headerSortableSensors = [ PointerSensor.configure({ @@ -94,8 +86,6 @@ 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"); @@ -104,7 +94,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 sortableIndices = new Map(activeItems.map((item, index) => [getHeaderItemKey(item), index])); + const stripEntries = createHeaderStripEntries(value.zones); const activeItemKeySet = new Set(activeItems.map(getHeaderItemKey)); const inactiveBuiltinIds = headerBuiltinItemIds.filter((itemId) => { const item = createBuiltinHeaderItem(itemId); @@ -149,14 +139,11 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom const handleDragEnd = ({ canceled, operation }: DragEndEvent) => { setDraggedItemKey(null); const source = operation.source; - const target = operation.target; - if (canceled || !source || !target) return; + if (canceled || !isSortable(source)) return; + if (source.initialIndex === source.index) return; - 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)); + const nextEntries = moveHeaderStripEntry(stripEntries, source.initialIndex, source.index); + updateZones(createHeaderZonesFromStripEntries(nextEntries)); }; return ( @@ -208,42 +195,36 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom >
-
+
- {headerZoneIds.map((zone) => { - const zoneLabel = t(`zones.${zone}` as never); + {stripEntries.map((entry, index) => { + if (entry.kind === "divider") { + return ( + + ); + } + + const item = entry.item; 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 ( - - - - - ); - })} -
+ ); })}
@@ -395,49 +376,32 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom ); }; -interface HeaderZoneDividerProps { +interface SortableZoneDividerProps { + entry: Extract; + index: number; label: string; } -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, +const SortableZoneDivider = ({ entry, index, label }: SortableZoneDividerProps) => { + const { isDropTarget, ref } = useSortable({ + id: entry.id, + index, + group: headerSortableGroup, + data: { kind: entry.kind, zone: entry.zone }, + disabled: { draggable: true }, }); return (
- {zoneLabel} - + + {label} + +
); }; @@ -474,13 +438,11 @@ const SortableHeaderItem = ({ onSelect, }: SortableHeaderItemProps) => { const itemKey = getHeaderItemKey(item); - const { handleRef, isDragging, ref } = useSortable({ + const { handleRef, isDragging, isDropTarget, ref } = useSortable({ id: itemKey, index, group: headerSortableGroup, - type: headerSortableType, - data: { kind: "item", itemKey } satisfies HeaderItemDragData, - disabled: { draggable: false, droppable: true }, + data: { kind: "item", item }, sensors: headerSortableSensors, modifiers: headerSortableModifiers, }); @@ -490,6 +452,7 @@ const SortableHeaderItem = ({ ref={ref} className={classes.sortableItem} data-dragging={isDragging || undefined} + data-drop-target={isDropTarget || undefined} data-entry-id={itemKey} data-selected={selected || undefined} > @@ -589,38 +552,35 @@ const PreviewItem = ({ return ; }; -const moveHeaderItemToSlot = (zones: HeaderZones, sourceItemKey: string, target: HeaderDropSlotData): HeaderZones => { - let sourceItem: HeaderItem | undefined; - let sourceZone: HeaderZoneId | undefined; - let sourceIndex = -1; +const createHeaderStripEntries = (zones: HeaderZones): HeaderStripEntry[] => + headerZoneIds.flatMap((zone) => [ + { id: `header-divider:${zone}`, kind: "divider" as const, zone }, + ...zones[zone].map((item) => ({ id: getHeaderItemKey(item), item, kind: "item" as const })), + ]); - 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; +const createHeaderZonesFromStripEntries = (entries: HeaderStripEntry[]): HeaderZones => { + const zones: HeaderZones = { left: [], center: [], right: [] }; + let activeZone: HeaderZoneId = "left"; + + for (const entry of entries) { + if (entry.kind === "divider") { + activeZone = entry.zone; + continue; + } + zones[activeZone].push(entry.item); } - if (!sourceItem || !sourceZone) return zones; + return zones; +}; - 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 moveHeaderStripEntry = (entries: HeaderStripEntry[], from: number, to: number): HeaderStripEntry[] => { + if (from === to || from < 0 || from >= entries.length || to < 0 || to >= entries.length) return entries; - 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 reorderedEntries = entries.slice(); + const [movedEntry] = reorderedEntries.splice(from, 1); + if (!movedEntry || movedEntry.kind !== "item") return entries; + reorderedEntries.splice(to, 0, movedEntry); + return reorderedEntries; }; const findHeaderItem = (items: HeaderItem[], id: string | null): HeaderItem | undefined => {