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 269f15751..8a1244039 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 @@ -66,6 +66,7 @@ .sortableStrip { display: flex; + align-items: stretch; gap: var(--mantine-spacing-sm); width: 100%; min-width: 100%; @@ -74,23 +75,15 @@ white-space: nowrap; } -.zoneSegment { +.zoneDelimiter { display: grid; grid-template-rows: 1.25rem 2.5rem; gap: 0.3rem; - min-width: max-content; - flex: 1 0 0; + min-width: 5rem; + height: 100%; + flex: 1 1 7rem; align-content: center; -} - -.zoneDelimiter { - display: grid; - align-items: center; - width: 100%; - min-width: 0; - min-height: 1.25rem; - padding-inline: 0.15rem; - pointer-events: none; + padding-inline: 0.25rem; user-select: none; } @@ -103,57 +96,42 @@ text-transform: uppercase; } -.zoneControls { +.zoneBoundary { display: flex; align-items: center; - gap: 0.3rem; - min-width: 0; + justify-content: center; + gap: var(--mantine-spacing-xs); height: 2.5rem; - padding: 0.125rem; - overflow: visible; + pointer-events: none; } -.zoneSegment[data-zone="center"] .zoneControls { - justify-content: safe center; -} - -.zoneSegment[data-zone="right"] .zoneControls { - justify-content: safe flex-end; -} - -.emptyZoneTarget { - display: grid; - place-items: center; - min-width: 2.25rem; - height: 2.25rem; - flex: 1 0 2.25rem; - border-radius: var(--mantine-radius-sm); - transition: background-color 120ms ease; -} - -.emptyZoneTarget span { +.zoneBoundary > span { width: 2px; - height: 1rem; + height: 1.25rem; border-radius: var(--mantine-radius-xl); - background: transparent; + background: var(--mantine-color-default-border); transition: height 120ms ease, background-color 120ms ease; } -.emptyZoneTarget[data-over="true"] { - background: var(--mantine-primary-color-light); -} - -.emptyZoneTarget[data-over="true"] span { +.zoneDelimiter[data-drop-target="true"] .zoneBoundary > span { height: 1.5rem; background: var(--mantine-primary-color-filled); } .sortableItem { + display: flex; + align-items: flex-end; + height: 100%; + padding-block: 1.55rem 0.125rem; flex: 0 0 auto; } +.sortableItem[data-dragging="true"] { + opacity: 0.2; +} + .itemButton, .dragOverlay { display: grid; @@ -185,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); @@ -302,8 +281,7 @@ @media (prefers-reduced-motion: reduce) { .device, - .emptyZoneTarget, - .emptyZoneTarget span, + .zoneBoundary > span, .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 ffb955795..7912e417b 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,25 +1,11 @@ "use client"; -import type { CSSProperties, ReactNode } from "react"; -import { useId, useMemo, useState } from "react"; -import type { DragEndEvent, UniqueIdentifier } from "@dnd-kit/core"; -import { - closestCenter, - DndContext, - DragOverlay, - KeyboardSensor, - MouseSensor, - TouchSensor, - useDroppable, - useSensor, - useSensors, -} from "@dnd-kit/core"; -import { - horizontalListSortingStrategy, - SortableContext, - sortableKeyboardCoordinates, - useSortable, -} from "@dnd-kit/sortable"; +import { useMemo, useState } from "react"; +import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"; +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 { ActionIcon, Avatar, @@ -35,7 +21,6 @@ import { Text, ThemeIcon, Tooltip, - VisuallyHidden, } from "@mantine/core"; import { IconBrandDocker, @@ -89,23 +74,27 @@ interface HeaderComposerProps { type PreviewSize = "desktop" | "mobile"; +type HeaderStripEntry = + | { id: string; kind: "divider"; zone: HeaderZoneId } + | { id: string; item: HeaderItem; kind: "item" }; + +const headerSortableSensors = [ + PointerSensor.configure({ + activationConstraints: [new PointerActivationConstraints.Distance({ value: 6 })], + }), + KeyboardSensor, +]; +const headerSortableModifiers = [RestrictToHorizontalAxis]; +const headerSortableGroup = "header-composer"; + export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHomeBoardId }: HeaderComposerProps) => { - const dndContextId = useId(); const [previewSize, setPreviewSize] = useState("desktop"); const [draggedItemKey, setDraggedItemKey] = useState(null); const [selectedItemKey, setSelectedItemKey] = useState(null); const t = useI18n("management.page.user.setting.general.header"); - const sensors = useSensors( - useSensor(MouseSensor, { activationConstraint: { distance: 6 } }), - useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 5 } }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - keyboardCodes: { start: ["Space"], cancel: ["Escape"], end: ["Space"] }, - }), - ); const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]); const activeItems = getHeaderItems(value.zones); - const visibleItemKeys = activeItems.map(getHeaderItemKey); + const stripEntries = createHeaderStripEntries(value.zones); const activeItemKeySet = new Set(activeItems.map(getHeaderItemKey)); const inactiveBuiltinIds = headerBuiltinItemIds.filter((itemId) => { const item = createBuiltinHeaderItem(itemId); @@ -147,26 +136,14 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom setSelectedItemKey(itemKey); }; - const handleDragEnd = ({ active, over }: DragEndEvent) => { + const handleDragEnd = ({ canceled, operation }: DragEndEvent) => { setDraggedItemKey(null); - if (!over) return; - const item = findHeaderItem(activeItems, active.id); - if (!item) return; - const sourceZone = getHeaderItemZone(value.zones, item); - const targetZone = getDropZone(value.zones, over.id); - if (!sourceZone || !targetZone) return; + const source = operation.source; + if (canceled || !isSortable(source)) return; + if (source.initialIndex === source.index) return; - const targetItem = findHeaderItem(activeItems, over.id); - let targetIndex = value.zones[targetZone].length; - if (targetItem) { - targetIndex = value.zones[targetZone].findIndex((candidate) => isSameItem(candidate, targetItem)); - const translatedRect = active.rect.current.translated; - const activeCenter = translatedRect ? translatedRect.left + translatedRect.width / 2 : null; - const targetCenter = over.rect.left + over.rect.width / 2; - if (activeCenter !== null && activeCenter > targetCenter) targetIndex += 1; - } - if (targetIndex < 0) return; - updateZones(moveHeaderItem(value.zones, item, targetZone, targetIndex)); + const nextEntries = moveHeaderStripEntry(stripEntries, source.initialIndex, source.index); + updateZones(createHeaderZonesFromStripEntries(nextEntries)); }; return ( @@ -212,40 +189,48 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom {value.visible ? ( - setDraggedItemKey(String(active.id))} - onDragCancel={() => setDraggedItemKey(null)} + setDraggedItemKey(operation.source?.id.toString() ?? null)} onDragEnd={handleDragEnd} >
- - {headerZoneIds.map((zone) => ( - : null} - items={value.zones[zone]} + {stripEntries.map((entry, index) => { + if (entry.kind === "divider") { + return ( + + ); + } + + const item = entry.item; + return ( + boardsById.get(boardId)?.logoImageUrl ?? null} - configureLabel={(item) => t("action.configure", { item: itemLabel(item) })} + boardLogo={ + item.type === "board" ? (boardsById.get(item.boardId)?.logoImageUrl ?? null) : null + } + configureLabel={t("action.configure", { item: itemLabel(item) })} + selected={selectedItemKey === entry.id} onSelect={setSelectedItemKey} /> - ))} - + ); + })}
- + {draggedItem ? ( ) : null} -
+ ) : (
@@ -391,74 +376,35 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom ); }; -interface ZoneSegmentProps { - zone: HeaderZoneId; +interface SortableZoneDividerProps { + entry: Extract; + index: number; label: string; - emptyLabel: string; - leading: ReactNode; - items: HeaderItem[]; - previewSize: PreviewSize; - searchDisplay: HeaderPreferences["searchDisplay"]; - selectedItemKey: string | null; - itemLabel: (item: HeaderItem) => string; - boardLogo: (boardId: string) => string | null; - configureLabel: (item: HeaderItem) => string; - onSelect: (itemKey: string) => void; + showMobileBurger: boolean; } -const ZoneSegment = ({ - zone, - label, - emptyLabel, - leading, - items, - previewSize, - searchDisplay, - selectedItemKey, - itemLabel, - boardLogo, - configureLabel, - onSelect, -}: ZoneSegmentProps) => ( -
- -
- {leading} - {items.map((item) => { - const itemKey = getHeaderItemKey(item); - return ( - - ); - })} - {items.length === 0 ? : null} -
-
-); - -// Fixed reference point inside the shared sortable row; it never enters SortableContext. -const ZoneDelimiter = ({ zone, label }: { zone: HeaderZoneId; label: string }) => ( -
- -
-); - -const EmptyZoneDropTarget = ({ zone, label }: { zone: HeaderZoneId; label: string }) => { - const { isOver, setNodeRef } = useDroppable({ id: getDropZoneId(zone) }); +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, + }); return ( -
- {label} - +
+ +
+ {showMobileBurger ? : null} + +
); }; @@ -473,6 +419,7 @@ const MobileBurger = () => ( interface SortableHeaderItemProps { item: HeaderItem; + index: number; label: string; previewSize: PreviewSize; searchDisplay: HeaderPreferences["searchDisplay"]; @@ -484,6 +431,7 @@ interface SortableHeaderItemProps { const SortableHeaderItem = ({ item, + index, label, previewSize, searchDisplay, @@ -493,19 +441,26 @@ const SortableHeaderItem = ({ onSelect, }: SortableHeaderItemProps) => { const itemKey = getHeaderItemKey(item); - const { attributes, isDragging, listeners, setActivatorNodeRef, setNodeRef, transform, transition } = useSortable({ + const { handleRef, isDragging, isDropTarget, ref } = useSortable({ id: itemKey, + index, + group: headerSortableGroup, + data: { kind: "item", item }, + sensors: headerSortableSensors, + modifiers: headerSortableModifiers, }); - const style = { - transition, - transform: transform ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)` : undefined, - opacity: isDragging ? 0.2 : undefined, - } satisfies CSSProperties; return ( -
+