diff --git a/packages/pxweb2-ui/src/index.ts b/packages/pxweb2-ui/src/index.ts index 3c8afc3f1..4d64087a0 100644 --- a/packages/pxweb2-ui/src/index.ts +++ b/packages/pxweb2-ui/src/index.ts @@ -22,6 +22,7 @@ export * from './lib/components/Link/Link'; export * from './lib/components/LinkCard/LinkCard'; export * from './lib/components/List'; export * from './lib/components/LocalAlert/LocalAlert'; +export * from './lib/components/Modal/Modal'; export * from './lib/components/Notes/MandatoryNotes'; export * from './lib/components/Notes/MandatoryTableNotes'; export * from './lib/components/Notes/MandatoryVariableNotes'; diff --git a/packages/pxweb2-ui/src/lib/components/Modal/Modal.spec.tsx b/packages/pxweb2-ui/src/lib/components/Modal/Modal.spec.tsx index e59bf7054..57f42d6dc 100644 --- a/packages/pxweb2-ui/src/lib/components/Modal/Modal.spec.tsx +++ b/packages/pxweb2-ui/src/lib/components/Modal/Modal.spec.tsx @@ -144,7 +144,7 @@ describe('Modal', () => { expect(screen.getByText('Test Heading')).toBeDefined(); }); - it('should handle Enter key press on buttons', () => { + it('should not close on button keyup Enter alone', () => { const onCloseMock = vi.fn(); render( @@ -157,7 +157,19 @@ describe('Modal', () => { fireEvent.keyUp(confirmButton, { key: 'Enter' }); - expect(onCloseMock).toHaveBeenCalledWith(true, 'Enter'); + expect(onCloseMock).not.toHaveBeenCalled(); + }); + + it('should focus modal body when opened', () => { + const { container } = render( + + test + , + ); + + const body = container.querySelector('div[class*="body"]'); + + expect(body).toBe(document.activeElement); }); it('should update body overflow style when opening and closing', () => { diff --git a/packages/pxweb2-ui/src/lib/components/Modal/Modal.tsx b/packages/pxweb2-ui/src/lib/components/Modal/Modal.tsx index 56c9bb7c6..fff263819 100644 --- a/packages/pxweb2-ui/src/lib/components/Modal/Modal.tsx +++ b/packages/pxweb2-ui/src/lib/components/Modal/Modal.tsx @@ -1,13 +1,6 @@ import cl from 'clsx'; import { useTranslation } from 'react-i18next'; -import { - useCallback, - useEffect, - useRef, - useState, - KeyboardEvent as ReactKeyboardEvent, - MouseEvent, -} from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import classes from './Modal.module.scss'; import Label from '../Typography/Label/Label'; @@ -39,6 +32,7 @@ export function Modal({ const cssClasses = className.length > 0 ? ' ' + className : ''; const [isModalOpen, setIsModalOpen] = useState(isOpen); const modalRef = useRef(null); + const bodyRef = useRef(null); let cancelLabelValue = cancelLabel; let confirmLabelValue = confirmLabel; @@ -59,6 +53,7 @@ export function Modal({ if (isModalOpen) { modalElement.showModal(); setWindowScroll(false); + bodyRef.current?.focus(); } else { modalElement.close(); setWindowScroll(true); @@ -74,33 +69,15 @@ export function Modal({ }; const handleCloseModal = useCallback( - (updated: boolean, event?: ReactKeyboardEvent | MouseEvent) => { - const handleKeyboardEvent = ( - updated: boolean, - event: ReactKeyboardEvent, - ) => { - const keyPress = event.key; - const isValidKeyPress = - keyPress === 'Enter' || keyPress === ' ' || keyPress === 'Escape'; - - if (onClose && isValidKeyPress) { - setWindowScroll(true); + (updated: boolean, keyPress?: ' ' | 'Enter' | 'Escape') => { + if (onClose) { + setWindowScroll(true); + if (keyPress) { onClose(updated, keyPress); - setIsModalOpen(false); - } - }; - - const handleMouseEvent = (updated: boolean) => { - if (onClose) { - setWindowScroll(true); + } else { onClose(updated); - setIsModalOpen(false); } - }; - if (event) { - handleKeyboardEvent(updated, event as ReactKeyboardEvent); - } else { - handleMouseEvent(updated); + setIsModalOpen(false); } }, [onClose], @@ -110,11 +87,7 @@ export function Modal({ // Handle the Escape key to close the modal const handleKeyDownInModal = (event: KeyboardEvent) => { if (event.key === 'Escape') { - // 'as unknown as ReactKeyboardEvent' is a hack to avoid the type error when passing the event to the function - handleCloseModal(false, event as unknown as ReactKeyboardEvent); - } - if (event.key === 'Enter') { - event.preventDefault(); // Prevent the default behavior of the Enter key on buttons (turns it into a mouse click event) + handleCloseModal(false, 'Escape'); } }; @@ -149,14 +122,13 @@ export function Modal({ icon="XMark" type="button" onClick={() => handleCloseModal(false)} - onKeyUp={(event) => handleCloseModal(false, event)} aria-label={cancelLabelValue} > {/* tabIndex to fix the div being focusable for some reason */} -
+
{children}
@@ -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 ? ( + + ) : 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 ? ( + + ) : 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)} /> + )}
+ {isManualPivotOpen && ( + { + pivotManual( + headerItems.map((item) => item.id), + stubItems.map((item) => item.id), + ); + setIsManualPivotOpen(false); + }} + headerVariables={data?.heading ?? []} + stubVariables={data?.stub ?? []} + /> + )} {t('common.status_messages.drawer_edit')} diff --git a/packages/pxweb2/src/app/components/TableInformation/TableInformation.spec.tsx b/packages/pxweb2/src/app/components/TableInformation/TableInformation.spec.tsx index 0aa41fdd1..c3788cb1c 100644 --- a/packages/pxweb2/src/app/components/TableInformation/TableInformation.spec.tsx +++ b/packages/pxweb2/src/app/components/TableInformation/TableInformation.spec.tsx @@ -173,6 +173,7 @@ describe('TableInformation', () => { pivotToMobile: vi.fn(), pivotToDesktop: vi.fn(), pivot: vi.fn(), + pivotManual: vi.fn(), buildTableTitle: vi.fn().mockReturnValue({ contentText: '', firstTitlePart: '', diff --git a/packages/pxweb2/src/app/context/TableDataProvider.tsx b/packages/pxweb2/src/app/context/TableDataProvider.tsx index 16e7d4139..534b84c34 100644 --- a/packages/pxweb2/src/app/context/TableDataProvider.tsx +++ b/packages/pxweb2/src/app/context/TableDataProvider.tsx @@ -39,6 +39,7 @@ export interface TableDataContextType { pivotToMobile: () => void; pivotToDesktop: () => void; pivot: (type: PivotType) => void; + pivotManual: (heading: string[], stub: string[]) => void; buildTableTitle: () => TableTitlePartsType; isFadingTable: boolean; setIsFadingTable: (value: boolean) => void; @@ -67,6 +68,9 @@ const TableDataContext = createContext({ pivot: () => { // No-op: useTableData hook prevents this from being called }, + pivotManual: () => { + // No-op: useTableData hook prevents this from being called + }, buildTableTitle: () => ({ contentText: '', firstTitlePart: '', @@ -1193,6 +1197,37 @@ const TableDataProvider: React.FC = ({ children }) => { ], ); + /** + * Pivots the table according to explicitly provided heading/stub variable order. + */ + const pivotManual = React.useCallback( + (heading: string[], stub: string[]): void => { + if (data?.heading === undefined || data?.stub === undefined) { + return; + } + + if (stub.length === 0 && heading.length === 0) { + return; + } + + const tmpTable = copyPxTableWithoutData(data); + pivotTable(tmpTable, stub, heading); + + // Reassemble table data + tmpTable.data = data.data; + setData(tmpTable); + + if (isMobileMode) { + setStubMobile(stub); + setHeadingMobile(heading); + } else { + setStubDesktop(stub); + setHeadingDesktop(heading); + } + }, + [data, isMobileMode], + ); + /** * Pivots the table according to the stub- and heading order. */ @@ -1243,6 +1278,7 @@ const TableDataProvider: React.FC = ({ children }) => { pivotToMobile, pivotToDesktop, pivot, + pivotManual, buildTableTitle, isInitialized, isFadingTable, @@ -1255,6 +1291,7 @@ const TableDataProvider: React.FC = ({ children }) => { pivotToMobile, pivotToDesktop, pivot, + pivotManual, buildTableTitle, isInitialized, isFadingTable,