From ca657b744a0d4dbea98319e6a459812ed5e6d498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Thu, 12 Mar 2026 15:28:39 +0100 Subject: [PATCH 01/18] feat: add ManualPivot component and integrate into DrawerEdit --- packages/pxweb2-ui/src/index.ts | 1 + .../ManualPivot/ManualPivot.module.scss | 59 ++++ .../components/ManualPivot/ManualPivot.tsx | 258 ++++++++++++++++++ .../NavigationDrawer/Drawers/DrawerEdit.tsx | 32 +++ 4 files changed, 350 insertions(+) create mode 100644 packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss create mode 100644 packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx 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/src/app/components/ManualPivot/ManualPivot.module.scss b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss new file mode 100644 index 000000000..8aac9be3f --- /dev/null +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss @@ -0,0 +1,59 @@ +.wrapper { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 12px; +} + +.groupColumn { + flex: 1; + min-width: 0; +} + +.groupZone { + border: 1px solid var(--px-color-border-subtle); + border-radius: var(--px-border-radius-medium); + padding: 8px; +} + +.list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 6px; +} + +.listItem { + 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, transform 120ms ease; +} + +.listItem:active { + cursor: grabbing; +} + +.dropZone { + margin-top: 8px; + min-height: 44px; + border: 1px dashed var(--px-color-border-default); + border-radius: var(--px-border-radius-small); + display: flex; + align-items: center; + justify-content: center; + color: var(--px-color-text-subtle); + font-size: 14px; +} + +.dropZoneActive { + border-color: var(--px-color-border-default); + background: var(--px-color-background-subtle); +} 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..bce60c7bc --- /dev/null +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx @@ -0,0 +1,258 @@ +import { useEffect, useRef, useState } from 'react'; +import { Reorder, type PanInfo } from 'framer-motion'; +import { useTranslation } from 'react-i18next'; + +import { Modal, Variable } from '@pxweb2/pxweb2-ui'; +import classes from './ManualPivot.module.scss'; + +interface ManualPivotProps { + readonly isOpen: boolean; + readonly onClose: () => void; + readonly headerVariables: Variable[]; + readonly stubVariables: Variable[]; +} + +export function ManualPivot({ + isOpen, + onClose, + headerVariables, + stubVariables, +}: ManualPivotProps) { + const { t } = useTranslation(); + const [headerItems, setHeaderItems] = useState(headerVariables); + const [stubItems, setStubItems] = useState(stubVariables); + const headerZoneRef = useRef(null); + const stubZoneRef = useRef(null); + const draggedItemIdRef = useRef(null); + const dragSourceGroupRef = useRef<'header' | 'stub' | null>(null); + const hoveredGroupRef = useRef<'header' | 'stub' | null>(null); + const [activeDropGroup, setActiveDropGroup] = useState< + 'header' | 'stub' | null + >(null); + + useEffect(() => { + if (isOpen) { + setHeaderItems(headerVariables); + setStubItems(stubVariables); + } + }, [headerVariables, isOpen, stubVariables]); + + const getGroupAtPoint = (x: number, y: number): 'header' | 'stub' | null => { + const headerRect = headerZoneRef.current?.getBoundingClientRect(); + if ( + headerRect && + x >= headerRect.left && + x <= headerRect.right && + y >= headerRect.top && + y <= headerRect.bottom + ) { + return 'header'; + } + + const stubRect = stubZoneRef.current?.getBoundingClientRect(); + if ( + stubRect && + x >= stubRect.left && + x <= stubRect.right && + y >= stubRect.top && + y <= stubRect.bottom + ) { + return 'stub'; + } + + return null; + }; + + const moveItemBetweenGroups = () => { + const draggedItemId = draggedItemIdRef.current; + const dragSourceGroup = dragSourceGroupRef.current; + const hoveredGroup = hoveredGroupRef.current; + + if (!draggedItemId || !dragSourceGroup || !hoveredGroup) { + return; + } + + if (dragSourceGroup === hoveredGroup) { + return; + } + + const sourceItems = dragSourceGroup === 'header' ? headerItems : stubItems; + const targetItems = hoveredGroup === 'header' ? headerItems : stubItems; + 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), + movingItem, + ]; + + if (dragSourceGroup === 'header') { + setHeaderItems(nextSourceItems); + setStubItems(nextTargetItems); + } else { + setStubItems(nextSourceItems); + setHeaderItems(nextTargetItems); + } + }; + + const resetDragState = () => { + draggedItemIdRef.current = null; + dragSourceGroupRef.current = null; + hoveredGroupRef.current = null; + setActiveDropGroup(null); + }; + + 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); + const hoveredGroup = getGroupAtPoint(point.x, point.y); + hoveredGroupRef.current = hoveredGroup; + setActiveDropGroup(hoveredGroup); + }; + + const handleItemDragEnd = ( + event: MouseEvent | TouchEvent | PointerEvent, + info: PanInfo, + ) => { + const point = getClientPoint(event, info); + const hoveredGroup = getGroupAtPoint(point.x, point.y); + hoveredGroupRef.current = hoveredGroup; + setActiveDropGroup(hoveredGroup); + + moveItemBetweenGroups(); + resetDragState(); + }; + + return ( + onClose()} + // heading={t('presentation_page.main_content.about_table.manual_pivot.heading')} + // label={t('presentation_page.main_content.about_table.manual_pivot.label')} + heading={t('presentation_page.side_menu.edit.customize.pivot.title')} + label={t('presentation_page.side_menu.edit.title')} + > +
+
+

Header variables

+
+ + {headerItems.map((variable) => ( + { + draggedItemIdRef.current = variable.id; + dragSourceGroupRef.current = 'header'; + hoveredGroupRef.current = 'header'; + setActiveDropGroup('header'); + }} + onDrag={handleItemDrag} + onDragEnd={handleItemDragEnd} + > + {variable.label} + + ))} + +
+ Drop here +
+
+
+ +
+

Stub variables

+
+ + {stubItems.map((variable) => ( + { + draggedItemIdRef.current = variable.id; + dragSourceGroupRef.current = 'stub'; + hoveredGroupRef.current = 'stub'; + setActiveDropGroup('stub'); + }} + onDrag={handleItemDrag} + onDragEnd={handleItemDragEnd} + > + {variable.label} + + ))} + +
+ Drop here +
+
+
+
+
+ ); +} + +export default ManualPivot; diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx index 95930f4e4..71e331cab 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,25 @@ 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, @@ -118,6 +138,7 @@ export function DrawerEdit() { const [loadingPivotType, setLoadingPivotType] = useState( null, ); + const [isManualPivotOpen, setIsManualPivotOpen] = useState(false); return ( @@ -140,7 +161,18 @@ export function DrawerEdit() { setLoadingPivotType={setLoadingPivotType} /> )} + {data && ( + setIsManualPivotOpen(true)} /> + )} + {isManualPivotOpen && ( + setIsManualPivotOpen(false)} + headerVariables={data?.heading ?? []} + stubVariables={data?.stub ?? []} + /> + )} {t('common.status_messages.drawer_edit')} From 240e25ec25fa13357a7e8b9b070a70ea385b8a67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Thu, 12 Mar 2026 15:34:26 +0100 Subject: [PATCH 02/18] style: format SCSS and TypeScript code for improved readability --- .../ManualPivot/ManualPivot.module.scss | 74 ++++++++++--------- .../components/ManualPivot/ManualPivot.tsx | 4 +- .../NavigationDrawer/Drawers/DrawerEdit.tsx | 4 +- 3 files changed, 44 insertions(+), 38 deletions(-) diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss index 8aac9be3f..865e7697d 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss @@ -1,59 +1,61 @@ .wrapper { - display: flex; - flex-direction: row; - align-items: flex-start; - gap: 12px; + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 12px; } .groupColumn { - flex: 1; - min-width: 0; + flex: 1; + min-width: 0; } .groupZone { - border: 1px solid var(--px-color-border-subtle); - border-radius: var(--px-border-radius-medium); - padding: 8px; + border: 1px solid var(--px-color-border-subtle); + border-radius: var(--px-border-radius-medium); + padding: 8px; } .list { - margin: 0; - padding: 0; - list-style: none; - display: flex; - flex-direction: column; - gap: 6px; + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 6px; } .listItem { - 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, transform 120ms ease; + 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, + transform 120ms ease; } .listItem:active { - cursor: grabbing; + cursor: grabbing; } .dropZone { - margin-top: 8px; - min-height: 44px; - border: 1px dashed var(--px-color-border-default); - border-radius: var(--px-border-radius-small); - display: flex; - align-items: center; - justify-content: center; - color: var(--px-color-text-subtle); - font-size: 14px; + margin-top: 8px; + min-height: 44px; + border: 1px dashed var(--px-color-border-default); + border-radius: var(--px-border-radius-small); + display: flex; + align-items: center; + justify-content: center; + color: var(--px-color-text-subtle); + font-size: 14px; } .dropZoneActive { - border-color: var(--px-color-border-default); - background: var(--px-color-background-subtle); + border-color: var(--px-color-border-default); + background: var(--px-color-background-subtle); } diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx index bce60c7bc..b6e1d7f39 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx @@ -84,7 +84,9 @@ export function ManualPivot({ return; } - const nextSourceItems = sourceItems.filter((item) => item.id !== draggedItemId); + const nextSourceItems = sourceItems.filter( + (item) => item.id !== draggedItemId, + ); const nextTargetItems = [ ...targetItems.filter((item) => item.id !== draggedItemId), movingItem, diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx index 71e331cab..cabb1eeb4 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx @@ -30,7 +30,9 @@ function PivotManuallyButton({ onClick }: PivotManuallyButtonProps) { return ( Date: Thu, 12 Mar 2026 15:40:23 +0100 Subject: [PATCH 03/18] test: update DrawerEdit.spec.tsx to expect three action buttons --- .../app/components/NavigationDrawer/Drawers/DrawerEdit.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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( From 854cc72b47140d21e8fca43a2d24c166b9ee8a06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Thu, 12 Mar 2026 16:49:53 +0100 Subject: [PATCH 04/18] feat: enhance rearrange functionality with modal descriptions in translations --- packages/pxweb2/public/locales/ar/translation.json | 10 +++++++++- packages/pxweb2/public/locales/en/translation.json | 10 +++++++++- packages/pxweb2/public/locales/no/translation.json | 10 +++++++++- packages/pxweb2/public/locales/sv/translation.json | 10 +++++++++- packages/pxweb2/src/@types/resources.d.ts | 8 ++++++++ 5 files changed, 44 insertions(+), 4 deletions(-) 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'; }; From 958acb3aad5bee458f70008b1fc407041e905181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Thu, 12 Mar 2026 16:51:33 +0100 Subject: [PATCH 05/18] feat: add labels to ManualPivot component for improved accessibility --- .../components/ManualPivot/ManualPivot.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx index b6e1d7f39..d78bbdbaa 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { Reorder, type PanInfo } from 'framer-motion'; import { useTranslation } from 'react-i18next'; +import { Label } from '@pxweb2/pxweb2-ui'; import { Modal, Variable } from '@pxweb2/pxweb2-ui'; import classes from './ManualPivot.module.scss'; @@ -161,10 +162,20 @@ export function ManualPivot({ // label={t('presentation_page.main_content.about_table.manual_pivot.label')} 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', + )} >
-

Header variables

+
-

Stub variables

+
Date: Mon, 16 Mar 2026 13:33:18 +0100 Subject: [PATCH 06/18] feat: add focus-visible styles and tabIndex to list items in ManualPivot for improved accessibility --- .../src/app/components/ManualPivot/ManualPivot.module.scss | 5 +++++ .../pxweb2/src/app/components/ManualPivot/ManualPivot.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss index 865e7697d..c403064a2 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss @@ -43,6 +43,11 @@ cursor: grabbing; } +.listItem:focus-visible { + outline: 2px solid var(--px-color-border-focus-outline); + outline-offset: 1px; +} + .dropZone { margin-top: 8px; min-height: 44px; diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx index d78bbdbaa..9709acffd 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx @@ -158,8 +158,6 @@ export function ManualPivot({ onClose()} - // heading={t('presentation_page.main_content.about_table.manual_pivot.heading')} - // label={t('presentation_page.main_content.about_table.manual_pivot.label')} heading={t('presentation_page.side_menu.edit.customize.pivot.title')} label={t('presentation_page.side_menu.edit.title')} cancelLabel={t( @@ -190,6 +188,7 @@ export function ManualPivot({ key={variable.id} value={variable} className={classes.listItem} + tabIndex={0} drag dragMomentum={false} dragElastic={0.12} @@ -239,6 +238,7 @@ export function ManualPivot({ key={variable.id} value={variable} className={classes.listItem} + tabIndex={0} drag dragMomentum={false} dragElastic={0.12} From 674481b9734edd8744744159fd23d064a5a4dabf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Tue, 17 Mar 2026 09:55:31 +0100 Subject: [PATCH 07/18] feat: implement manual pivot functionality in ManualPivot component and update DrawerEdit to utilize it --- .../ManualPivot/ManualPivot.module.scss | 17 +- .../ManualPivot/ManualPivot.spec.tsx | 153 +++++++++ .../components/ManualPivot/ManualPivot.tsx | 307 +++++++++++------- .../NavigationDrawer/Drawers/DrawerEdit.tsx | 12 +- .../TableInformation.spec.tsx | 1 + .../src/app/context/TableDataProvider.tsx | 37 +++ 6 files changed, 397 insertions(+), 130 deletions(-) create mode 100644 packages/pxweb2/src/app/components/ManualPivot/ManualPivot.spec.tsx diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss index c403064a2..f4cf441e7 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss @@ -11,9 +11,11 @@ } .groupZone { + position: relative; border: 1px solid var(--px-color-border-subtle); border-radius: var(--px-border-radius-medium); padding: 8px; + min-height: 220px; } .list { @@ -23,6 +25,8 @@ display: flex; flex-direction: column; gap: 6px; + position: relative; + z-index: 1; } .listItem { @@ -34,9 +38,7 @@ -webkit-user-select: none; user-select: none; touch-action: none; - transition: - box-shadow 120ms ease, - transform 120ms ease; + transition: box-shadow 120ms ease; } .listItem:active { @@ -49,8 +51,8 @@ } .dropZone { - margin-top: 8px; - min-height: 44px; + position: absolute; + inset: 8px; border: 1px dashed var(--px-color-border-default); border-radius: var(--px-border-radius-small); display: flex; @@ -58,9 +60,14 @@ justify-content: center; color: var(--px-color-text-subtle); font-size: 14px; + opacity: 0; + pointer-events: none; + z-index: 0; + transition: opacity 120ms ease; } .dropZoneActive { + opacity: 1; border-color: var(--px-color-border-default); background: var(--px-color-background-subtle); } 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..c27d2b9f1 --- /dev/null +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.spec.tsx @@ -0,0 +1,153 @@ +import { createElement, type ElementType, type ReactNode } from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { 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} + ), +})); + +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' })], + ); + }); +}); + diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx index 9709acffd..125197998 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx @@ -6,9 +6,14 @@ import { Label } from '@pxweb2/pxweb2-ui'; import { Modal, Variable } from '@pxweb2/pxweb2-ui'; import classes from './ManualPivot.module.scss'; +type VariableGroup = 'header' | 'stub'; +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'; + interface ManualPivotProps { readonly isOpen: boolean; - readonly onClose: () => void; + readonly onClose: (headerItems: Variable[], stubItems: Variable[]) => void; readonly headerVariables: Variable[]; readonly stubVariables: Variable[]; } @@ -22,23 +27,46 @@ export function ManualPivot({ const { t } = useTranslation(); const [headerItems, setHeaderItems] = useState(headerVariables); const [stubItems, setStubItems] = useState(stubVariables); + const headerItemsRef = useRef(headerVariables); + const stubItemsRef = useRef(stubVariables); const headerZoneRef = useRef(null); const stubZoneRef = useRef(null); const draggedItemIdRef = useRef(null); - const dragSourceGroupRef = useRef<'header' | 'stub' | null>(null); - const hoveredGroupRef = useRef<'header' | 'stub' | null>(null); - const [activeDropGroup, setActiveDropGroup] = useState< - 'header' | 'stub' | null - >(null); + const dragSourceGroupRef = useRef(null); + const hoveredGroupRef = useRef(null); + const lastPointerYRef = useRef(null); + const [activeDropGroup, setActiveDropGroup] = useState( + null, + ); useEffect(() => { if (isOpen) { setHeaderItems(headerVariables); setStubItems(stubVariables); + headerItemsRef.current = headerVariables; + stubItemsRef.current = stubVariables; } }, [headerVariables, isOpen, stubVariables]); - const getGroupAtPoint = (x: number, y: number): 'header' | 'stub' | null => { + 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 getGroupAtPoint = (x: number, y: number): VariableGroup | null => { const headerRect = headerZoneRef.current?.getBoundingClientRect(); if ( headerRect && @@ -64,21 +92,52 @@ export function ManualPivot({ 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 moveItemBetweenGroups = () => { const draggedItemId = draggedItemIdRef.current; - const dragSourceGroup = dragSourceGroupRef.current; + const currentGroup = dragSourceGroupRef.current; const hoveredGroup = hoveredGroupRef.current; + const lastPointerY = lastPointerYRef.current; - if (!draggedItemId || !dragSourceGroup || !hoveredGroup) { + if ( + !draggedItemId || + !currentGroup || + !hoveredGroup || + lastPointerY === null + ) { return; } - if (dragSourceGroup === hoveredGroup) { + if (currentGroup === hoveredGroup) { return; } - const sourceItems = dragSourceGroup === 'header' ? headerItems : stubItems; - const targetItems = hoveredGroup === 'header' ? headerItems : stubItems; + const sourceItems = + currentGroup === 'header' ? headerItemsRef.current : stubItemsRef.current; + const targetItems = + hoveredGroup === 'header' ? headerItemsRef.current : stubItemsRef.current; const movingItem = sourceItems.find((item) => item.id === draggedItemId); if (!movingItem) { @@ -88,24 +147,30 @@ export function ManualPivot({ const nextSourceItems = sourceItems.filter( (item) => item.id !== draggedItemId, ); - const nextTargetItems = [ - ...targetItems.filter((item) => item.id !== draggedItemId), - movingItem, - ]; - - if (dragSourceGroup === 'header') { - setHeaderItems(nextSourceItems); - setStubItems(nextTargetItems); + const nextTargetItems = targetItems.filter( + (item) => item.id !== draggedItemId, + ); + const insertIndex = getInsertIndexForGroup( + hoveredGroup, + lastPointerY, + draggedItemId, + ); + nextTargetItems.splice(insertIndex, 0, movingItem); + + if (currentGroup === 'header') { + commitLists(nextSourceItems, nextTargetItems); } else { - setStubItems(nextSourceItems); - setHeaderItems(nextTargetItems); + commitLists(nextTargetItems, nextSourceItems); } + + dragSourceGroupRef.current = hoveredGroup; }; const resetDragState = () => { draggedItemIdRef.current = null; dragSourceGroupRef.current = null; hoveredGroupRef.current = null; + lastPointerYRef.current = null; setActiveDropGroup(null); }; @@ -136,9 +201,14 @@ export function ManualPivot({ info: PanInfo, ) => { const point = getClientPoint(event, info); + lastPointerYRef.current = point.y; const hoveredGroup = getGroupAtPoint(point.x, point.y); hoveredGroupRef.current = hoveredGroup; setActiveDropGroup(hoveredGroup); + + if (hoveredGroup && hoveredGroup !== dragSourceGroupRef.current) { + moveItemBetweenGroups(); + } }; const handleItemDragEnd = ( @@ -146,18 +216,96 @@ export function ManualPivot({ info: PanInfo, ) => { const point = getClientPoint(event, info); + lastPointerYRef.current = point.y; const hoveredGroup = getGroupAtPoint(point.x, point.y); hoveredGroupRef.current = hoveredGroup; setActiveDropGroup(hoveredGroup); - moveItemBetweenGroups(); + if (hoveredGroup && hoveredGroup !== dragSourceGroupRef.current) { + moveItemBetweenGroups(); + } resetDragState(); }; + const handleDragStart = (group: VariableGroup, variableId: string) => { + draggedItemIdRef.current = variableId; + dragSourceGroupRef.current = group; + hoveredGroupRef.current = group; + setActiveDropGroup(group); + }; + + const handleGroupReorder = (group: VariableGroup, nextItems: Variable[]) => { + const dedupedItems = dedupeById(nextItems); + + 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, + ) => ( +
+ +
+ handleGroupReorder(group, nextItems)} + className={classes.list} + > + {items.map((variable) => ( + handleDragStart(group, variable.id)} + onDrag={handleItemDrag} + onDragEnd={handleItemDragEnd} + > + {variable.label} + + ))} + +
+ Drop here +
+
+
+ ); + return ( onClose()} + onClose={() => onClose(headerItems, stubItems)} heading={t('presentation_page.side_menu.edit.customize.pivot.title')} label={t('presentation_page.side_menu.edit.title')} cancelLabel={t( @@ -168,105 +316,18 @@ export function ManualPivot({ )} >
-
- -
- - {headerItems.map((variable) => ( - { - draggedItemIdRef.current = variable.id; - dragSourceGroupRef.current = 'header'; - hoveredGroupRef.current = 'header'; - setActiveDropGroup('header'); - }} - onDrag={handleItemDrag} - onDragEnd={handleItemDragEnd} - > - {variable.label} - - ))} - -
- Drop here -
-
-
- -
- -
- - {stubItems.map((variable) => ( - { - draggedItemIdRef.current = variable.id; - dragSourceGroupRef.current = 'stub'; - hoveredGroupRef.current = 'stub'; - setActiveDropGroup('stub'); - }} - onDrag={handleItemDrag} - onDragEnd={handleItemDragEnd} - > - {variable.label} - - ))} - -
- Drop here -
-
-
+ {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, + )}
); diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx index cabb1eeb4..05aeb89ce 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx @@ -136,7 +136,9 @@ 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, ); @@ -170,7 +172,13 @@ export function DrawerEdit() { {isManualPivotOpen && ( setIsManualPivotOpen(false)} + onClose={(headerItems, stubItems) => { + pivotManual( + headerItems.map((item) => item.id), + stubItems.map((item) => item.id), + ); + setIsManualPivotOpen(false); + }} headerVariables={data?.heading ?? []} stubVariables={data?.stub ?? []} /> 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, From d2a2c0de1bfd0293a7378727f17ffb122bc5fc7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Tue, 17 Mar 2026 09:59:08 +0100 Subject: [PATCH 08/18] refactor: improve formatting and readability in ManualPivot component and tests --- .../ManualPivot/ManualPivot.spec.tsx | 263 +++++++++--------- .../components/ManualPivot/ManualPivot.tsx | 8 +- 2 files changed, 137 insertions(+), 134 deletions(-) diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.spec.tsx b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.spec.tsx index c27d2b9f1..22a9806fc 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.spec.tsx +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.spec.tsx @@ -7,147 +7,146 @@ import { ManualPivot } from './ManualPivot'; import type { Variable } from '@pxweb2/pxweb2-ui'; vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), + 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), - }, + 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} - ), + Modal: ({ + isOpen, + onClose, + heading, + children, + }: { + isOpen: boolean; + onClose: () => void; + heading: string; + children: ReactNode; + }) => + isOpen ? ( +
+

{heading}

+ + {children} +
+ ) : null, + Label: ({ children }: { children: ReactNode }) => {children}, })); const createVariable = (id: string, label: string): Variable => - ({ id, label }) as 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' })], - ); - }); + 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' })], + ); + }); }); - diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx index 125197998..4cefbef5c 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx @@ -48,7 +48,10 @@ export function ManualPivot({ } }, [headerVariables, isOpen, stubVariables]); - const commitLists = (nextHeaderItems: Variable[], nextStubItems: Variable[]) => { + const commitLists = ( + nextHeaderItems: Variable[], + nextStubItems: Variable[], + ) => { headerItemsRef.current = nextHeaderItems; stubItemsRef.current = nextStubItems; setHeaderItems(nextHeaderItems); @@ -99,7 +102,8 @@ export function ManualPivot({ ): number => { const zoneRef = group === 'header' ? headerZoneRef : stubZoneRef; const itemElements = Array.from( - zoneRef.current?.querySelectorAll('[data-variable-id]') ?? [], + zoneRef.current?.querySelectorAll('[data-variable-id]') ?? + [], ).filter((element) => element.dataset.variableId !== draggedItemId); if (itemElements.length === 0) { From 79ac0d0a059fddccfafdbcd6e39413e96d97f9b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Mon, 23 Mar 2026 15:28:02 +0100 Subject: [PATCH 09/18] feat: enhance manual pivot with drop preview functionality and improve drag-and-drop experience --- .../ManualPivot/ManualPivot.module.scss | 44 +-- .../components/ManualPivot/ManualPivot.tsx | 303 +++++++++++++----- 2 files changed, 242 insertions(+), 105 deletions(-) diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss index f4cf441e7..ad0de754b 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss @@ -29,6 +29,28 @@ z-index: 1; } +.dropPlaceholder { + 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 { border: 1px solid var(--px-color-border-subtle); border-radius: var(--px-border-radius-small); @@ -49,25 +71,3 @@ outline: 2px solid var(--px-color-border-focus-outline); outline-offset: 1px; } - -.dropZone { - position: absolute; - inset: 8px; - border: 1px dashed var(--px-color-border-default); - border-radius: var(--px-border-radius-small); - display: flex; - align-items: center; - justify-content: center; - color: var(--px-color-text-subtle); - font-size: 14px; - opacity: 0; - pointer-events: none; - z-index: 0; - transition: opacity 120ms ease; -} - -.dropZoneActive { - opacity: 1; - border-color: var(--px-color-border-default); - background: var(--px-color-background-subtle); -} diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx index 4cefbef5c..1fa0c3780 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { Fragment, useEffect, useRef, useState } from 'react'; import { Reorder, type PanInfo } from 'framer-motion'; import { useTranslation } from 'react-i18next'; import { Label } from '@pxweb2/pxweb2-ui'; @@ -7,6 +7,11 @@ import { Modal, Variable } 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'; @@ -34,10 +39,10 @@ export function ManualPivot({ const draggedItemIdRef = useRef(null); const dragSourceGroupRef = useRef(null); const hoveredGroupRef = useRef(null); + const isDraggingRef = useRef(false); const lastPointerYRef = useRef(null); - const [activeDropGroup, setActiveDropGroup] = useState( - null, - ); + const dropPreviewRef = useRef(null); + const [dropPreview, setDropPreview] = useState(null); useEffect(() => { if (isOpen) { @@ -48,6 +53,21 @@ export function ManualPivot({ } }, [headerVariables, isOpen, stubVariables]); + 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[], @@ -70,26 +90,28 @@ export function ManualPivot({ }; 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(); - if ( - headerRect && - x >= headerRect.left && - x <= headerRect.right && - y >= headerRect.top && - y <= headerRect.bottom - ) { - return 'header'; + const stubRect = stubZoneRef.current?.getBoundingClientRect(); + + if (!headerRect && !stubRect) { + return null; } - const stubRect = stubZoneRef.current?.getBoundingClientRect(); - if ( - stubRect && - x >= stubRect.left && - x <= stubRect.right && - y >= stubRect.top && - y <= stubRect.bottom - ) { - return 'stub'; + 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; @@ -119,29 +141,70 @@ export function ManualPivot({ return index === -1 ? itemElements.length : index; }; - const moveItemBetweenGroups = () => { + 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 currentGroup = dragSourceGroupRef.current; - const hoveredGroup = hoveredGroupRef.current; - const lastPointerY = lastPointerYRef.current; + const sourceGroup = dragSourceGroupRef.current; - if ( - !draggedItemId || - !currentGroup || - !hoveredGroup || - lastPointerY === null - ) { + if (!draggedItemId || !sourceGroup) { return; } - if (currentGroup === hoveredGroup) { + if (sourceGroup === targetGroup) { return; } const sourceItems = - currentGroup === 'header' ? headerItemsRef.current : stubItemsRef.current; + sourceGroup === 'header' ? headerItemsRef.current : stubItemsRef.current; const targetItems = - hoveredGroup === 'header' ? headerItemsRef.current : stubItemsRef.current; + targetGroup === 'header' ? headerItemsRef.current : stubItemsRef.current; const movingItem = sourceItems.find((item) => item.id === draggedItemId); if (!movingItem) { @@ -154,28 +217,28 @@ export function ManualPivot({ const nextTargetItems = targetItems.filter( (item) => item.id !== draggedItemId, ); - const insertIndex = getInsertIndexForGroup( - hoveredGroup, - lastPointerY, - draggedItemId, + const clampedInsertIndex = Math.min( + Math.max(0, targetIndex), + nextTargetItems.length, ); - nextTargetItems.splice(insertIndex, 0, movingItem); + nextTargetItems.splice(clampedInsertIndex, 0, movingItem); - if (currentGroup === 'header') { + if (sourceGroup === 'header') { commitLists(nextSourceItems, nextTargetItems); } else { commitLists(nextTargetItems, nextSourceItems); } - dragSourceGroupRef.current = hoveredGroup; + dragSourceGroupRef.current = targetGroup; }; const resetDragState = () => { + isDraggingRef.current = false; draggedItemIdRef.current = null; dragSourceGroupRef.current = null; hoveredGroupRef.current = null; lastPointerYRef.current = null; - setActiveDropGroup(null); + updateDropPreview(null); }; const getClientPoint = ( @@ -206,12 +269,19 @@ export function ManualPivot({ ) => { const point = getClientPoint(event, info); lastPointerYRef.current = point.y; - const hoveredGroup = getGroupAtPoint(point.x, point.y); - hoveredGroupRef.current = hoveredGroup; - setActiveDropGroup(hoveredGroup); + const detectedGroup = getGroupAtPoint(point.x, point.y); + if (detectedGroup) { + hoveredGroupRef.current = detectedGroup; + } + + const hoveredGroup = + detectedGroup ?? hoveredGroupRef.current ?? dragSourceGroupRef.current; - if (hoveredGroup && hoveredGroup !== dragSourceGroupRef.current) { - moveItemBetweenGroups(); + const draggedItemId = draggedItemIdRef.current; + if (hoveredGroup && draggedItemId) { + updateDropPreview(getDropPreviewForGroup(hoveredGroup, point.y, draggedItemId)); + } else { + updateDropPreview(null); } }; @@ -221,25 +291,64 @@ export function ManualPivot({ ) => { const point = getClientPoint(event, info); lastPointerYRef.current = point.y; - const hoveredGroup = getGroupAtPoint(point.x, point.y); - hoveredGroupRef.current = hoveredGroup; - setActiveDropGroup(hoveredGroup); + 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 (hoveredGroup && hoveredGroup !== dragSourceGroupRef.current) { - moveItemBetweenGroups(); + 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; - setActiveDropGroup(group); + + const zoneRect = + (group === 'header' ? headerZoneRef.current : stubZoneRef.current)?.getBoundingClientRect(); + if (zoneRect) { + updateDropPreview(getDropPreviewForGroup(group, zoneRect.top, variableId)); + } }; const handleGroupReorder = (group: VariableGroup, nextItems: Variable[]) => { - const dedupedItems = dedupeById(nextItems); + 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( @@ -262,7 +371,16 @@ export function ManualPivot({ 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; + + return (
@@ -273,38 +391,57 @@ export function ManualPivot({ onReorder={(nextItems) => handleGroupReorder(group, nextItems)} className={classes.list} > - {items.map((variable) => ( - handleDragStart(group, variable.id)} - onDrag={handleItemDrag} - onDragEnd={handleItemDragEnd} - > - {variable.label} - + {items.map((variable, index) => ( + + {previewIndex === index ? ( + + ) : null} + handleDragStart(group, variable.id)} + onDrag={handleItemDrag} + onDragEnd={handleItemDragEnd} + > + {variable.label} + + ))} + {previewIndex === items.length ? ( + + ) : null} -
- Drop here -
- ); + ); + }; return ( Date: Mon, 23 Mar 2026 15:28:41 +0100 Subject: [PATCH 10/18] refactor: improve code readability and formatting in ManualPivot component --- .../components/ManualPivot/ManualPivot.tsx | 154 ++++++++++-------- 1 file changed, 87 insertions(+), 67 deletions(-) diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx index 1fa0c3780..dc76e3044 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx @@ -61,7 +61,8 @@ export function ManualPivot({ return; } - const zoneRef = dropPreview.group === 'header' ? headerZoneRef : stubZoneRef; + const zoneRef = + dropPreview.group === 'header' ? headerZoneRef : stubZoneRef; zoneRef.current?.style.setProperty( '--drop-preview-height', `${dropPreview.height}px`, @@ -104,8 +105,12 @@ export function ManualPivot({ return null; } - const headerDistance = headerRect ? distanceToRect(headerRect) : Number.POSITIVE_INFINITY; - const stubDistance = stubRect ? distanceToRect(stubRect) : Number.POSITIVE_INFINITY; + 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); @@ -279,7 +284,9 @@ export function ManualPivot({ const draggedItemId = draggedItemIdRef.current; if (hoveredGroup && draggedItemId) { - updateDropPreview(getDropPreviewForGroup(hoveredGroup, point.y, draggedItemId)); + updateDropPreview( + getDropPreviewForGroup(hoveredGroup, point.y, draggedItemId), + ); } else { updateDropPreview(null); } @@ -301,7 +308,9 @@ export function ManualPivot({ const draggedItemId = draggedItemIdRef.current; if (hoveredGroup && draggedItemId) { - updateDropPreview(getDropPreviewForGroup(hoveredGroup, point.y, draggedItemId)); + updateDropPreview( + getDropPreviewForGroup(hoveredGroup, point.y, draggedItemId), + ); } const persistedDropTarget = dropPreviewRef.current; @@ -324,10 +333,13 @@ export function ManualPivot({ dragSourceGroupRef.current = group; hoveredGroupRef.current = group; - const zoneRect = - (group === 'header' ? headerZoneRef.current : stubZoneRef.current)?.getBoundingClientRect(); + const zoneRect = ( + group === 'header' ? headerZoneRef.current : stubZoneRef.current + )?.getBoundingClientRect(); if (zoneRect) { - updateDropPreview(getDropPreviewForGroup(group, zoneRect.top, variableId)); + updateDropPreview( + getDropPreviewForGroup(group, zoneRect.top, variableId), + ); } }; @@ -337,12 +349,16 @@ export function ManualPivot({ const sourceGroup = dragSourceGroupRef.current; if (isDraggingRef.current && draggedItemId && sourceGroup === group) { - const hasDraggedItem = dedupedItems.some((item) => item.id === draggedItemId); + 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); + const draggedItem = currentGroupItems.find( + (item) => item.id === draggedItemId, + ); if (draggedItem) { dedupedItems = [...dedupedItems, draggedItem]; @@ -381,65 +397,69 @@ export function ManualPivot({ : undefined; return ( -
- -
- handleGroupReorder(group, nextItems)} - className={classes.list} - > - {items.map((variable, index) => ( - - {previewIndex === index ? ( - + ) : null} + handleDragStart(group, variable.id)} + onDrag={handleItemDrag} + onDragEnd={handleItemDragEnd} > - {draggedItemLabel ? ( - {draggedItemLabel} - ) : null} - - ) : null} - handleDragStart(group, variable.id)} - onDrag={handleItemDrag} - onDragEnd={handleItemDragEnd} + {variable.label} + + + ))} + {previewIndex === items.length ? ( + - ) : null} - -
-
+ {draggedItemLabel ? ( + + {draggedItemLabel} + + ) : null} + + ) : null} +
+
+
); }; From d5ab2397dc494c677b204fb404fa2e8030e2c1ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Mon, 23 Mar 2026 15:57:16 +0100 Subject: [PATCH 11/18] feat: implement keyboard navigation and drag-and-drop functionality in ManualPivot component --- .../ManualPivot/ManualPivot.module.scss | 12 + .../ManualPivot/ManualPivot.spec.tsx | 131 ++++++++- .../components/ManualPivot/ManualPivot.tsx | 260 +++++++++++++++++- 3 files changed, 401 insertions(+), 2 deletions(-) diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss index ad0de754b..2634304ff 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss @@ -71,3 +71,15 @@ 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 index 22a9806fc..af95052d7 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.spec.tsx +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.spec.tsx @@ -1,6 +1,6 @@ import { createElement, type ElementType, type ReactNode } from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import '@testing-library/jest-dom/vitest'; import { ManualPivot } from './ManualPivot'; @@ -149,4 +149,133 @@ describe('ManualPivot', () => { [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 index dc76e3044..f3038ff7f 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx @@ -1,4 +1,4 @@ -import { Fragment, useEffect, useRef, useState } from 'react'; +import { Fragment, useEffect, useId, useRef, useState } from 'react'; import { Reorder, type PanInfo } from 'framer-motion'; import { useTranslation } from 'react-i18next'; import { Label } from '@pxweb2/pxweb2-ui'; @@ -15,6 +15,10 @@ type DropPreview = { 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; @@ -30,12 +34,20 @@ export function ManualPivot({ 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); @@ -50,9 +62,30 @@ export function ManualPivot({ 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'); @@ -237,6 +270,159 @@ export function ManualPivot({ 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; + + if (itemLabel && itemIndex !== -1) { + setLiveAnnouncement( + `${itemLabel} 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) { + setLiveAnnouncement( + `${itemLabel} 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(`${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(`${itemLabel} move cancelled.`); + } + pendingFocusItemIdRef.current = draggedItemId; + } + + keyboardDragSnapshotRef.current = null; + setKeyboardDraggedItemId(null); + resetDragState(); + }; + const resetDragState = () => { isDraggingRef.current = false; draggedItemIdRef.current = null; @@ -246,6 +432,59 @@ export function ManualPivot({ updateDropPreview(null); }; + const handleItemKeyDown = ( + event: React.KeyboardEvent, + group: VariableGroup, + variableId: string, + ) => { + const isKeyboardDragging = keyboardDraggedItemId === variableId; + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (isKeyboardDragging) { + dropKeyboardDrag(); + } else if (!keyboardDraggedItemId) { + startKeyboardDrag(group, variableId); + } + return; + } + + if (event.key === 'Escape' && isKeyboardDragging) { + event.preventDefault(); + cancelKeyboardDrag(); + return; + } + + if (!isKeyboardDragging) { + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + moveKeyboardDraggedItemWithinGroup(-1); + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + moveKeyboardDraggedItemWithinGroup(1); + return; + } + + if (event.key === 'ArrowLeft') { + event.preventDefault(); + const targetGroup = group === 'header' ? 'stub' : 'header'; + moveKeyboardDraggedItemAcrossGroups(targetGroup); + return; + } + + if (event.key === 'ArrowRight') { + event.preventDefault(); + const targetGroup = group === 'header' ? 'stub' : 'header'; + moveKeyboardDraggedItemAcrossGroups(targetGroup); + } + }; + const getClientPoint = ( event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo, @@ -427,7 +666,16 @@ export function ManualPivot({ data-variable-id={variable.id} value={variable} className={classes.listItem} + ref={(element: HTMLLIElement | 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} @@ -439,6 +687,9 @@ export function ManualPivot({ onDragStart={() => handleDragStart(group, variable.id)} onDrag={handleItemDrag} onDragEnd={handleItemDragEnd} + onKeyDown={(event) => + handleItemKeyDown(event, group, variable.id) + } > {variable.label} @@ -476,6 +727,13 @@ export function ManualPivot({ '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', From a96537fa1310bcb995ade55193bf15fcd259e367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Mon, 23 Mar 2026 15:59:30 +0100 Subject: [PATCH 12/18] refactor: improve code formatting and readability in ManualPivot component --- .../src/app/components/ManualPivot/ManualPivot.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx index f3038ff7f..3fce515c3 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx @@ -327,7 +327,9 @@ export function ManualPivot({ } const sourceItems = getItemsForGroup(sourceGroup); - const sourceIndex = sourceItems.findIndex((item) => item.id === draggedItemId); + const sourceIndex = sourceItems.findIndex( + (item) => item.id === draggedItemId, + ); if (sourceIndex === -1) { return false; @@ -369,7 +371,9 @@ export function ManualPivot({ } const sourceItems = getItemsForGroup(sourceGroup); - const sourceIndex = sourceItems.findIndex((item) => item.id === draggedItemId); + const sourceIndex = sourceItems.findIndex( + (item) => item.id === draggedItemId, + ); if (sourceIndex === -1) { return false; @@ -728,8 +732,8 @@ export function ManualPivot({ )} >

- Press Space or Enter to pick up an item. Use arrow keys to move it, - then press Enter to drop. Press Escape to cancel. + 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} From bfb842f7200ca8e6f3d20b7eb5ff26cb47eef693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Mon, 23 Mar 2026 16:23:36 +0100 Subject: [PATCH 13/18] feat: enhance Modal and ManualPivot components with improved keyboard handling and focus management --- .../src/lib/components/Modal/Modal.spec.tsx | 16 ++++++- .../src/lib/components/Modal/Modal.tsx | 45 +++++-------------- 2 files changed, 24 insertions(+), 37 deletions(-) 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..e7b9e12a0 100644 --- a/packages/pxweb2-ui/src/lib/components/Modal/Modal.tsx +++ b/packages/pxweb2-ui/src/lib/components/Modal/Modal.tsx @@ -5,8 +5,6 @@ import { useEffect, useRef, useState, - KeyboardEvent as ReactKeyboardEvent, - MouseEvent, } from 'react'; import classes from './Modal.module.scss'; @@ -39,6 +37,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 +58,7 @@ export function Modal({ if (isModalOpen) { modalElement.showModal(); setWindowScroll(false); + bodyRef.current?.focus(); } else { modalElement.close(); setWindowScroll(true); @@ -74,33 +74,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 +92,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 +127,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 +143,6 @@ export function Modal({ size="medium" type="button" onClick={() => handleCloseModal(true)} - onKeyUp={(event) => handleCloseModal(true, event)} aria-label={confirmLabelValue} > {confirmLabelValue} @@ -176,7 +152,6 @@ export function Modal({ size="medium" type="button" onClick={() => handleCloseModal(false)} - onKeyUp={(event) => handleCloseModal(false, event)} aria-label={cancelLabelValue} > {cancelLabelValue} From 36ad278c3a61d2e351d89a00a41cd22c9f7efd5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Mon, 23 Mar 2026 16:24:10 +0100 Subject: [PATCH 14/18] refactor: streamline import statements in Modal component --- packages/pxweb2-ui/src/lib/components/Modal/Modal.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/pxweb2-ui/src/lib/components/Modal/Modal.tsx b/packages/pxweb2-ui/src/lib/components/Modal/Modal.tsx index e7b9e12a0..fff263819 100644 --- a/packages/pxweb2-ui/src/lib/components/Modal/Modal.tsx +++ b/packages/pxweb2-ui/src/lib/components/Modal/Modal.tsx @@ -1,11 +1,6 @@ import cl from 'clsx'; import { useTranslation } from 'react-i18next'; -import { - useCallback, - useEffect, - useRef, - useState, -} from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import classes from './Modal.module.scss'; import Label from '../Typography/Label/Label'; From 1878fb0eab9430227739b95ed8c2c72bd6dc68d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Mon, 23 Mar 2026 17:05:25 +0100 Subject: [PATCH 15/18] feat: enhance ManualPivot component with improved label capitalization and styling adjustments --- .../ManualPivot/ManualPivot.module.scss | 12 ++++- .../ManualPivot/ManualPivot.spec.tsx | 1 + .../components/ManualPivot/ManualPivot.tsx | 46 +++++++++++++------ 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss index 2634304ff..fe2dfff65 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.module.scss @@ -1,17 +1,21 @@ .wrapper { display: flex; flex-direction: row; - align-items: flex-start; + align-items: stretch; + width: 100%; gap: 12px; } .groupColumn { - flex: 1; + 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; @@ -30,6 +34,8 @@ } .dropPlaceholder { + position: relative; + z-index: 2; width: 100%; height: var(--drop-preview-height, 40px); border: 1px dashed var(--px-color-border-focus-outline); @@ -52,6 +58,8 @@ } .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); diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.spec.tsx b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.spec.tsx index af95052d7..b4cd320b1 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.spec.tsx +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.spec.tsx @@ -53,6 +53,7 @@ vi.mock('@pxweb2/pxweb2-ui', () => ({
) : null, Label: ({ children }: { children: ReactNode }) => {children}, + BodyShort: ({ children }: { children: ReactNode }) => {children}, })); const createVariable = (id: string, label: string): Variable => diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx index 3fce515c3..e168ff9df 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx @@ -1,7 +1,7 @@ import { Fragment, useEffect, useId, useRef, useState } from 'react'; import { Reorder, type PanInfo } from 'framer-motion'; import { useTranslation } from 'react-i18next'; -import { Label } from '@pxweb2/pxweb2-ui'; +import { BodyShort, Label } from '@pxweb2/pxweb2-ui'; import { Modal, Variable } from '@pxweb2/pxweb2-ui'; import classes from './ManualPivot.module.scss'; @@ -123,6 +123,9 @@ export function ManualPivot({ }); }; + 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 => { @@ -291,10 +294,11 @@ export function ManualPivot({ const groupItems = getItemsForGroup(group); const itemIndex = groupItems.findIndex((item) => item.id === itemId); const itemLabel = getItemById(itemId)?.label; + const capitalizedItemLabel = itemLabel ? capitalizeLabel(itemLabel) : ''; - if (itemLabel && itemIndex !== -1) { + if (capitalizedItemLabel && itemIndex !== -1) { setLiveAnnouncement( - `${itemLabel} moved to position ${itemIndex + 1} in ${getGroupLabel(group)}.`, + `${capitalizedItemLabel} moved to position ${itemIndex + 1} in ${getGroupLabel(group)}.`, ); } }; @@ -312,8 +316,9 @@ export function ManualPivot({ const itemLabel = getItemById(variableId)?.label; if (itemLabel) { + const capitalizedItemLabel = capitalizeLabel(itemLabel); setLiveAnnouncement( - `${itemLabel} selected. Use arrow keys to move, Enter to drop, Escape to cancel.`, + `${capitalizedItemLabel} selected. Use arrow keys to move, Enter to drop, Escape to cancel.`, ); } }; @@ -397,7 +402,9 @@ export function ManualPivot({ const itemLabel = getItemById(draggedItemId)?.label; const groupLabel = getGroupLabel(sourceGroup); if (itemLabel) { - setLiveAnnouncement(`${itemLabel} dropped in ${groupLabel}.`); + setLiveAnnouncement( + `${capitalizeLabel(itemLabel)} dropped in ${groupLabel}.`, + ); } } @@ -417,7 +424,7 @@ export function ManualPivot({ if (draggedItemId) { const itemLabel = getItemById(draggedItemId)?.label; if (itemLabel) { - setLiveAnnouncement(`${itemLabel} move cancelled.`); + setLiveAnnouncement(`${capitalizeLabel(itemLabel)} move cancelled.`); } pendingFocusItemIdRef.current = draggedItemId; } @@ -638,6 +645,9 @@ export function ManualPivot({ (item) => item.id === draggedItemIdRef.current, )?.label : undefined; + const capitalizedDraggedItemLabel = draggedItemLabel + ? capitalizeLabel(draggedItemLabel) + : undefined; return (
@@ -656,11 +666,11 @@ export function ManualPivot({ @@ -670,6 +680,14 @@ export function ManualPivot({ data-variable-id={variable.id} value={variable} className={classes.listItem} + style={{ + zIndex: + isDraggingRef.current && draggedItemIdRef.current === variable.id + ? 2 + : previewIndex !== undefined && index < previewIndex + ? 3 + : 1, + }} ref={(element: HTMLLIElement | null) => { if (element) { itemRefs.current.set(variable.id, element); @@ -685,7 +703,7 @@ export function ManualPivot({ dragElastic={0} whileDrag={{ scale: 1.02, - zIndex: 10, + zIndex: 2, boxShadow: '0 8px 20px rgba(0, 0, 0, 0.2)', }} onDragStart={() => handleDragStart(group, variable.id)} @@ -695,7 +713,7 @@ export function ManualPivot({ handleItemKeyDown(event, group, variable.id) } > - {variable.label} + {capitalizeLabel(variable.label)} ))} @@ -703,11 +721,11 @@ export function ManualPivot({ From 318fbf2500f680370586ee195846c5f5831795f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Mon, 23 Mar 2026 17:05:54 +0100 Subject: [PATCH 16/18] refactor: improve code formatting in ManualPivot component for better readability --- .../pxweb2/src/app/components/ManualPivot/ManualPivot.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx index e168ff9df..d96f86070 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx @@ -682,7 +682,8 @@ export function ManualPivot({ className={classes.listItem} style={{ zIndex: - isDraggingRef.current && draggedItemIdRef.current === variable.id + isDraggingRef.current && + draggedItemIdRef.current === variable.id ? 2 : previewIndex !== undefined && index < previewIndex ? 3 @@ -713,7 +714,9 @@ export function ManualPivot({ handleItemKeyDown(event, group, variable.id) } > - {capitalizeLabel(variable.label)} + + {capitalizeLabel(variable.label)} + ))} From df6a4f452f6b42e3461ffc3dda81c7f3422b2bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Tue, 24 Mar 2026 08:33:19 +0100 Subject: [PATCH 17/18] refactor: consolidate import statements in ManualPivot component for improved organization --- packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx index d96f86070..f140bc327 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx @@ -1,9 +1,8 @@ import { Fragment, useEffect, useId, useRef, useState } from 'react'; import { Reorder, type PanInfo } from 'framer-motion'; import { useTranslation } from 'react-i18next'; -import { BodyShort, Label } from '@pxweb2/pxweb2-ui'; -import { Modal, Variable } from '@pxweb2/pxweb2-ui'; +import { Modal, Variable, BodyShort, Label } from '@pxweb2/pxweb2-ui'; import classes from './ManualPivot.module.scss'; type VariableGroup = 'header' | 'stub'; From 9005c2ce59aa8f203340a48ac98857b321e2c05a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kent=20Mossb=C3=A4ck?= Date: Tue, 24 Mar 2026 10:09:33 +0100 Subject: [PATCH 18/18] feat: enhance keyboard handling in ManualPivot component with improved key event management --- .../components/ManualPivot/ManualPivot.tsx | 90 ++++++++++--------- 1 file changed, 48 insertions(+), 42 deletions(-) diff --git a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx index f140bc327..d86f40d1f 100644 --- a/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx +++ b/packages/pxweb2/src/app/components/ManualPivot/ManualPivot.tsx @@ -442,6 +442,9 @@ export function ManualPivot({ updateDropPreview(null); }; + const getOtherGroup = (group: VariableGroup): VariableGroup => + group === 'header' ? 'stub' : 'header'; + const handleItemKeyDown = ( event: React.KeyboardEvent, group: VariableGroup, @@ -449,49 +452,52 @@ export function ManualPivot({ ) => { const isKeyboardDragging = keyboardDraggedItemId === variableId; - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - if (isKeyboardDragging) { - dropKeyboardDrag(); - } else if (!keyboardDraggedItemId) { - startKeyboardDrag(group, variableId); + switch (event.key) { + case 'Enter': + case ' ': { + event.preventDefault(); + if (isKeyboardDragging) { + dropKeyboardDrag(); + } else if (!keyboardDraggedItemId) { + startKeyboardDrag(group, variableId); + } + return; } - return; - } - - if (event.key === 'Escape' && isKeyboardDragging) { - event.preventDefault(); - cancelKeyboardDrag(); - return; - } - - if (!isKeyboardDragging) { - return; - } - - if (event.key === 'ArrowUp') { - event.preventDefault(); - moveKeyboardDraggedItemWithinGroup(-1); - return; - } - - if (event.key === 'ArrowDown') { - event.preventDefault(); - moveKeyboardDraggedItemWithinGroup(1); - return; - } - - if (event.key === 'ArrowLeft') { - event.preventDefault(); - const targetGroup = group === 'header' ? 'stub' : 'header'; - moveKeyboardDraggedItemAcrossGroups(targetGroup); - return; - } - - if (event.key === 'ArrowRight') { - event.preventDefault(); - const targetGroup = group === 'header' ? 'stub' : 'header'; - moveKeyboardDraggedItemAcrossGroups(targetGroup); + 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; } };