diff --git a/dataweaver/apps/web/src/components/elements/card/base.module.scss b/dataweaver/apps/web/src/components/elements/card/base.module.scss index e9f57290..e8642b23 100644 --- a/dataweaver/apps/web/src/components/elements/card/base.module.scss +++ b/dataweaver/apps/web/src/components/elements/card/base.module.scss @@ -29,7 +29,8 @@ transition: transform 0.3s $ease-out; } - :is([data-selection="single"]) & { + [data-selection="single"] &, + &:focus-within { transform: translateY(calc(var(--actions-height) * -1)); } } @@ -50,7 +51,7 @@ // tldraw forces 'user-select: none' on all nested nodes. This ensures we // allow selecting text when in single selection mode - :is([data-selection="single"]) & * { + [data-selection="single"] & * { user-select: text; } @@ -63,7 +64,8 @@ transition: border-color 0.1s $ease-linear; } - :is([data-selection="single"], [data-selection="multiple"]) & { + [data-selection="single"] &, + [data-selection="multiple"] & { border-color: rgb(var(--color-card-surface-selected)); } } diff --git a/dataweaver/apps/web/src/components/elements/card/chart/menu_chart_options.tsx b/dataweaver/apps/web/src/components/elements/card/chart/menu_chart_options.tsx index bf25459f..5fc32fc2 100644 --- a/dataweaver/apps/web/src/components/elements/card/chart/menu_chart_options.tsx +++ b/dataweaver/apps/web/src/components/elements/card/chart/menu_chart_options.tsx @@ -60,9 +60,6 @@ export const MenuChartOptions = ({ useKeydown('Escape', onClose); - // TODO: For now this doesn't seem to really work due to TLDraw consuming - // tab events. Review focus trap implementation once we review how TLDraw - // handles focus and keyboard events in general, and adjust as needed useFocusTrap(contentContainerRef); return ( diff --git a/dataweaver/apps/web/src/components/scopes/atlas/atlas_provider.tsx b/dataweaver/apps/web/src/components/scopes/atlas/atlas_provider.tsx index e0c5d7ac..8b2d452b 100644 --- a/dataweaver/apps/web/src/components/scopes/atlas/atlas_provider.tsx +++ b/dataweaver/apps/web/src/components/scopes/atlas/atlas_provider.tsx @@ -25,6 +25,7 @@ import { ExportProvider } from './export_provider'; import { type AtlasContent, type CardVariant, contentToShape } from './helpers'; import { keepInView, placeCard } from './placement'; import { QueryProvider } from './query_provider'; +import { registerCardTabNavigation } from './register_card_tab_navigation'; /** The content shape that corresponds to a given card variant. */ type ContentForVariant = Extract< @@ -186,6 +187,10 @@ export const AtlasProvider = ({ children, licenseKey }: AtlasProviderProps) => { }, ); + // Support tab navigation; prevent tldraw consuming tab events and manage + // focus ourselves so we can step through card content + const cleanupTabNavigation = registerCardTabNavigation(editor); + // Expose the editor for synchronous reads and release any canvas writes // that were issued before mount setEditor(editor); @@ -198,6 +203,7 @@ export const AtlasProvider = ({ children, licenseKey }: AtlasProviderProps) => { cleanupBeforeCreate(); cleanupAfterCreate(); cleanupAfterDelete(); + cleanupTabNavigation(); // Swap in a fresh deferred and drop clone tracking tied to this // editor, so any future remount starts clean rather than chaining diff --git a/dataweaver/apps/web/src/components/scopes/atlas/register_card_tab_navigation.ts b/dataweaver/apps/web/src/components/scopes/atlas/register_card_tab_navigation.ts new file mode 100644 index 00000000..ac463820 --- /dev/null +++ b/dataweaver/apps/web/src/components/scopes/atlas/register_card_tab_navigation.ts @@ -0,0 +1,156 @@ +import type { Editor, TLShapeId } from 'tldraw'; +import { TABBABLE_ELEMENTS } from '~/hooks/use_focus_trap'; +import { CARD_DATA_ATTRIBUTE } from './shapes/card'; + +const CARD_SELECTOR = `[${CARD_DATA_ATTRIBUTE}]`; + +interface CardFocusable { + element: HTMLElement; + shapeId: TLShapeId; +} + +/** + * Get every card's focusables list, flattened in reading order + * (top-to-bottom, left-to-right). + */ +const getCardFocusables = (editor: Editor): CardFocusable[] => { + const container = editor.getContainer(); + const culledShapes = editor.getCulledShapes(); + + return ( + editor + .getCurrentPageShapes() + + // Current page shapes returns all shapes, including culled ones. So + // filter those out before proceeding + .filter((shape) => shape.type === 'card' && !culledShapes.has(shape.id)) + .map((shape) => ({ shape, bounds: editor.getShapePageBounds(shape.id) })) + + // Sort by page bounds (top-to-bottom, left-to-right) so we tab in order + // of appearance rather than draw order. Shapes without bounds sink to the + // end, so the comparator stays a strict weak ordering + .sort((a, b) => { + if (!a.bounds && !b.bounds) return 0; + if (!a.bounds) return 1; + if (!b.bounds) return -1; + return a.bounds.y - b.bounds.y || a.bounds.x - b.bounds.x; + }) + + // For each card, find its element and read the tabbable elements inside + .flatMap(({ shape }) => { + const shapeElement = container.querySelector( + `[data-shape-id='${shape.id}']`, + ); + + // Ignore if we couldn't find the card's wrapper element + if (!shapeElement) return []; + + // Return every tabbable element inside the card paired with the ID + const focusableElements = + shapeElement.querySelectorAll(TABBABLE_ELEMENTS); + return Array.from(focusableElements, (element) => ({ + element, + shapeId: shape.id, + })); + }) + ); +}; + +/** Where a tab press should land next within the list of cards. */ +const getTargetIndex = ( + focusables: CardFocusable[], + activeElement: HTMLElement, + pressedShiftKey: boolean, + selectedId: TLShapeId | null, +): number => { + // Where the focused element sits in the list (-1 when focus is on the canvas + // rather than on a card focusable) + const currentIndex = focusables.findIndex( + (focusable) => focusable.element === activeElement, + ); + + // Already on a focusable: step to the next / previous one, wrapping at ends + if (currentIndex !== -1) { + const step = pressedShiftKey ? -1 : 1; + return (currentIndex + step + focusables.length) % focusables.length; + } + + // Entering from the canvas with a card selected: dive into that card — its + // first focusable when tabbing forward, its last when tabbing backward — so + // tab enters whichever card you just clicked + if (selectedId) { + const shapeIds = focusables.map((focusable) => focusable.shapeId); + const selectedCardIndex = pressedShiftKey + ? shapeIds.lastIndexOf(selectedId) + : shapeIds.indexOf(selectedId); + if (selectedCardIndex !== -1) return selectedCardIndex; + } + + // Nothing selected: start at the first focusable (tab) or last (shift+tab) + return pressedShiftKey ? focusables.length - 1 : 0; +}; + +/** + * Enable tab / shift+tab to reach the focusable content inside cards. Under the + * hood we listen for tab presses and override tldraw's own shape-cycling + * behavior to instead cycle through the focusable elements inside cards in + * reading order (top-to-bottom, left-to-right). This allows users to tab into a + * card's content and then tab out of it to the next card seamlessly. + */ +export const registerCardTabNavigation = (editor: Editor) => { + const container = editor.getContainer(); + + const pressedKey = (event: KeyboardEvent) => { + // Ignore non-tab keys and modifier combos (alt+tab, ctrl+tab, etc). + if (event.key !== 'Tab' || event.altKey || event.ctrlKey || event.metaKey) { + return; + } + + // Leave tldraw's own text editing alone + if (editor.getEditingShapeId()) return; + + // Ignore if the active element isn't an HTMLElement + const { activeElement } = document; + if (!(activeElement instanceof HTMLElement)) return; + + // A dialog / menu runs its own focus trap — stay out of its way. Covers the + // native plus ARIA widgets (menu, listbox, dialog) whose own + // keyboard handling we'd otherwise clobber from the capture phase + if ( + container.querySelector('dialog[open]') || + activeElement.closest('[role="dialog"], [role="menu"], [role="listbox"]') + ) { + return; + } + + // Only handle tab events from the canvas itself (e.g. right after clicking + // a card to select it) or from inside a card + if (activeElement !== container && !activeElement.closest(CARD_SELECTOR)) { + return; + } + + const focusables = getCardFocusables(editor); + if (focusables.length === 0) return; + + const targetIndex = getTargetIndex( + focusables, + activeElement, + event.shiftKey, + editor.getOnlySelectedShapeId(), + ); + + // If we couldn't find a target - ignore rest + const target = focusables[targetIndex]; + if (!target) return; + + // Take over from tldraw's shape-cycling and the browser's native tab order + event.preventDefault(); + editor.markEventAsHandled(event); + target.element.focus(); + }; + + container.addEventListener('keydown', pressedKey, { capture: true }); + return () => { + container.removeEventListener('keydown', pressedKey, { capture: true }); + }; +}; diff --git a/dataweaver/apps/web/src/hooks/use_focus_trap.ts b/dataweaver/apps/web/src/hooks/use_focus_trap.ts index 8dcea912..97783725 100755 --- a/dataweaver/apps/web/src/hooks/use_focus_trap.ts +++ b/dataweaver/apps/web/src/hooks/use_focus_trap.ts @@ -2,8 +2,14 @@ import type { RefObject } from 'react'; import { useKeydown } from './use_keydown'; -const TABBABLE_ELEMENTS = - "a[href]:not([tabindex='-1']), button:not([tabindex='-1']), [tabindex='0']"; +export const TABBABLE_ELEMENTS = [ + "a[href]:not([tabindex='-1'])", + "button:not([tabindex='-1']):not(:disabled)", + "input:not([type='hidden']):not([tabindex='-1']):not(:disabled)", + "select:not([tabindex='-1']):not(:disabled)", + "textarea:not([tabindex='-1']):not(:disabled)", + "[tabindex]:not([tabindex='-1'])", +].join(', '); interface Config { /** @@ -67,7 +73,9 @@ export const useFocusTrap = ( return; } - const activeElement = document.activeElement as HTMLElement | null; + // Ignore if active element isn't a HTMLElement + const { activeElement } = document; + if (!(activeElement instanceof HTMLElement)) return; // Check if active element is within any target and if it's not, focus // respective first or last focusable element diff --git a/dataweaver/apps/web/src/styles/layers.css b/dataweaver/apps/web/src/styles/layers.css index f5075878..6c05ca56 100644 --- a/dataweaver/apps/web/src/styles/layers.css +++ b/dataweaver/apps/web/src/styles/layers.css @@ -1 +1 @@ -@layer reset, root, base, primitive; +@layer reset, root, primitive, base;