fix: simplify header reordering

This commit is contained in:
ajnart committed 2026-08-24 16:55:25 +02:00
1 parent 3c83e646ca
commit d0fe65a1d6
2 files changed
+203 -118

No files matched your search

@@ -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;
}
@@ -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<PreviewSize>("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
>
<div className={classes.previewHeader}>
<div className={classes.stripScroller}>
<div className={classes.sortableStrip}>
{stripEntries.map((entry, index) => {
if (entry.kind === "divider") {
return (
<SortableZoneDivider
key={entry.id}
entry={entry}
index={index}
label={t(`zones.${entry.zone}` as never)}
showMobileBurger={entry.zone === "left"}
/>
);
}
const item = entry.item;
<div className={classes.sortableStrip} data-drag-active={draggedItemKey !== null || undefined}>
<MobileBurger />
{headerZoneIds.map((zone) => {
const zoneLabel = t(`zones.${zone}` as never);
return (
<SortableHeaderItem
key={entry.id}
item={item}
index={index}
label={itemLabel(item)}
previewSize={previewSize}
searchDisplay={value.searchDisplay}
boardLogo={
item.type === "board" ? (boardsById.get(item.boardId)?.logoImageUrl ?? null) : null
}
configureLabel={t("action.configure", { item: itemLabel(item) })}
selected={selectedItemKey === entry.id}
onSelect={setSelectedItemKey}
/>
<div key={zone} className={classes.zoneSection} data-zone={zone}>
<HeaderZoneDivider label={zoneLabel} />
<HeaderInsertionSlot zone={zone} index={0} zoneLabel={zoneLabel} />
{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 (
<Fragment key={itemKey}>
<SortableHeaderItem
item={item}
index={sortableIndex}
label={itemLabel(item)}
previewSize={previewSize}
searchDisplay={value.searchDisplay}
boardLogo={boardLogo}
configureLabel={t("action.configure", { item: itemLabel(item) })}
selected={selectedItemKey === itemKey}
onSelect={setSelectedItemKey}
/>
<HeaderInsertionSlot zone={zone} index={zoneIndex + 1} zoneLabel={zoneLabel} />
</Fragment>
);
})}
</div>
);
})}
</div>
@@ -376,35 +395,49 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
);
};
interface SortableZoneDividerProps {
entry: Extract<HeaderStripEntry, { kind: "divider" }>;
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 (
<div className={classes.zoneDelimiter}>
<Text className={classes.zoneLabel} component="span">
{label}
</Text>
<Divider className={classes.zoneLine} orientation="vertical" />
</div>
);
};
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 (
<div
ref={ref}
className={classes.zoneDelimiter}
data-divider-zone={entry.zone}
className={classes.dropSlot}
data-drop-target={isDropTarget || undefined}
data-slot-zone={zone}
data-slot-index={index}
aria-label={zoneLabel}
>
<Divider label={label} labelPosition="center" />
<div className={classes.zoneBoundary}>
{showMobileBurger ? <MobileBurger /> : null}
<span aria-hidden />
</div>
<span className={classes.dropZoneLabel}>{zoneLabel}</span>
<span className={classes.dropMarker} aria-hidden />
</div>
);
};
@@ -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 <Icon size={20} stroke={1.7} />;
};
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 => {