-
Notifications
You must be signed in to change notification settings - Fork 28
Improve Canvas Tab Support #399
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<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 — 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 <dialog> 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; | ||
|
PauloMFJ marked this conversation as resolved.
|
||
| } | ||
|
|
||
| const focusables = getCardFocusables(editor); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }); | ||
| }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| @layer reset, root, base, primitive; | ||
| @layer reset, root, primitive, base; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
There was a problem hiding this comment.
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).