@@ -166,7 +138,6 @@ export function Modal({
size="medium"
type="button"
onClick={() => handleCloseModal(true)}
- onKeyUp={(event) => handleCloseModal(true, event)}
aria-label={confirmLabelValue}
>
{confirmLabelValue}
@@ -176,7 +147,6 @@ export function Modal({
size="medium"
type="button"
onClick={() => handleCloseModal(false)}
- onKeyUp={(event) => handleCloseModal(false, event)}
aria-label={cancelLabelValue}
>
{cancelLabelValue}
diff --git a/packages/pxweb2/public/locales/ar/translation.json b/packages/pxweb2/public/locales/ar/translation.json
index 0e70037cc..02135469c 100644
--- a/packages/pxweb2/public/locales/ar/translation.json
+++ b/packages/pxweb2/public/locales/ar/translation.json
@@ -143,7 +143,15 @@
},
"rearrange": {
"title": "إعادة ترتيب الجدول",
- "description": "نص الوصف..."
+ "description": "نص الوصف...",
+ "rearrange_modal": {
+ "cancel_button": "يلغي",
+ "confirm_button": "يحفظ",
+ "description": "اسحب وأفلت المتغيرات لإعادة ترتيب تخطيط الجدول",
+ "title": "إعادة ترتيب الجدول",
+ "stub_variable_header": "متغيرات الستوب",
+ "heading_variable_header": "متغيرات العنوان"
+ }
},
"change_order": {
"title": "تغيير الترتيب",
diff --git a/packages/pxweb2/public/locales/en/translation.json b/packages/pxweb2/public/locales/en/translation.json
index 1b41dbf32..e192fa082 100644
--- a/packages/pxweb2/public/locales/en/translation.json
+++ b/packages/pxweb2/public/locales/en/translation.json
@@ -200,7 +200,15 @@
},
"rearrange": {
"title": "Rearrange table",
- "description": "Description text..."
+ "description": "Description text...",
+ "rearrange_modal": {
+ "cancel_button": "Cancel",
+ "confirm_button": "Confirm",
+ "description": "Drag and drop the variables to rearrange the table layout",
+ "title": "Rearrange table",
+ "stub_variable_header": "Stub variables",
+ "heading_variable_header": "Heading variables"
+ }
},
"change_order": {
"title": "Change order",
diff --git a/packages/pxweb2/public/locales/no/translation.json b/packages/pxweb2/public/locales/no/translation.json
index ca38dc446..850a9f92f 100644
--- a/packages/pxweb2/public/locales/no/translation.json
+++ b/packages/pxweb2/public/locales/no/translation.json
@@ -200,7 +200,15 @@
},
"rearrange": {
"title": "Omorganiser tabell",
- "description": "Beskrivelsestekst..."
+ "description": "Beskrivelsestekst...",
+ "rearrange_modal": {
+ "cancel_button": "Avbryt",
+ "confirm_button": "Bekreft",
+ "description": "Dra og slipp variablene for å endre tabelloppsettet",
+ "title": "Tilpass tabell",
+ "stub_variable_header": "Rader",
+ "heading_variable_header": "Kolonner"
+ }
},
"change_order": {
"title": "Endre rekkefølge",
diff --git a/packages/pxweb2/public/locales/sv/translation.json b/packages/pxweb2/public/locales/sv/translation.json
index 539303aea..2337bbc80 100644
--- a/packages/pxweb2/public/locales/sv/translation.json
+++ b/packages/pxweb2/public/locales/sv/translation.json
@@ -200,7 +200,15 @@
},
"rearrange": {
"title": "Anpassa tabell",
- "description": "Beskrivande text..."
+ "description": "Beskrivande text...",
+ "rearrange_modal": {
+ "cancel_button": "Avbryt",
+ "confirm_button": "Bekräfta",
+ "description": "Dra och släpp variablerna för att ändra tabellens layout",
+ "title": "Anpassa tabell",
+ "stub_variable_header": "Rader",
+ "heading_variable_header": "Kolumner"
+ }
},
"change_order": {
"title": "Ändra ordning",
diff --git a/packages/pxweb2/src/@types/resources.d.ts b/packages/pxweb2/src/@types/resources.d.ts
index 2976812fe..ddcc8c2d2 100644
--- a/packages/pxweb2/src/@types/resources.d.ts
+++ b/packages/pxweb2/src/@types/resources.d.ts
@@ -211,6 +211,14 @@ interface Resources {
rearrange: {
description: 'Description text...';
title: 'Rearrange table';
+ rearrange_modal: {
+ cancel_button: 'Cancel';
+ confirm_button: 'Confirm';
+ description: 'Drag and drop the variables to rearrange the table layout';
+ title: 'Rearrange table';
+ stub_variable_header: 'Stub variables';
+ heading_variable_header: 'Header variables';
+ };
};
title: 'Customise';
};
diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss
new file mode 100644
index 000000000..fe2dfff65
--- /dev/null
+++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss
@@ -0,0 +1,93 @@
+.wrapper {
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ width: 100%;
+ gap: 12px;
+}
+
+.groupColumn {
+ flex: 1 1 0;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.groupZone {
+ position: relative;
+ width: 100%;
+ border: 1px solid var(--px-color-border-subtle);
+ border-radius: var(--px-border-radius-medium);
+ padding: 8px;
+ min-height: 220px;
+}
+
+.list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ position: relative;
+ z-index: 1;
+}
+
+.dropPlaceholder {
+ position: relative;
+ z-index: 2;
+ width: 100%;
+ height: var(--drop-preview-height, 40px);
+ border: 1px dashed var(--px-color-border-focus-outline);
+ border-radius: var(--px-border-radius-small);
+ padding: 8px 10px;
+ display: flex;
+ align-items: center;
+ background: color-mix(
+ in srgb,
+ var(--px-color-background-subtle) 88%,
+ transparent
+ );
+ pointer-events: none;
+}
+
+.dropPlaceholderLabel {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.listItem {
+ position: relative;
+ z-index: 1;
+ border: 1px solid var(--px-color-border-subtle);
+ border-radius: var(--px-border-radius-small);
+ background: var(--px-color-surface-default);
+ padding: 8px 10px;
+ cursor: grab;
+ -webkit-user-select: none;
+ user-select: none;
+ touch-action: none;
+ transition: box-shadow 120ms ease;
+}
+
+.listItem:active {
+ cursor: grabbing;
+}
+
+.listItem:focus-visible {
+ outline: 2px solid var(--px-color-border-focus-outline);
+ outline-offset: 1px;
+}
+
+.visuallyHidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.spec.tsx b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.spec.tsx
new file mode 100644
index 000000000..b4cd320b1
--- /dev/null
+++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.spec.tsx
@@ -0,0 +1,282 @@
+import { createElement, type ElementType, type ReactNode } from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom/vitest';
+
+import { ManualPivot } from './ManualPivot';
+import type { Variable } from '@pxweb2/pxweb2-ui';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+vi.mock('framer-motion', () => ({
+ Reorder: {
+ Group: ({
+ children,
+ as: Tag = 'div',
+ }: {
+ children: ReactNode;
+ as?: ElementType;
+ }) => createElement(Tag, null, children),
+ Item: ({
+ children,
+ as: Tag = 'div',
+ ...props
+ }: {
+ children: ReactNode;
+ as?: ElementType;
+ [key: string]: unknown;
+ }) => createElement(Tag, props, children),
+ },
+}));
+
+vi.mock('@pxweb2/pxweb2-ui', () => ({
+ Modal: ({
+ isOpen,
+ onClose,
+ heading,
+ children,
+ }: {
+ isOpen: boolean;
+ onClose: () => void;
+ heading: string;
+ children: ReactNode;
+ }) =>
+ isOpen ? (
+
+
{heading}
+
+ {children}
+
+ ) : null,
+ Label: ({ children }: { children: ReactNode }) =>
{children},
+ BodyShort: ({ children }: { children: ReactNode }) =>
{children},
+}));
+
+const createVariable = (id: string, label: string): Variable =>
+ ({ id, label }) as Variable;
+
+describe('ManualPivot', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders modal title, group labels and variables', () => {
+ render(
+
,
+ );
+
+ expect(
+ screen.getByText(
+ 'presentation_page.side_menu.edit.customize.pivot.title',
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'presentation_page.side_menu.edit.customize.rearrange.rearrange_modal.stub_variable_header',
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'presentation_page.side_menu.edit.customize.rearrange.rearrange_modal.heading_variable_header',
+ ),
+ ).toBeInTheDocument();
+
+ expect(screen.getByText('Header 1')).toBeInTheDocument();
+ expect(screen.getByText('Stub 1')).toBeInTheDocument();
+ });
+
+ it('calls onClose with current header/stub lists', async () => {
+ const onClose = vi.fn();
+ render(
+
,
+ );
+
+ screen.getByRole('button', { name: 'close-modal' }).click();
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledWith(
+ [expect.objectContaining({ id: 'h1', label: 'Header 1' })],
+ [expect.objectContaining({ id: 's1', label: 'Stub 1' })],
+ );
+ });
+
+ it('syncs internal lists from props when opened again', () => {
+ const onClose = vi.fn();
+ const { rerender } = render(
+
,
+ );
+
+ rerender(
+
,
+ );
+
+ rerender(
+
,
+ );
+
+ screen.getByRole('button', { name: 'close-modal' }).click();
+
+ expect(onClose).toHaveBeenCalledWith(
+ [expect.objectContaining({ id: 'h2', label: 'Header 2' })],
+ [expect.objectContaining({ id: 's2', label: 'Stub 2' })],
+ );
+ });
+
+ it('supports keyboard move within and across groups before drop', () => {
+ const onClose = vi.fn();
+
+ render(
+
,
+ );
+
+ const header2Item = screen.getByText('Header 2').closest('li');
+ expect(header2Item).not.toBeNull();
+ if (!header2Item) {
+ throw new Error('Expected Header 2 list item to exist');
+ }
+
+ expect(
+ screen.getByText(
+ 'Press Space or Enter to pick up an item. Use arrow keys to move it, then press Enter to drop. Press Escape to cancel.',
+ ),
+ ).toBeInTheDocument();
+ expect(header2Item).toHaveAttribute('aria-grabbed', 'false');
+
+ fireEvent.keyDown(header2Item, { key: 'Enter' });
+
+ expect(header2Item).toHaveAttribute('aria-grabbed', 'true');
+ const liveRegion = document.querySelector('[aria-live="polite"]');
+ expect(liveRegion).not.toBeNull();
+ if (!liveRegion) {
+ throw new Error('Expected live region to exist');
+ }
+ expect(liveRegion).toHaveTextContent(
+ 'Header 2 selected. Use arrow keys to move, Enter to drop, Escape to cancel.',
+ );
+
+ fireEvent.keyDown(header2Item, { key: 'ArrowUp' });
+ expect(liveRegion).toHaveTextContent(
+ 'Header 2 moved to position 1 in presentation_page.side_menu.edit.customize.rearrange.rearrange_modal.heading_variable_header.',
+ );
+
+ fireEvent.keyDown(header2Item, { key: 'ArrowLeft' });
+ expect(liveRegion).toHaveTextContent(
+ 'Header 2 moved to position 1 in presentation_page.side_menu.edit.customize.rearrange.rearrange_modal.stub_variable_header.',
+ );
+
+ const movedHeader2Item = screen.getByText('Header 2').closest('li');
+ expect(movedHeader2Item).not.toBeNull();
+ if (!movedHeader2Item) {
+ throw new Error('Expected moved Header 2 list item to exist');
+ }
+
+ expect(movedHeader2Item).toHaveAttribute('aria-grabbed', 'true');
+ fireEvent.keyDown(movedHeader2Item, { key: 'Enter' });
+ expect(movedHeader2Item).toHaveAttribute('aria-grabbed', 'false');
+ expect(liveRegion).toHaveTextContent(
+ 'Header 2 dropped in presentation_page.side_menu.edit.customize.rearrange.rearrange_modal.stub_variable_header.',
+ );
+
+ screen.getByRole('button', { name: 'close-modal' }).click();
+
+ expect(onClose).toHaveBeenCalledWith(
+ [expect.objectContaining({ id: 'h1', label: 'Header 1' })],
+ [
+ expect.objectContaining({ id: 'h2', label: 'Header 2' }),
+ expect.objectContaining({ id: 's1', label: 'Stub 1' }),
+ ],
+ );
+ });
+
+ it('restores original lists when keyboard move is cancelled with Escape', () => {
+ const onClose = vi.fn();
+
+ render(
+
,
+ );
+
+ const header1Item = screen.getByText('Header 1').closest('li');
+ expect(header1Item).not.toBeNull();
+ if (!header1Item) {
+ throw new Error('Expected Header 1 list item to exist');
+ }
+
+ const liveRegion = document.querySelector('[aria-live="polite"]');
+ expect(liveRegion).not.toBeNull();
+ if (!liveRegion) {
+ throw new Error('Expected live region to exist');
+ }
+
+ fireEvent.keyDown(header1Item, { key: 'Enter' });
+ expect(header1Item).toHaveAttribute('aria-grabbed', 'true');
+
+ fireEvent.keyDown(header1Item, { key: 'ArrowLeft' });
+ expect(liveRegion).toHaveTextContent(
+ 'Header 1 moved to position 1 in presentation_page.side_menu.edit.customize.rearrange.rearrange_modal.stub_variable_header.',
+ );
+
+ const movedHeader1Item = screen.getByText('Header 1').closest('li');
+ expect(movedHeader1Item).not.toBeNull();
+ if (!movedHeader1Item) {
+ throw new Error('Expected moved Header 1 list item to exist');
+ }
+
+ fireEvent.keyDown(movedHeader1Item, { key: 'Escape' });
+ const restoredHeader1Item = screen.getByText('Header 1').closest('li');
+ expect(restoredHeader1Item).not.toBeNull();
+ if (!restoredHeader1Item) {
+ throw new Error('Expected restored Header 1 list item to exist');
+ }
+ expect(restoredHeader1Item).toHaveAttribute('aria-grabbed', 'false');
+ expect(liveRegion).toHaveTextContent('Header 1 move cancelled.');
+
+ screen.getByRole('button', { name: 'close-modal' }).click();
+
+ expect(onClose).toHaveBeenCalledWith(
+ [expect.objectContaining({ id: 'h1', label: 'Header 1' })],
+ [expect.objectContaining({ id: 's1', label: 'Stub 1' })],
+ );
+ });
+});
diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx
new file mode 100644
index 000000000..d86f40d1f
--- /dev/null
+++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx
@@ -0,0 +1,785 @@
+import { Fragment, useEffect, useId, useRef, useState } from 'react';
+import { Reorder, type PanInfo } from 'framer-motion';
+import { useTranslation } from 'react-i18next';
+
+import { Modal, Variable, BodyShort, Label } from '@pxweb2/pxweb2-ui';
+import classes from './ManualPivot.module.scss';
+
+type VariableGroup = 'header' | 'stub';
+type DropPreview = {
+ group: VariableGroup;
+ index: number;
+ height: number;
+} | null;
+type GroupLabelKey =
+ | 'presentation_page.side_menu.edit.customize.rearrange.rearrange_modal.stub_variable_header'
+ | 'presentation_page.side_menu.edit.customize.rearrange.rearrange_modal.heading_variable_header';
+type KeyboardDragSnapshot = {
+ headerItems: Variable[];
+ stubItems: Variable[];
+};
+
+interface ManualPivotProps {
+ readonly isOpen: boolean;
+ readonly onClose: (headerItems: Variable[], stubItems: Variable[]) => void;
+ readonly headerVariables: Variable[];
+ readonly stubVariables: Variable[];
+}
+
+export function ManualPivot({
+ isOpen,
+ onClose,
+ headerVariables,
+ stubVariables,
+}: ManualPivotProps) {
+ const { t } = useTranslation();
+ const keyboardInstructionsId = useId();
+ const [headerItems, setHeaderItems] = useState
(headerVariables);
+ const [stubItems, setStubItems] = useState(stubVariables);
+ const [keyboardDraggedItemId, setKeyboardDraggedItemId] = useState<
+ string | null
+ >(null);
+ const [liveAnnouncement, setLiveAnnouncement] = useState('');
+ const headerItemsRef = useRef(headerVariables);
+ const stubItemsRef = useRef(stubVariables);
+ const headerZoneRef = useRef(null);
+ const stubZoneRef = useRef(null);
+ const itemRefs = useRef(new Map());
+ const pendingFocusItemIdRef = useRef(null);
+ const keyboardDragSnapshotRef = useRef(null);
+ const draggedItemIdRef = useRef(null);
+ const dragSourceGroupRef = useRef(null);
+ const hoveredGroupRef = useRef(null);
+ const isDraggingRef = useRef(false);
+ const lastPointerYRef = useRef(null);
+ const dropPreviewRef = useRef(null);
+ const [dropPreview, setDropPreview] = useState(null);
+
+ useEffect(() => {
+ if (isOpen) {
+ setHeaderItems(headerVariables);
+ setStubItems(stubVariables);
+ headerItemsRef.current = headerVariables;
+ stubItemsRef.current = stubVariables;
+ setKeyboardDraggedItemId(null);
+ setLiveAnnouncement('');
+ keyboardDragSnapshotRef.current = null;
+ }
+ }, [headerVariables, isOpen, stubVariables]);
+
+ useEffect(() => {
+ const pendingItemId = pendingFocusItemIdRef.current;
+
+ if (!pendingItemId) {
+ return;
+ }
+
+ const animationFrame = requestAnimationFrame(() => {
+ itemRefs.current.get(pendingItemId)?.focus();
+ });
+
+ pendingFocusItemIdRef.current = null;
+
+ return () => {
+ cancelAnimationFrame(animationFrame);
+ };
+ }, [headerItems, stubItems]);
+
+ useEffect(() => {
+ headerZoneRef.current?.style.removeProperty('--drop-preview-height');
+ stubZoneRef.current?.style.removeProperty('--drop-preview-height');
+
+ if (!dropPreview) {
+ return;
+ }
+
+ const zoneRef =
+ dropPreview.group === 'header' ? headerZoneRef : stubZoneRef;
+ zoneRef.current?.style.setProperty(
+ '--drop-preview-height',
+ `${dropPreview.height}px`,
+ );
+ }, [dropPreview]);
+
+ const commitLists = (
+ nextHeaderItems: Variable[],
+ nextStubItems: Variable[],
+ ) => {
+ headerItemsRef.current = nextHeaderItems;
+ stubItemsRef.current = nextStubItems;
+ setHeaderItems(nextHeaderItems);
+ setStubItems(nextStubItems);
+ };
+
+ const dedupeById = (items: Variable[]): Variable[] => {
+ const seen = new Set();
+ return items.filter((item) => {
+ if (seen.has(item.id)) {
+ return false;
+ }
+ seen.add(item.id);
+ return true;
+ });
+ };
+
+ const capitalizeLabel = (label: string): string =>
+ label.charAt(0).toUpperCase() + label.slice(1);
+
+ const getGroupAtPoint = (x: number, y: number): VariableGroup | null => {
+ const hitPadding = 20;
+ const distanceToRect = (rect: DOMRect): number => {
+ const dx = Math.max(rect.left - x, 0, x - rect.right);
+ const dy = Math.max(rect.top - y, 0, y - rect.bottom);
+ return Math.sqrt(dx * dx + dy * dy);
+ };
+
+ const headerRect = headerZoneRef.current?.getBoundingClientRect();
+ const stubRect = stubZoneRef.current?.getBoundingClientRect();
+
+ if (!headerRect && !stubRect) {
+ return null;
+ }
+
+ const headerDistance = headerRect
+ ? distanceToRect(headerRect)
+ : Number.POSITIVE_INFINITY;
+ const stubDistance = stubRect
+ ? distanceToRect(stubRect)
+ : Number.POSITIVE_INFINITY;
+
+ const nearestGroup = headerDistance <= stubDistance ? 'header' : 'stub';
+ const nearestDistance = Math.min(headerDistance, stubDistance);
+
+ if (nearestDistance <= hitPadding) {
+ return nearestGroup;
+ }
+
+ return null;
+ };
+
+ const getInsertIndexForGroup = (
+ group: VariableGroup,
+ pointerY: number,
+ draggedItemId: string,
+ ): number => {
+ const zoneRef = group === 'header' ? headerZoneRef : stubZoneRef;
+ const itemElements = Array.from(
+ zoneRef.current?.querySelectorAll('[data-variable-id]') ??
+ [],
+ ).filter((element) => element.dataset.variableId !== draggedItemId);
+
+ if (itemElements.length === 0) {
+ return 0;
+ }
+
+ const index = itemElements.findIndex((element) => {
+ const rect = element.getBoundingClientRect();
+ const midpoint = rect.top + rect.height / 2;
+ return pointerY < midpoint;
+ });
+
+ return index === -1 ? itemElements.length : index;
+ };
+
+ const getDropPreviewForGroup = (
+ group: VariableGroup,
+ pointerY: number,
+ draggedItemId: string,
+ ): DropPreview => {
+ const zoneRef = group === 'header' ? headerZoneRef : stubZoneRef;
+ const zoneElement = zoneRef.current;
+
+ if (!zoneElement) {
+ return null;
+ }
+
+ const itemElements = Array.from(
+ zoneElement.querySelectorAll('[data-variable-id]'),
+ ).filter((element) => element.dataset.variableId !== draggedItemId);
+
+ const index = getInsertIndexForGroup(group, pointerY, draggedItemId);
+ const defaultItemHeight = 40;
+
+ if (itemElements.length === 0) {
+ return { group, index: 0, height: defaultItemHeight };
+ }
+
+ const clampedIndex = Math.min(Math.max(0, index), itemElements.length);
+ const referenceElement =
+ clampedIndex >= itemElements.length
+ ? itemElements[itemElements.length - 1]
+ : itemElements[clampedIndex];
+ const referenceHeight = Math.max(
+ defaultItemHeight,
+ Math.round(referenceElement.getBoundingClientRect().height),
+ );
+
+ return {
+ group,
+ index: clampedIndex,
+ height: referenceHeight,
+ };
+ };
+
+ const updateDropPreview = (nextPreview: DropPreview) => {
+ dropPreviewRef.current = nextPreview;
+ setDropPreview(nextPreview);
+ };
+
+ const moveDraggedItemToGroup = (
+ targetGroup: VariableGroup,
+ targetIndex: number,
+ ) => {
+ const draggedItemId = draggedItemIdRef.current;
+ const sourceGroup = dragSourceGroupRef.current;
+
+ if (!draggedItemId || !sourceGroup) {
+ return;
+ }
+
+ if (sourceGroup === targetGroup) {
+ return;
+ }
+
+ const sourceItems =
+ sourceGroup === 'header' ? headerItemsRef.current : stubItemsRef.current;
+ const targetItems =
+ targetGroup === 'header' ? headerItemsRef.current : stubItemsRef.current;
+ const movingItem = sourceItems.find((item) => item.id === draggedItemId);
+
+ if (!movingItem) {
+ return;
+ }
+
+ const nextSourceItems = sourceItems.filter(
+ (item) => item.id !== draggedItemId,
+ );
+ const nextTargetItems = targetItems.filter(
+ (item) => item.id !== draggedItemId,
+ );
+ const clampedInsertIndex = Math.min(
+ Math.max(0, targetIndex),
+ nextTargetItems.length,
+ );
+ nextTargetItems.splice(clampedInsertIndex, 0, movingItem);
+
+ if (sourceGroup === 'header') {
+ commitLists(nextSourceItems, nextTargetItems);
+ } else {
+ commitLists(nextTargetItems, nextSourceItems);
+ }
+
+ dragSourceGroupRef.current = targetGroup;
+ };
+
+ const getItemsForGroup = (group: VariableGroup): Variable[] =>
+ group === 'header' ? headerItemsRef.current : stubItemsRef.current;
+
+ const getItemById = (itemId: string): Variable | undefined =>
+ [...headerItemsRef.current, ...stubItemsRef.current].find(
+ (item) => item.id === itemId,
+ );
+
+ const getGroupLabel = (group: VariableGroup): string =>
+ group === 'stub'
+ ? t(
+ 'presentation_page.side_menu.edit.customize.rearrange.rearrange_modal.stub_variable_header',
+ )
+ : t(
+ 'presentation_page.side_menu.edit.customize.rearrange.rearrange_modal.heading_variable_header',
+ );
+
+ const announceKeyboardMove = (itemId: string, group: VariableGroup) => {
+ const groupItems = getItemsForGroup(group);
+ const itemIndex = groupItems.findIndex((item) => item.id === itemId);
+ const itemLabel = getItemById(itemId)?.label;
+ const capitalizedItemLabel = itemLabel ? capitalizeLabel(itemLabel) : '';
+
+ if (capitalizedItemLabel && itemIndex !== -1) {
+ setLiveAnnouncement(
+ `${capitalizedItemLabel} moved to position ${itemIndex + 1} in ${getGroupLabel(group)}.`,
+ );
+ }
+ };
+
+ const startKeyboardDrag = (group: VariableGroup, variableId: string) => {
+ isDraggingRef.current = true;
+ draggedItemIdRef.current = variableId;
+ dragSourceGroupRef.current = group;
+ hoveredGroupRef.current = group;
+ keyboardDragSnapshotRef.current = {
+ headerItems: [...headerItemsRef.current],
+ stubItems: [...stubItemsRef.current],
+ };
+ setKeyboardDraggedItemId(variableId);
+
+ const itemLabel = getItemById(variableId)?.label;
+ if (itemLabel) {
+ const capitalizedItemLabel = capitalizeLabel(itemLabel);
+ setLiveAnnouncement(
+ `${capitalizedItemLabel} selected. Use arrow keys to move, Enter to drop, Escape to cancel.`,
+ );
+ }
+ };
+
+ const moveKeyboardDraggedItemWithinGroup = (direction: -1 | 1): boolean => {
+ const draggedItemId = draggedItemIdRef.current;
+ const sourceGroup = dragSourceGroupRef.current;
+
+ if (!draggedItemId || !sourceGroup) {
+ return false;
+ }
+
+ const sourceItems = getItemsForGroup(sourceGroup);
+ const sourceIndex = sourceItems.findIndex(
+ (item) => item.id === draggedItemId,
+ );
+
+ if (sourceIndex === -1) {
+ return false;
+ }
+
+ const targetIndex = Math.min(
+ Math.max(0, sourceIndex + direction),
+ sourceItems.length - 1,
+ );
+
+ if (sourceIndex === targetIndex) {
+ return false;
+ }
+
+ const nextSourceItems = [...sourceItems];
+ const [movingItem] = nextSourceItems.splice(sourceIndex, 1);
+ nextSourceItems.splice(targetIndex, 0, movingItem);
+
+ if (sourceGroup === 'header') {
+ commitLists(nextSourceItems, stubItemsRef.current);
+ } else {
+ commitLists(headerItemsRef.current, nextSourceItems);
+ }
+
+ pendingFocusItemIdRef.current = draggedItemId;
+ announceKeyboardMove(draggedItemId, sourceGroup);
+
+ return true;
+ };
+
+ const moveKeyboardDraggedItemAcrossGroups = (
+ targetGroup: VariableGroup,
+ ): boolean => {
+ const draggedItemId = draggedItemIdRef.current;
+ const sourceGroup = dragSourceGroupRef.current;
+
+ if (!draggedItemId || !sourceGroup || sourceGroup === targetGroup) {
+ return false;
+ }
+
+ const sourceItems = getItemsForGroup(sourceGroup);
+ const sourceIndex = sourceItems.findIndex(
+ (item) => item.id === draggedItemId,
+ );
+
+ if (sourceIndex === -1) {
+ return false;
+ }
+
+ const targetItems = getItemsForGroup(targetGroup);
+ const targetIndex = Math.min(sourceIndex, targetItems.length);
+
+ moveDraggedItemToGroup(targetGroup, targetIndex);
+ pendingFocusItemIdRef.current = draggedItemId;
+ announceKeyboardMove(draggedItemId, targetGroup);
+
+ return true;
+ };
+
+ const dropKeyboardDrag = () => {
+ const draggedItemId = draggedItemIdRef.current;
+ const sourceGroup = dragSourceGroupRef.current;
+
+ if (draggedItemId && sourceGroup) {
+ const itemLabel = getItemById(draggedItemId)?.label;
+ const groupLabel = getGroupLabel(sourceGroup);
+ if (itemLabel) {
+ setLiveAnnouncement(
+ `${capitalizeLabel(itemLabel)} dropped in ${groupLabel}.`,
+ );
+ }
+ }
+
+ keyboardDragSnapshotRef.current = null;
+ setKeyboardDraggedItemId(null);
+ resetDragState();
+ };
+
+ const cancelKeyboardDrag = () => {
+ const draggedItemId = draggedItemIdRef.current;
+ const snapshot = keyboardDragSnapshotRef.current;
+
+ if (snapshot) {
+ commitLists(snapshot.headerItems, snapshot.stubItems);
+ }
+
+ if (draggedItemId) {
+ const itemLabel = getItemById(draggedItemId)?.label;
+ if (itemLabel) {
+ setLiveAnnouncement(`${capitalizeLabel(itemLabel)} move cancelled.`);
+ }
+ pendingFocusItemIdRef.current = draggedItemId;
+ }
+
+ keyboardDragSnapshotRef.current = null;
+ setKeyboardDraggedItemId(null);
+ resetDragState();
+ };
+
+ const resetDragState = () => {
+ isDraggingRef.current = false;
+ draggedItemIdRef.current = null;
+ dragSourceGroupRef.current = null;
+ hoveredGroupRef.current = null;
+ lastPointerYRef.current = null;
+ updateDropPreview(null);
+ };
+
+ const getOtherGroup = (group: VariableGroup): VariableGroup =>
+ group === 'header' ? 'stub' : 'header';
+
+ const handleItemKeyDown = (
+ event: React.KeyboardEvent,
+ group: VariableGroup,
+ variableId: string,
+ ) => {
+ const isKeyboardDragging = keyboardDraggedItemId === variableId;
+
+ switch (event.key) {
+ case 'Enter':
+ case ' ': {
+ event.preventDefault();
+ if (isKeyboardDragging) {
+ dropKeyboardDrag();
+ } else if (!keyboardDraggedItemId) {
+ startKeyboardDrag(group, variableId);
+ }
+ return;
+ }
+ case 'Escape': {
+ if (!isKeyboardDragging) {
+ return;
+ }
+ event.preventDefault();
+ cancelKeyboardDrag();
+ return;
+ }
+ case 'ArrowUp': {
+ if (!isKeyboardDragging) {
+ return;
+ }
+ event.preventDefault();
+ moveKeyboardDraggedItemWithinGroup(-1);
+ return;
+ }
+ case 'ArrowDown': {
+ if (!isKeyboardDragging) {
+ return;
+ }
+ event.preventDefault();
+ moveKeyboardDraggedItemWithinGroup(1);
+ return;
+ }
+ case 'ArrowLeft':
+ case 'ArrowRight': {
+ if (!isKeyboardDragging) {
+ return;
+ }
+ event.preventDefault();
+ moveKeyboardDraggedItemAcrossGroups(getOtherGroup(group));
+ return;
+ }
+ default:
+ return;
+ }
+ };
+
+ const getClientPoint = (
+ event: MouseEvent | TouchEvent | PointerEvent,
+ info: PanInfo,
+ ) => {
+ if ('clientX' in event && 'clientY' in event) {
+ return { x: event.clientX, y: event.clientY };
+ }
+
+ if ('touches' in event && event.touches.length > 0) {
+ return { x: event.touches[0].clientX, y: event.touches[0].clientY };
+ }
+
+ if ('changedTouches' in event && event.changedTouches.length > 0) {
+ return {
+ x: event.changedTouches[0].clientX,
+ y: event.changedTouches[0].clientY,
+ };
+ }
+
+ return { x: info.point.x, y: info.point.y };
+ };
+
+ const handleItemDrag = (
+ event: MouseEvent | TouchEvent | PointerEvent,
+ info: PanInfo,
+ ) => {
+ const point = getClientPoint(event, info);
+ lastPointerYRef.current = point.y;
+ const detectedGroup = getGroupAtPoint(point.x, point.y);
+ if (detectedGroup) {
+ hoveredGroupRef.current = detectedGroup;
+ }
+
+ const hoveredGroup =
+ detectedGroup ?? hoveredGroupRef.current ?? dragSourceGroupRef.current;
+
+ const draggedItemId = draggedItemIdRef.current;
+ if (hoveredGroup && draggedItemId) {
+ updateDropPreview(
+ getDropPreviewForGroup(hoveredGroup, point.y, draggedItemId),
+ );
+ } else {
+ updateDropPreview(null);
+ }
+ };
+
+ const handleItemDragEnd = (
+ event: MouseEvent | TouchEvent | PointerEvent,
+ info: PanInfo,
+ ) => {
+ const point = getClientPoint(event, info);
+ lastPointerYRef.current = point.y;
+ const detectedGroup = getGroupAtPoint(point.x, point.y);
+ if (detectedGroup) {
+ hoveredGroupRef.current = detectedGroup;
+ }
+
+ const hoveredGroup =
+ detectedGroup ?? hoveredGroupRef.current ?? dragSourceGroupRef.current;
+
+ const draggedItemId = draggedItemIdRef.current;
+ if (hoveredGroup && draggedItemId) {
+ updateDropPreview(
+ getDropPreviewForGroup(hoveredGroup, point.y, draggedItemId),
+ );
+ }
+
+ const persistedDropTarget = dropPreviewRef.current;
+ const targetGroup = persistedDropTarget?.group ?? hoveredGroup;
+ const targetIndex = persistedDropTarget?.index;
+
+ if (
+ targetGroup &&
+ typeof targetIndex === 'number' &&
+ targetGroup !== dragSourceGroupRef.current
+ ) {
+ moveDraggedItemToGroup(targetGroup, targetIndex);
+ }
+ resetDragState();
+ };
+
+ const handleDragStart = (group: VariableGroup, variableId: string) => {
+ isDraggingRef.current = true;
+ draggedItemIdRef.current = variableId;
+ dragSourceGroupRef.current = group;
+ hoveredGroupRef.current = group;
+
+ const zoneRect = (
+ group === 'header' ? headerZoneRef.current : stubZoneRef.current
+ )?.getBoundingClientRect();
+ if (zoneRect) {
+ updateDropPreview(
+ getDropPreviewForGroup(group, zoneRect.top, variableId),
+ );
+ }
+ };
+
+ const handleGroupReorder = (group: VariableGroup, nextItems: Variable[]) => {
+ let dedupedItems = dedupeById(nextItems);
+ const draggedItemId = draggedItemIdRef.current;
+ const sourceGroup = dragSourceGroupRef.current;
+
+ if (isDraggingRef.current && draggedItemId && sourceGroup === group) {
+ const hasDraggedItem = dedupedItems.some(
+ (item) => item.id === draggedItemId,
+ );
+
+ if (!hasDraggedItem) {
+ const currentGroupItems =
+ group === 'header' ? headerItemsRef.current : stubItemsRef.current;
+ const draggedItem = currentGroupItems.find(
+ (item) => item.id === draggedItemId,
+ );
+
+ if (draggedItem) {
+ dedupedItems = [...dedupedItems, draggedItem];
+ }
+ }
+ }
+
+ if (group === 'stub') {
+ const nextHeaderItems = headerItemsRef.current.filter(
+ (headerItem) =>
+ !dedupedItems.some((stubItem) => stubItem.id === headerItem.id),
+ );
+ commitLists(nextHeaderItems, dedupedItems);
+ return;
+ }
+
+ const nextStubItems = stubItemsRef.current.filter(
+ (stubItem) =>
+ !dedupedItems.some((headerItem) => headerItem.id === stubItem.id),
+ );
+ commitLists(dedupedItems, nextStubItems);
+ };
+
+ const renderGroup = (
+ group: VariableGroup,
+ labelKey: GroupLabelKey,
+ items: Variable[],
+ zoneRef: React.RefObject,
+ ) => {
+ const preview = dropPreview?.group === group ? dropPreview : null;
+ const previewIndex = preview?.index;
+ const draggedItemLabel = draggedItemIdRef.current
+ ? [...headerItemsRef.current, ...stubItemsRef.current].find(
+ (item) => item.id === draggedItemIdRef.current,
+ )?.label
+ : undefined;
+ const capitalizedDraggedItemLabel = draggedItemLabel
+ ? capitalizeLabel(draggedItemLabel)
+ : undefined;
+
+ return (
+
+
+
+ handleGroupReorder(group, nextItems)}
+ className={classes.list}
+ >
+ {items.map((variable, index) => (
+
+ {previewIndex === index ? (
+
+ {capitalizedDraggedItemLabel ? (
+
+ {capitalizedDraggedItemLabel}
+
+ ) : null}
+
+ ) : null}
+ {
+ if (element) {
+ itemRefs.current.set(variable.id, element);
+ } else {
+ itemRefs.current.delete(variable.id);
+ }
+ }}
+ tabIndex={0}
+ aria-grabbed={keyboardDraggedItemId === variable.id}
+ aria-describedby={keyboardInstructionsId}
+ drag
+ dragMomentum={false}
+ dragElastic={0}
+ whileDrag={{
+ scale: 1.02,
+ zIndex: 2,
+ boxShadow: '0 8px 20px rgba(0, 0, 0, 0.2)',
+ }}
+ onDragStart={() => handleDragStart(group, variable.id)}
+ onDrag={handleItemDrag}
+ onDragEnd={handleItemDragEnd}
+ onKeyDown={(event) =>
+ handleItemKeyDown(event, group, variable.id)
+ }
+ >
+
+ {capitalizeLabel(variable.label)}
+
+
+
+ ))}
+ {previewIndex === items.length ? (
+
+ {capitalizedDraggedItemLabel ? (
+
+ {capitalizedDraggedItemLabel}
+
+ ) : null}
+
+ ) : null}
+
+
+
+ );
+ };
+
+ return (
+ onClose(headerItems, stubItems)}
+ heading={t('presentation_page.side_menu.edit.customize.pivot.title')}
+ label={t('presentation_page.side_menu.edit.title')}
+ cancelLabel={t(
+ 'presentation_page.side_menu.edit.customize.rearrange.rearrange_modal.cancel_button',
+ )}
+ confirmLabel={t(
+ 'presentation_page.side_menu.edit.customize.rearrange.rearrange_modal.confirm_button',
+ )}
+ >
+
+ Press Space or Enter to pick up an item. Use arrow keys to move it, then
+ press Enter to drop. Press Escape to cancel.
+
+
+ {liveAnnouncement}
+
+
+ {renderGroup(
+ 'stub',
+ 'presentation_page.side_menu.edit.customize.rearrange.rearrange_modal.stub_variable_header',
+ stubItems,
+ stubZoneRef,
+ )}
+ {renderGroup(
+ 'header',
+ 'presentation_page.side_menu.edit.customize.rearrange.rearrange_modal.heading_variable_header',
+ headerItems,
+ headerZoneRef,
+ )}
+
+
+ );
+}
+
+export default ManualPivot;
diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.spec.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.spec.tsx
index e21c8e95b..f23b651d3 100644
--- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.spec.tsx
+++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.spec.tsx
@@ -83,7 +83,7 @@ describe('DrawerEdit', () => {
expect(screen.getByTestId('content-box')).toBeInTheDocument();
// Two action buttons: auto pivot & clockwise pivot (unified PivotButton)
const buttons = screen.getAllByTestId('action-item');
- expect(buttons).toHaveLength(2);
+ expect(buttons).toHaveLength(3);
// Check labels via translation keys
expect(
screen.getByText(
diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx
index 95930f4e4..05aeb89ce 100644
--- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx
+++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx
@@ -11,6 +11,7 @@ import useTableData from '../../../context/useTableData';
import classes from './DrawerEdit.module.scss';
import { PivotType } from '../../../context/PivotType';
import useApp from '../../../context/useApp';
+import ManualPivot from '../../ManualPivot/ManualPivot';
interface PivotButtonProps {
readonly stub: Variable[];
@@ -20,6 +21,27 @@ interface PivotButtonProps {
readonly setLoadingPivotType: (type: PivotType | null) => void;
}
+interface PivotManuallyButtonProps {
+ readonly onClick: () => void;
+}
+
+function PivotManuallyButton({ onClick }: PivotManuallyButtonProps) {
+ const { t } = useTranslation();
+ return (
+
+ );
+}
+
function PivotButton({
stub,
heading,
@@ -114,10 +136,13 @@ function PivotButton({
export function DrawerEdit() {
const { t } = useTranslation();
const { isMobile } = useApp();
- const data = useTableData().data;
+ const tableData = useTableData();
+ const data = tableData.data;
+ const { pivotManual } = tableData;
const [loadingPivotType, setLoadingPivotType] = useState(
null,
);
+ const [isManualPivotOpen, setIsManualPivotOpen] = useState(false);
return (
@@ -140,7 +165,24 @@ export function DrawerEdit() {
setLoadingPivotType={setLoadingPivotType}
/>
)}
+ {data && (
+ setIsManualPivotOpen(true)} />
+ )}