fix: refine header layout editor

This commit is contained in:
ajnart committed 2026-08-24 18:23:50 +02:00
1 parent e68a1491ae
commit 2dbeb792a7
3 files changed
+160 -246

No files matched your search

@@ -1,13 +1,7 @@
.composer {
width: 100%;
overflow: hidden;
background:
radial-gradient(
ellipse 70% 42% at 50% 0%,
color-mix(in srgb, var(--mantine-primary-color-light) 32%, transparent),
transparent 72%
),
var(--mantine-color-body);
background: var(--mantine-color-body);
}
.heading,
@@ -22,7 +16,7 @@
.stage {
display: grid;
place-items: center;
min-height: 20rem;
min-height: 21rem;
padding: var(--mantine-spacing-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
background-image: radial-gradient(var(--mantine-color-default-border) 0.75px, transparent 0.75px);
@@ -31,8 +25,8 @@
.device {
position: relative;
width: 100%;
min-height: 15rem;
width: min(100%, 31.2rem);
min-height: 18rem;
overflow: hidden;
background: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-lg);
@@ -41,107 +35,89 @@
min-height 180ms ease;
}
.stage[data-size="mobile"] .device {
width: min(100%, 24rem);
min-height: 18rem;
}
.previewHeader {
position: relative;
z-index: 1;
height: 5rem;
min-height: 5rem;
border-bottom: 1px solid var(--mantine-color-default-border);
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
direction: ltr;
}
.stripScroller {
width: 100%;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
scrollbar-color: var(--mantine-color-default-border) transparent;
scrollbar-width: thin;
}
.sortableStrip {
position: relative;
display: flex;
align-items: stretch;
gap: 0.25rem;
width: max-content;
min-width: 100%;
height: 100%;
padding: 0.35rem var(--mantine-spacing-sm) 0.45rem;
white-space: nowrap;
}
.zoneOriginLabel {
position: absolute;
inset-block-start: 0.85rem;
inset-inline-start: var(--mantine-spacing-sm);
color: var(--mantine-color-dimmed);
font-size: 0.55rem;
font-weight: 700;
line-height: 1;
letter-spacing: 0.06em;
text-transform: uppercase;
user-select: none;
}
.zoneDelimiter {
.zoneTable {
display: grid;
grid-template-rows: 1.2rem 1.75rem;
place-items: center;
gap: 0.2rem;
width: 3.5rem;
height: 100%;
align-content: center;
padding-block: 0.25rem 0.4rem;
flex: 0 0 auto;
user-select: none;
grid-template-columns: repeat(3, minmax(0, 1fr));
width: 100%;
min-height: 5rem;
}
.zoneColumn {
min-width: 0;
border-inline-end: 1px solid var(--mantine-color-default-border);
transition: background-color 120ms ease;
}
.zoneLabel {
.zoneColumn:last-child {
border-inline-end: 0;
}
.zoneColumn[data-drop-target="true"] {
background: var(--mantine-primary-color-light);
}
.zoneColumn[data-drop-target="true"] .zoneItems::after {
content: "";
width: 2.25rem;
height: 2.25rem;
border: 1px dashed var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md);
background: color-mix(in srgb, var(--mantine-primary-color-light) 65%, transparent);
}
.zoneColumnHeader {
display: grid;
place-items: center;
height: 1.75rem;
margin: 0;
color: var(--mantine-color-dimmed);
border-bottom: 1px solid var(--mantine-color-default-border);
font-size: 0.55rem;
font-weight: 700;
line-height: 1;
letter-spacing: 0.06em;
text-transform: uppercase;
transition: color 120ms ease;
user-select: none;
}
.zoneLine {
height: 1.5rem;
border-color: var(--mantine-color-default-border);
transition:
height 120ms ease,
border-color 120ms ease;
}
.zoneDelimiter[data-drop-target="true"] {
background: color-mix(in srgb, var(--mantine-primary-color-light) 48%, transparent);
}
.zoneDelimiter[data-drop-target="true"] .zoneLabel {
color: var(--mantine-primary-color-filled);
}
.zoneDelimiter[data-drop-target="true"] .zoneLine {
height: 1.75rem;
border-color: var(--mantine-primary-color-filled);
.zoneItems {
display: flex;
align-content: flex-start;
align-items: flex-start;
flex-wrap: wrap;
gap: 0.25rem;
min-height: 3.25rem;
padding: 0.5rem;
}
.sortableItem {
position: relative;
display: flex;
align-items: flex-end;
height: 100%;
padding-block: 1.55rem 0.125rem;
align-items: center;
flex: 0 0 auto;
}
.sortableItem[data-drop-target="true"]::before {
content: "";
position: absolute;
inset-block: 0.2rem;
inset-inline-start: -0.2rem;
z-index: 1;
width: 2px;
border-radius: var(--mantine-radius-xl);
background: var(--mantine-primary-color-filled);
box-shadow: 0 0 0 2px var(--mantine-primary-color-light);
}
.sortableItem[data-dragging="true"] {
opacity: 0.2;
}
@@ -216,28 +192,6 @@
text-overflow: ellipsis;
}
.mobileBurger {
display: none;
width: 2.25rem;
height: 2.25rem;
gap: 3px;
place-content: center;
align-self: flex-end;
margin: 0 0.125rem 0.125rem 0;
flex: 0 0 auto;
}
.mobileBurger span {
display: block;
width: 1rem;
height: 1px;
background: var(--mantine-color-text);
}
.stage[data-size="mobile"] .mobileBurger {
display: grid;
}
.hiddenPreview {
position: absolute;
inset: 0 0 auto;
@@ -252,7 +206,7 @@
}
.dashboardPreview {
height: 11.25rem;
height: 13rem;
overflow: hidden;
opacity: 0.86;
}
@@ -262,18 +216,6 @@
aspect-ratio: auto;
}
.stage[data-size="mobile"] .sortableStrip {
padding-inline: var(--mantine-spacing-xs);
}
.stage[data-size="mobile"] .zoneLabel {
font-size: 0.5rem;
}
.stage[data-size="mobile"] .dashboardPreview {
height: 14.25rem;
}
.tray {
border-top: 1px solid var(--mantine-color-default-border);
}
@@ -296,9 +238,7 @@
@media (prefers-reduced-motion: reduce) {
.device,
.zoneDelimiter,
.zoneLabel,
.zoneLine,
.zoneColumn,
.itemButton {
transition: none;
}
@@ -1,18 +1,17 @@
"use client";
import { useMemo, useState } from "react";
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers";
import { type ReactNode, useMemo, useState } from "react";
import { PointerActivationConstraints } from "@dnd-kit/dom";
import { SortableKeyboardPlugin } from "@dnd-kit/dom/sortable";
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,
Box,
Button,
Card,
Divider,
Group,
Menu,
Paper,
@@ -71,11 +70,19 @@ interface HeaderComposerProps {
mobileHomeBoardId: string | null;
}
type PreviewSize = "desktop" | "mobile";
interface HeaderItemDragData {
kind: "item";
itemKey: string;
zone: HeaderZoneId;
index: number;
}
type HeaderStripEntry =
| { id: string; kind: "divider"; zone: HeaderZoneId }
| { id: string; item: HeaderItem; kind: "item" };
interface HeaderZoneDropData {
kind: "zone";
zone: HeaderZoneId;
}
type HeaderDropData = HeaderItemDragData | HeaderZoneDropData;
const headerSortableSensors = [
PointerSensor.configure({
@@ -83,17 +90,16 @@ const headerSortableSensors = [
}),
KeyboardSensor,
];
const headerSortableModifiers = [RestrictToHorizontalAxis];
const headerSortableGroup = "header-composer";
const headerSortablePlugins = [SortableKeyboardPlugin];
const headerGuideZones = ["left", "center", "right"] as const;
export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHomeBoardId }: HeaderComposerProps) => {
const [previewSize, setPreviewSize] = useState<PreviewSize>("desktop");
export const HeaderComposer = ({ value, onChange, boards, homeBoardId }: HeaderComposerProps) => {
const [draggedItemKey, setDraggedItemKey] = useState<string | null>(null);
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
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 activeItemKeySet = new Set(activeItems.map(getHeaderItemKey));
const inactiveBuiltinIds = headerBuiltinItemIds.filter((itemId) => {
const item = createBuiltinHeaderItem(itemId);
@@ -104,9 +110,14 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
);
const draggedItem = findHeaderItem(activeItems, draggedItemKey);
const selectedItem = findHeaderItem(activeItems, selectedItemKey);
let previewBoardId = homeBoardId;
if (previewSize === "mobile") previewBoardId = mobileHomeBoardId ?? homeBoardId;
const previewBoard = boardsById.get(previewBoardId ?? "") ?? boards.at(0);
const previewBoard = boardsById.get(homeBoardId ?? "") ?? boards.at(0);
const toggleSelectedItem = (itemKey: string) => {
setSelectedItemKey((currentItemKey) => {
if (currentItemKey === itemKey) return null;
return itemKey;
});
};
const itemLabel = (item: HeaderItem) => {
if (item.type === "board") return boardsById.get(item.boardId)?.name ?? t("items.boardUnavailable");
@@ -138,11 +149,22 @@ 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 HeaderDropData;
if (sourceData.kind !== "item") return;
let targetIndex = value.zones[targetData.zone].length;
if (targetData.kind === "item") {
if (targetData.itemKey === sourceData.itemKey) return;
targetIndex = targetData.index;
}
const nextZones = moveHeaderItem(value.zones, sourceData.itemKey, targetData.zone, targetIndex);
if (nextZones === value.zones) return;
updateZones(nextZones);
};
return (
@@ -163,15 +185,6 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
</Text>
</Stack>
<Group gap="xs">
<SegmentedControl
size="xs"
value={previewSize}
onChange={(next) => setPreviewSize(next as PreviewSize)}
data={[
{ value: "desktop", label: t("device.desktop") },
{ value: "mobile", label: t("device.mobile") },
]}
/>
<Tooltip label={value.visible ? t("action.hide") : t("action.show")}>
<ActionIcon
size="lg"
@@ -185,7 +198,7 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
</Group>
</Group>
<Box className={classes.stage} data-size={previewSize} data-visible={value.visible}>
<Box className={classes.stage} data-visible={value.visible}>
<Paper className={classes.device} radius="lg" withBorder>
{value.visible ? (
<DragDropProvider
@@ -193,43 +206,27 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
onDragEnd={handleDragEnd}
>
<div className={classes.previewHeader}>
<div className={classes.stripScroller}>
<div className={classes.sortableStrip}>
<Text className={classes.zoneOriginLabel} component="span">
{t("zones.left")}
</Text>
<MobileBurger />
{stripEntries.map((entry, index) => {
if (entry.kind === "divider") {
return (
<SortableZoneDivider
key={entry.id}
entry={entry}
index={index}
label={t(`zones.${entry.zone}` as never)}
/>
);
}
const item = entry.item;
return (
<div className={classes.zoneTable} onClick={() => setSelectedItemKey(null)}>
{headerGuideZones.map((zone) => (
<HeaderZoneColumn key={zone} zone={zone} label={t(`zones.${zone}` as never)}>
{value.zones[zone].map((item, index) => (
<SortableHeaderItem
key={entry.id}
key={getHeaderItemKey(item)}
item={item}
zone={zone}
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}
selected={selectedItemKey === getHeaderItemKey(item)}
onSelect={toggleSelectedItem}
/>
);
})}
</div>
))}
</HeaderZoneColumn>
))}
</div>
</div>
<DragOverlay dropAnimation={null}>
@@ -237,7 +234,7 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
<HeaderItemPreview
item={draggedItem}
label={itemLabel(draggedItem)}
compact={previewSize === "mobile"}
compact={false}
searchDisplay={value.searchDisplay}
boardLogo={
draggedItem.type === "board" ? boardsById.get(draggedItem.boardId)?.logoImageUrl : null
@@ -378,49 +375,33 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
);
};
interface SortableZoneDividerProps {
entry: Extract<HeaderStripEntry, { kind: "divider" }>;
index: number;
interface HeaderZoneColumnProps {
zone: HeaderZoneId;
label: string;
children: ReactNode;
}
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 },
const HeaderZoneColumn = ({ zone, label, children }: HeaderZoneColumnProps) => {
const { isDropTarget, ref } = useDroppable({
id: `header-zone:${zone}`,
data: { kind: "zone", zone } satisfies HeaderZoneDropData,
});
return (
<div
ref={ref}
className={classes.zoneDelimiter}
data-divider-zone={entry.zone}
data-drop-target={isDropTarget || undefined}
>
<Text className={classes.zoneLabel} component="span">
<section ref={ref} className={classes.zoneColumn} data-drop-target={isDropTarget || undefined}>
<Text className={classes.zoneColumnHeader} component="h4">
{label}
</Text>
<Divider className={classes.zoneLine} orientation="vertical" />
</div>
<div className={classes.zoneItems}>{children}</div>
</section>
);
};
const MobileBurger = () => (
<div className={classes.mobileBurger} aria-hidden>
<span />
<span />
<span />
</div>
);
interface SortableHeaderItemProps {
item: HeaderItem;
zone: HeaderZoneId;
index: number;
label: string;
previewSize: PreviewSize;
searchDisplay: HeaderPreferences["searchDisplay"];
boardLogo: string | null;
configureLabel: string;
@@ -430,9 +411,9 @@ interface SortableHeaderItemProps {
const SortableHeaderItem = ({
item,
zone,
index,
label,
previewSize,
searchDisplay,
boardLogo,
configureLabel,
@@ -443,10 +424,10 @@ const SortableHeaderItem = ({
const { handleRef, isDragging, isDropTarget, ref } = useSortable({
id: itemKey,
index,
group: headerSortableGroup,
data: { kind: "item", item },
group: `${headerSortableGroup}:${zone}`,
data: { kind: "item", itemKey, zone, index } satisfies HeaderItemDragData,
sensors: headerSortableSensors,
modifiers: headerSortableModifiers,
plugins: headerSortablePlugins,
});
return (
@@ -463,19 +444,19 @@ const SortableHeaderItem = ({
type="button"
className={classes.itemButton}
data-kind={item.type === "builtin" ? item.id : "board"}
data-compact={previewSize === "mobile" || undefined}
data-wide={
item.type === "builtin" && item.id === "search" && searchDisplay === "input" && previewSize === "desktop"
? true
: undefined
(item.type === "builtin" && item.id === "search" && searchDisplay === "input") || undefined
}
aria-label={configureLabel}
onClick={() => onSelect(itemKey)}
onClick={(event) => {
event.stopPropagation();
onSelect(itemKey);
}}
>
<HeaderItemPreview
item={item}
label={label}
compact={previewSize === "mobile"}
compact={false}
searchDisplay={searchDisplay}
boardLogo={boardLogo}
/>
@@ -554,37 +535,30 @@ const PreviewItem = ({
return <Icon size={20} stroke={1.7} />;
};
const createHeaderStripEntries = (zones: HeaderZones): HeaderStripEntry[] => [
...zones.left.map((item) => ({ id: getHeaderItemKey(item), item, kind: "item" as const })),
{ id: "header-divider:center", kind: "divider", zone: "center" },
...zones.center.map((item) => ({ id: getHeaderItemKey(item), item, kind: "item" as const })),
{ id: "header-divider:right", kind: "divider", zone: "right" },
...zones.right.map((item) => ({ id: getHeaderItemKey(item), item, kind: "item" as const })),
];
const moveHeaderItem = (
zones: HeaderZones,
itemKey: string,
targetZone: HeaderZoneId,
requestedTargetIndex: number,
): HeaderZones => {
let sourceItem: HeaderItem | undefined;
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);
for (const zone of headerGuideZones) {
sourceItem = zones[zone].find((item) => getHeaderItemKey(item) === itemKey);
if (sourceItem) break;
}
return zones;
};
if (!sourceItem) 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 reorderedEntries = entries.slice();
const [movedEntry] = reorderedEntries.splice(from, 1);
if (!movedEntry || movedEntry.kind !== "item") return entries;
reorderedEntries.splice(to, 0, movedEntry);
return reorderedEntries;
const nextZones: HeaderZones = {
left: zones.left.filter((item) => getHeaderItemKey(item) !== itemKey),
center: zones.center.filter((item) => getHeaderItemKey(item) !== itemKey),
right: zones.right.filter((item) => getHeaderItemKey(item) !== itemKey),
};
const targetItems = nextZones[targetZone];
const targetIndex = Math.max(0, Math.min(requestedTargetIndex, targetItems.length));
nextZones[targetZone] = [...targetItems.slice(0, targetIndex), sourceItem, ...targetItems.slice(targetIndex)];
return nextZones;
};
const findHeaderItem = (items: HeaderItem[], id: string | null): HeaderItem | undefined => {
+1 -1
View File
@@ -5690,7 +5690,7 @@
"header": {
"title": "Header studio",
"badge": "Personal",
"description": "Drag controls anywhere in the row. Center and Right are fixed boundaries; everything before Center belongs to Left.",
"description": "Drag controls anywhere in the row. Left, Center, and Right stay fixed while controls shift around them.",
"device": {
"desktop": "Desktop",
"mobile": "Mobile"