refactor(tasks): stabilize grouped board headers

This commit is contained in:
lizerk 2026-08-06 19:47:57 +03:00
parent 31a0ec922f
commit 63b7da46d1
2 changed files with 162 additions and 93 deletions

View File

@ -899,6 +899,19 @@ html[data-bs-theme="dark"] .shadow-xl {
box-shadow: var(--shadow-card) !important;
}
html[data-bs-theme="dark"] .tasks-board-column-header,
html[data-bs-theme="dark"] .tasks-board-subcolumn-header {
box-shadow: none !important;
}
html[data-bs-theme="dark"] .tasks-board-column-header-sticky {
background: var(--surface-elevated);
}
html[data-bs-theme="dark"] .tasks-board-group-header-sticky {
background: var(--surface-elevated);
}
/* Скрытые полосы прокрутки везде */
* {
@ -1001,35 +1014,66 @@ html[data-bs-theme="dark"] .shadow-xl {
--tasks-board-stack-gap: 0.5rem;
--tasks-board-column-header-height: 4rem;
--tasks-board-subcolumn-header-height: 4rem;
--tasks-board-group-header-stack-height: calc(
var(--tasks-board-column-header-height, 4rem)
+ var(--tasks-board-stack-gap, 0.5rem)
+ var(--tasks-board-subcolumn-header-height, 4rem)
+ 1rem
+ 1px
);
}
.tasks-board-column-header {
.tasks-board-column-header-sticky {
position: sticky;
top: 0;
z-index: 30;
padding-bottom: var(--tasks-board-stack-gap, 0.5rem);
background: var(--background);
}
.tasks-board-column-header-sticky-standalone {
padding-bottom: 0;
}
.tasks-board-column-header {
min-height: var(--tasks-board-column-header-height, 4rem);
background: var(--surface-elevated);
}
.tasks-board-subcolumn-header-row {
.tasks-board-group-header-sticky {
position: sticky;
top: calc(
var(--tasks-board-column-header-height, 4rem)
+ var(--tasks-board-stack-gap, 0.5rem)
);
z-index: 20;
top: 0;
z-index: 30;
background: var(--background);
}
.tasks-board-subcolumn-header-row {
min-height: var(--tasks-board-subcolumn-header-height, 4rem);
}
.tasks-board-subcolumn-header-surface::after {
position: absolute;
right: -1px;
bottom: -0.75rem;
left: -1px;
height: 0.75rem;
border: 1px solid transparent;
border-top: 0;
border-radius: 0 0 0.75rem 0.75rem;
pointer-events: none;
background: transparent;
content: "";
}
.tasks-board-column-group[data-content-bottom-reached="true"] .tasks-board-subcolumn-header-surface::after {
border-color: var(--border-subtle);
border-top-color: transparent;
background: var(--surface-secondary);
}
.tasks-board-lane-header {
position: sticky;
top: calc(
var(--tasks-board-column-header-height, 4rem)
+ var(--tasks-board-subcolumn-header-height, 4rem)
+ var(--tasks-board-stack-gap, 0.5rem)
+ var(--tasks-board-stack-gap, 0.5rem)
);
top: var(--tasks-board-group-header-stack-height, calc(9.5rem + 1px));
z-index: 15;
background: var(--surface-secondary);
}
@ -1043,4 +1087,5 @@ html[data-bs-theme="dark"] .shadow-xl {
height: 3.9375rem;
content: "";
}
}

View File

@ -1009,11 +1009,12 @@ function BoardColumn({
isDragging ? "opacity-70" : ""
}`}
>
<div
className={`tasks-board-column-header app-surface flex min-h-16 items-center justify-between gap-3 rounded-xl px-3 py-3 transition ${columnBorderClass} ${
isDragging ? "shadow-xl" : ""
}`}
>
<div className={`tasks-board-column-header-sticky ${contentVisible ? "" : "tasks-board-column-header-sticky-standalone"}`}>
<div
className={`tasks-board-column-header app-surface flex min-h-16 items-center justify-between gap-3 rounded-xl px-3 py-3 transition ${columnBorderClass} ${
isDragging ? "shadow-xl" : ""
}`}
>
<div
ref={setActivatorNodeRef}
className="min-w-0 flex-1 cursor-grab touch-none overflow-hidden active:cursor-grabbing"
@ -1126,9 +1127,10 @@ function BoardColumn({
) : null}
</div>
</div>
</div>
</div>
{contentVisible ? <div
className={`tasks-column-scroll mt-2 min-h-0 flex-1 space-y-2 overflow-y-auto rounded-xl border bg-[var(--surface-primary)] p-3 transition xl:flex-none xl:overflow-visible ${columnBorderClass}`}
className={`tasks-column-scroll mt-2 min-h-0 flex-1 space-y-2 overflow-y-auto rounded-xl border bg-[var(--surface-primary)] p-3 transition xl:mt-0 xl:flex-none xl:overflow-visible ${columnBorderClass}`}
>
<TaskColumnCards
tasks={tasks}
@ -1209,14 +1211,16 @@ function BoardSubcolumnHeader({
<section
ref={setHeaderNodeRef}
style={{
transform: sortable ? CSS.Transform.toString(transform) : undefined,
transition: sortable ? transition : undefined,
transform: sortable && synchronizedColumnDrag && transform
? CSS.Transform.toString({ ...transform, y: 0 })
: undefined,
transition: sortable && synchronizedColumnDrag ? transition : undefined,
zIndex: isDragging ? 20 : undefined,
}}
className={`flex w-full min-w-0 flex-col transition ${isDragging ? "opacity-70" : ""}`}
>
<div
className={`flex min-h-16 items-center justify-between gap-3 rounded-xl border bg-[var(--surface-primary)] px-3 py-3 shadow-sm transition ${columnBorderClass} ${
className={`tasks-board-subcolumn-header flex min-h-16 items-center justify-between gap-3 rounded-xl border bg-[var(--surface-primary)] px-3 py-3 shadow-sm transition ${columnBorderClass} ${
isDragging ? "shadow-xl" : ""
}`}
>
@ -1363,6 +1367,8 @@ function BoardColumnGroup({
onColumnMount,
contentVisible,
onToggleContentVisibility,
subcolumnHeaders,
subcolumnGridTemplate,
hideOnMobile = false,
}: {
column: TaskColumn;
@ -1379,6 +1385,8 @@ function BoardColumnGroup({
onColumnMount: (columnId: number, node: HTMLElement | null) => void;
contentVisible: boolean;
onToggleContentVisibility: () => void;
subcolumnHeaders: ReactNode;
subcolumnGridTemplate: string;
hideOnMobile?: boolean;
}) {
const {
@ -1408,14 +1416,15 @@ function BoardColumnGroup({
}}
className={`${hideOnMobile ? "hidden xl:flex" : "flex"} tasks-board-column-group min-w-0 flex-col ${isDragging ? "opacity-70" : ""}`}
>
<div className="tasks-board-column-header app-surface flex min-h-16 items-center justify-between gap-3 rounded-xl px-3 py-3">
<div
ref={setActivatorNodeRef}
className="min-w-0 flex-1 cursor-grab touch-none active:cursor-grabbing"
title="Перетащите, чтобы изменить порядок колонок"
{...attributes}
{...listeners}
>
<div className="tasks-board-group-header-sticky">
<div className="tasks-board-column-header app-surface flex min-h-16 items-center justify-between gap-3 rounded-xl px-3 py-3">
<div
ref={setActivatorNodeRef}
className="min-w-0 flex-1 cursor-grab touch-none active:cursor-grabbing"
title="Перетащите, чтобы изменить порядок колонок"
{...attributes}
{...listeners}
>
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
@ -1514,10 +1523,21 @@ function BoardColumnGroup({
</div>
) : null}
</div>
</div>
</div>
{contentVisible ? (
<div className="tasks-board-subcolumn-header-surface relative mt-2 rounded-t-xl border border-b-0 border-[var(--border-subtle)] bg-[var(--surface-secondary)] p-2">
<div
className="tasks-board-subcolumn-header-row flex min-w-0 flex-col gap-2 xl:grid xl:w-max"
style={{ gridTemplateColumns: subcolumnGridTemplate }}
>
{subcolumnHeaders}
</div>
</div>
) : null}
</div>
{contentVisible ? (
<div className="mt-2 flex w-full min-w-0 flex-col gap-2 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-secondary)] p-2 xl:w-auto xl:min-w-max">
<div className="tasks-board-column-content flex w-full min-w-0 flex-col gap-2 rounded-b-xl border border-t-0 border-[var(--border-subtle)] bg-[var(--surface-secondary)] px-2 pb-2 xl:w-auto xl:min-w-max">
{children}
</div>
) : null}
@ -1550,7 +1570,7 @@ function BoardColumnRowView({
<section className={`tasks-board-content-section min-w-0 ${
hasFollowingSection ? "tasks-board-content-section-continuous" : ""
}`}>
<div className={`tasks-board-lane-header flex items-center justify-between gap-3 px-1 ${expanded ? "mb-2" : ""}`}>
<div className={`tasks-board-lane-header flex items-center justify-between gap-3 px-1 ${expanded ? "mb-2 xl:mb-0 xl:pb-2" : ""}`}>
<button
type="button"
onClick={onToggle}
@ -1647,7 +1667,7 @@ function BoardBaseColumnsView({
<button
type="button"
onClick={onToggle}
className={`tasks-board-lane-header flex w-full items-center gap-2 rounded-lg px-1 py-1 text-left ${headers ? "mt-2" : ""} ${expanded ? "mb-2" : ""}`}
className={`tasks-board-lane-header flex w-full items-center gap-2 rounded-lg px-1 py-1 text-left ${headers ? "mt-2" : ""} ${expanded ? "mb-2 xl:mb-0 xl:pb-2" : ""}`}
aria-expanded={expanded}
>
<ChevronRight
@ -3405,14 +3425,26 @@ function TasksPageContent() {
const container = boardScrollRef.current;
if (!container || loading) return undefined;
const updateViewportHeight = () => {
const updateBoardViewport = () => {
setBoardViewportHeight(container.clientHeight);
container.querySelectorAll<HTMLElement>(".tasks-board-column-group").forEach((group) => {
const content = group.querySelector<HTMLElement>(".tasks-board-column-content");
const stickyHeader = group.querySelector<HTMLElement>(".tasks-board-group-header-sticky");
const contentBottomReached = content && stickyHeader
? content.getBoundingClientRect().bottom <= stickyHeader.getBoundingClientRect().bottom + 1
: false;
group.dataset.contentBottomReached = contentBottomReached ? "true" : "false";
});
};
updateViewportHeight();
updateBoardViewport();
const observer = new ResizeObserver(updateViewportHeight);
container.addEventListener("scroll", updateBoardViewport, { passive: true });
const observer = new ResizeObserver(updateBoardViewport);
observer.observe(container);
return () => observer.disconnect();
return () => {
container.removeEventListener("scroll", updateBoardViewport);
observer.disconnect();
};
}, [board?.id, desktopWideMode, loading]);
useEffect(() => {
@ -6954,29 +6986,7 @@ function TasksPageContent() {
);
}
return (
<BoardColumnGroup
key={column.id}
column={column}
tasksCount={columnCounts.get(column.id) || 0}
onCreateTask={() => {
if (subcolumns[0]) startQuickTask(subcolumns[0].id);
else startQuickTask(column.id);
}}
onCreateSubcolumn={() => openCreateColumn(column.id)}
onCreateRow={() => openCreateColumnRow(column.id)}
onEditColumn={openEditColumn}
onDeleteColumn={deleteColumn}
columnMenuOpen={columnMenuId === column.id}
columnMenuRef={columnMenuRef}
onToggleColumnMenu={() => toggleColumnMenu(column.id)}
onColumnMount={registerColumnNode}
contentVisible={!boardViewState.hidden_column_ids.includes(column.id)}
onToggleContentVisibility={() => toggleBoardViewState("column", column.id)}
hideOnMobile={hideOnMobile}
>
{(() => {
const cellColumns = subcolumns.length ? subcolumns : [column];
const cellColumns = subcolumns.length ? subcolumns : [column];
const activePreviewIndex = cellColumns.findIndex(
(item) => item.id === activeColumnDragId,
);
@ -7068,41 +7078,55 @@ function TasksPageContent() {
) : renderSubcolumnHeader(column, false);
return (
<div className="flex min-w-0 flex-col gap-2 xl:w-max">
<div
className="tasks-board-subcolumn-header-row flex min-w-0 flex-col gap-2 xl:grid xl:w-max"
style={{ gridTemplateColumns }}
>
{subcolumnHeaders}
</div>
<BoardColumnGroup
key={column.id}
column={column}
tasksCount={columnCounts.get(column.id) || 0}
onCreateTask={() => {
if (subcolumns[0]) startQuickTask(subcolumns[0].id);
else startQuickTask(column.id);
}}
onCreateSubcolumn={() => openCreateColumn(column.id)}
onCreateRow={() => openCreateColumnRow(column.id)}
onEditColumn={openEditColumn}
onDeleteColumn={deleteColumn}
columnMenuOpen={columnMenuId === column.id}
columnMenuRef={columnMenuRef}
onToggleColumnMenu={() => toggleColumnMenu(column.id)}
onColumnMount={registerColumnNode}
contentVisible={!boardViewState.hidden_column_ids.includes(column.id)}
onToggleContentVisibility={() => toggleBoardViewState("column", column.id)}
subcolumnHeaders={subcolumnHeaders}
subcolumnGridTemplate={gridTemplateColumns}
hideOnMobile={hideOnMobile}
>
{baseColumns}
<div className="flex min-w-0 flex-col gap-2">
{columnRows.map((row, rowIndex) => {
const rowTasksCount = filteredTasks.filter((task) => task.row === row.id).length;
return (
<BoardColumnRowView
key={row.id}
row={{ ...row, tasks_count: rowTasksCount }}
gridTemplateColumns={gridTemplateColumns}
onCreateTask={() => {
if (cellColumns[0]) startQuickTask(cellColumns[0].id, row.id);
}}
onEdit={() => openEditColumnRow(row)}
onDelete={() => void deleteColumnRow(row)}
expanded={!boardViewState.collapsed_row_ids.includes(row.id)}
onToggle={() => toggleBoardViewState("row", row.id)}
hasFollowingSection={rowIndex < columnRows.length - 1}
>
{previewCellColumns.map((item) => renderTaskCell(item, row.id))}
</BoardColumnRowView>
);
})}
</div>
</div>
{columnRows.length > 0 ? (
<div className="flex min-w-0 flex-col gap-2">
{columnRows.map((row, rowIndex) => {
const rowTasksCount = filteredTasks.filter((task) => task.row === row.id).length;
return (
<BoardColumnRowView
key={row.id}
row={{ ...row, tasks_count: rowTasksCount }}
gridTemplateColumns={gridTemplateColumns}
onCreateTask={() => {
if (cellColumns[0]) startQuickTask(cellColumns[0].id, row.id);
}}
onEdit={() => openEditColumnRow(row)}
onDelete={() => void deleteColumnRow(row)}
expanded={!boardViewState.collapsed_row_ids.includes(row.id)}
onToggle={() => toggleBoardViewState("row", row.id)}
hasFollowingSection={rowIndex < columnRows.length - 1}
>
{previewCellColumns.map((item) => renderTaskCell(item, row.id))}
</BoardColumnRowView>
);
})}
</div>
) : null}
</BoardColumnGroup>
);
})()}
</BoardColumnGroup>
);
})}
<AddColumnCard
onClick={() => openCreateColumn()}