Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Expand All @@ -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;
}

Expand All @@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,6 @@ export const MenuChartOptions = ({

useKeydown('Escape', onClose);

// TODO: For now this doesn't seem to really work due to TLDraw consuming

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made a comment about this in a previous PR (which is now out-of-date due to this change).

// 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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TVariant extends CardVariant> = Extract<
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
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
.sort((a, b) =>
a.bounds && b.bounds
? a.bounds.y - b.bounds.y || a.bounds.x - b.bounds.x
: 0,
)
Comment thread
PauloMFJ marked this conversation as resolved.
Outdated

// For each card, find its element and read the tabbable elements inside
.flatMap(({ shape }) => {
const shapeElement = container.querySelector<HTMLElement>(
`[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<HTMLElement>(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's first
// focusable, so tab enters whichever card you just clicked
if (selectedId) {
const selectedCardIndex = focusables.findIndex(
(focusable) => focusable.shapeId === selectedId,
);
if (selectedCardIndex !== -1) return selectedCardIndex;
}
Comment thread
PauloMFJ marked this conversation as resolved.
Outdated

// 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;

// A dialog / menu runs its own focus trap — stay out of its way
if (container.querySelector('dialog[open]')) return;

// Ignore if the active element isn't an HTMLElement
const { activeElement } = document;
if (!(activeElement instanceof HTMLElement)) 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;
Comment thread
PauloMFJ marked this conversation as resolved.
}

const focusables = getCardFocusables(editor);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs on every tab hit. This means that as a user tabs through the canvas, with each key press the focusables array is rebuild (despite it only changing if the canvas actually changes).

It's not critical at this stage, but this could get costly on a very large canvas with many cards. Can we create a tracker item to update this to cache it (and invalidate the cache whenever the canvas changes)?

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 });
};
};
14 changes: 11 additions & 3 deletions dataweaver/apps/web/src/hooks/use_focus_trap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([tabindex='-1']):not(:disabled)",
"select:not([tabindex='-1']):not(:disabled)",
"textarea:not([tabindex='-1']):not(:disabled)",
"[tabindex]:not([tabindex='-1'])",
].join(', ');
Comment thread
PauloMFJ marked this conversation as resolved.

interface Config {
/**
Expand Down Expand Up @@ -67,7 +73,9 @@ export const useFocusTrap = <TElement extends HTMLElement | null>(
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
Expand Down
2 changes: 1 addition & 1 deletion dataweaver/apps/web/src/styles/layers.css
Original file line number Diff line number Diff line change
@@ -1 +1 @@
@layer reset, root, base, primitive;
@layer reset, root, primitive, base;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's content in the FRONTEND.md documentation that refers to the order and still documents it the other way around.