UNPKG

@kobalte/core

Version:

Unstyled components and primitives for building accessible web apps and design systems with SolidJS.

547 lines (457 loc) 15.5 kB
/* * Portions of this file are based on code from react-spectrum. * Apache License Version 2.0, Copyright 2020 Adobe. * * Credits to the React Spectrum team: * https://github.com/adobe/react-spectrum/blob/8f2f2acb3d5850382ebe631f055f88c704aa7d17/packages/@react-aria/selection/src/useSelectableCollection.ts */ import { type MaybeAccessor, type Orientation, access, callHandler, createEventListener, focusWithoutScrolling, getFocusableTreeWalker, scrollIntoView, } from "@kobalte/utils"; import { type Accessor, type JSX, createEffect, createMemo, mergeProps, on, onMount, } from "solid-js"; import { useLocale } from "../i18n"; import { createTypeSelect } from "./create-type-select"; import type { FocusStrategy, KeyboardDelegate, MultipleSelectionManager, } from "./types"; import { isCtrlKeyPressed, isNonContiguousSelectionModifier } from "./utils"; interface CreateSelectableCollectionProps { /** An interface for reading and updating multiple selection state. */ selectionManager: MaybeAccessor<MultipleSelectionManager>; /** A delegate object that implements behavior for keyboard focus movement. */ keyboardDelegate: MaybeAccessor<KeyboardDelegate>; /** Whether the collection or one of its items should be automatically focused upon render. */ autoFocus?: MaybeAccessor<boolean | FocusStrategy | undefined>; /** Whether the autofocus should run on next tick. */ deferAutoFocus?: MaybeAccessor<boolean | undefined>; /** Whether focus should wrap around when the end/start is reached. */ shouldFocusWrap?: MaybeAccessor<boolean | undefined>; /** Whether the collection allows empty selection. */ disallowEmptySelection?: MaybeAccessor<boolean | undefined>; /** Whether the collection allows the user to select all items via keyboard shortcut. */ disallowSelectAll?: MaybeAccessor<boolean | undefined>; /** Whether selection should occur automatically on focus. */ selectOnFocus?: MaybeAccessor<boolean | undefined>; /** Whether typeahead is disabled. */ disallowTypeAhead?: MaybeAccessor<boolean | undefined>; /** Whether the collection items should use virtual focus instead of being focused directly. */ shouldUseVirtualFocus?: MaybeAccessor<boolean | undefined>; /** Whether navigation through tab key is enabled. */ allowsTabNavigation?: MaybeAccessor<boolean | undefined>; /** Whether the collection items are contained in a virtual scroller. */ isVirtualized?: MaybeAccessor<boolean | undefined>; /** When virtualized, the Virtualizer function used to scroll to the item of the key provided. */ scrollToKey?: (key: string) => void; /** The orientation of the selectable collection interactions. */ orientation?: MaybeAccessor<Orientation | undefined>; } /** * Handles interactions with selectable collections. * @param props Props for the collection. * @param ref The ref attached to the element representing the collection. * @param scrollRef The ref attached to the scrollable body. Used to provide automatic scrolling on item focus for non-virtualized collections. If not provided, defaults to the collection ref. */ export function createSelectableCollection< T extends HTMLElement, U extends HTMLElement = T, >( props: CreateSelectableCollectionProps, ref: Accessor<T | undefined>, scrollRef?: Accessor<U | undefined>, ) { const defaultProps: Partial<CreateSelectableCollectionProps> = { selectOnFocus: () => access(props.selectionManager).selectionBehavior() === "replace", }; const mergedProps = mergeProps(defaultProps, props); const finalScrollRef = () => scrollRef?.() ?? ref(); const { direction } = useLocale(); // Store the scroll position, so we can restore it later. let scrollPos = { top: 0, left: 0 }; createEventListener( () => (!access(mergedProps.isVirtualized) ? finalScrollRef() : undefined), "scroll", () => { const scrollEl = finalScrollRef(); if (!scrollEl) { return; } scrollPos = { top: scrollEl.scrollTop, left: scrollEl.scrollLeft, }; }, ); const { typeSelectHandlers } = createTypeSelect({ isDisabled: () => access(mergedProps.disallowTypeAhead), keyboardDelegate: () => access(mergedProps.keyboardDelegate), selectionManager: () => access(mergedProps.selectionManager), }); const orientation = () => access(mergedProps.orientation) ?? "vertical"; const onKeyDown: JSX.EventHandlerUnion<HTMLElement, KeyboardEvent> = (e) => { callHandler(e, typeSelectHandlers.onKeyDown); // Prevent option + tab from doing anything since it doesn't move focus to the cells, only buttons/checkboxes if (e.altKey && e.key === "Tab") { e.preventDefault(); } const refEl = ref(); // Keyboard events bubble through portals. Don't handle keyboard events // for elements outside the collection (e.g. menus). if (!refEl?.contains(e.target as HTMLElement)) { return; } const manager = access(mergedProps.selectionManager); const selectOnFocus = access(mergedProps.selectOnFocus); const navigateToKey = (key: string | undefined) => { if (key != null) { manager.setFocusedKey(key); if (e.shiftKey && manager.selectionMode() === "multiple") { manager.extendSelection(key); } else if (selectOnFocus && !isNonContiguousSelectionModifier(e)) { manager.replaceSelection(key); } } }; const delegate = access(mergedProps.keyboardDelegate); const shouldFocusWrap = access(mergedProps.shouldFocusWrap); const focusedKey = manager.focusedKey(); switch (e.key) { case orientation() === "vertical" ? "ArrowDown" : "ArrowRight": { if (delegate.getKeyBelow) { e.preventDefault(); let nextKey: string | undefined; if (focusedKey != null) { nextKey = delegate.getKeyBelow(focusedKey); } else { nextKey = delegate.getFirstKey?.(); } if (nextKey == null && shouldFocusWrap) { nextKey = delegate.getFirstKey?.(focusedKey); } navigateToKey(nextKey); } break; } case orientation() === "vertical" ? "ArrowUp" : "ArrowLeft": { if (delegate.getKeyAbove) { e.preventDefault(); let nextKey: string | undefined; if (focusedKey != null) { nextKey = delegate.getKeyAbove(focusedKey); } else { nextKey = delegate.getLastKey?.(); } if (nextKey == null && shouldFocusWrap) { nextKey = delegate.getLastKey?.(focusedKey); } navigateToKey(nextKey); } break; } case orientation() === "vertical" ? "ArrowLeft" : "ArrowUp": { if (delegate.getKeyLeftOf) { e.preventDefault(); const isRTL = direction() === "rtl"; let nextKey: string | undefined; if (focusedKey != null) { nextKey = delegate.getKeyLeftOf(focusedKey); } else { nextKey = isRTL ? delegate.getFirstKey?.() : delegate.getLastKey?.(); } navigateToKey(nextKey); } break; } case orientation() === "vertical" ? "ArrowRight" : "ArrowDown": { if (delegate.getKeyRightOf) { e.preventDefault(); const isRTL = direction() === "rtl"; let nextKey: string | undefined; if (focusedKey != null) { nextKey = delegate.getKeyRightOf(focusedKey); } else { nextKey = isRTL ? delegate.getLastKey?.() : delegate.getFirstKey?.(); } navigateToKey(nextKey); } break; } case "Home": if (delegate.getFirstKey) { e.preventDefault(); const firstKey = delegate.getFirstKey( focusedKey, isCtrlKeyPressed(e), ); if (firstKey != null) { manager.setFocusedKey(firstKey); if ( isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode() === "multiple" ) { manager.extendSelection(firstKey); } else if (selectOnFocus) { manager.replaceSelection(firstKey); } } } break; case "End": if (delegate.getLastKey) { e.preventDefault(); const lastKey = delegate.getLastKey(focusedKey, isCtrlKeyPressed(e)); if (lastKey != null) { manager.setFocusedKey(lastKey); if ( isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode() === "multiple" ) { manager.extendSelection(lastKey); } else if (selectOnFocus) { manager.replaceSelection(lastKey); } } } break; case "PageDown": if (delegate.getKeyPageBelow && focusedKey != null) { e.preventDefault(); const nextKey = delegate.getKeyPageBelow(focusedKey); navigateToKey(nextKey); } break; case "PageUp": if (delegate.getKeyPageAbove && focusedKey != null) { e.preventDefault(); const nextKey = delegate.getKeyPageAbove(focusedKey); navigateToKey(nextKey); } break; case "a": if ( isCtrlKeyPressed(e) && manager.selectionMode() === "multiple" && access(mergedProps.disallowSelectAll) !== true ) { e.preventDefault(); manager.selectAll(); } break; case "Escape": if (!e.defaultPrevented) { e.preventDefault(); if (!access(mergedProps.disallowEmptySelection)) { manager.clearSelection(); } } break; case "Tab": { if (!access(mergedProps.allowsTabNavigation)) { // There may be elements that are "tabbable" inside a collection (e.g. in a grid cell). // However, collections should be treated as a single tab stop, with arrow key navigation internally. // We don't control the rendering of these, so we can't override the tabIndex to prevent tabbing. // Instead, we handle the Tab key, and move focus manually to the first/last tabbable element // in the collection, so that the browser default behavior will apply starting from that element // rather than the currently focused one. if (e.shiftKey) { refEl.focus(); } else { const walker = getFocusableTreeWalker(refEl, { tabbable: true }); let next: HTMLElement | undefined; let last: HTMLElement | undefined; do { last = walker.lastChild() as HTMLElement; if (last) { next = last; } } while (last); if (next && !next.contains(document.activeElement)) { focusWithoutScrolling(next); } } break; } } } }; const onFocusIn: JSX.EventHandlerUnion<HTMLElement, FocusEvent> = (e) => { const manager = access(mergedProps.selectionManager); const delegate = access(mergedProps.keyboardDelegate); const selectOnFocus = access(mergedProps.selectOnFocus); if (manager.isFocused()) { // If a focus event bubbled through a portal, reset focus state. if (!e.currentTarget.contains(e.target)) { manager.setFocused(false); } return; } // Focus events can bubble through portals. Ignore these events. if (!e.currentTarget.contains(e.target)) { return; } manager.setFocused(true); if (manager.focusedKey() == null) { const navigateToFirstKey = (key: string | undefined) => { if (key == null) { return; } manager.setFocusedKey(key); if (selectOnFocus) { manager.replaceSelection(key); } }; // If the user hasn't yet interacted with the collection, there will be no focusedKey set. // Attempt to detect whether the user is tabbing forward or backward into the collection // and either focus the first or last item accordingly. const relatedTarget = e.relatedTarget as Element; if ( relatedTarget && e.currentTarget.compareDocumentPosition(relatedTarget) & Node.DOCUMENT_POSITION_FOLLOWING ) { navigateToFirstKey( manager.lastSelectedKey() ?? delegate.getLastKey?.(), ); } else { navigateToFirstKey( manager.firstSelectedKey() ?? delegate.getFirstKey?.(), ); } } else if (!access(mergedProps.isVirtualized)) { const scrollEl = finalScrollRef(); if (scrollEl) { // Restore the scroll position to what it was before. scrollEl.scrollTop = scrollPos.top; scrollEl.scrollLeft = scrollPos.left; // Refocus and scroll the focused item into view if it exists within the scrollable region. const element = scrollEl.querySelector( `[data-key="${manager.focusedKey()}"]`, ); if (element) { // This prevents a flash of focus on the first/last element in the collection focusWithoutScrolling(element as HTMLElement); scrollIntoView(scrollEl, element as HTMLElement); } } } }; const onFocusOut: JSX.EventHandlerUnion<HTMLElement, FocusEvent> = (e) => { const manager = access(mergedProps.selectionManager); // Don't set blurred and then focused again if moving focus within the collection. if (!e.currentTarget.contains(e.relatedTarget as HTMLElement)) { manager.setFocused(false); } }; const onMouseDown: JSX.EventHandlerUnion<HTMLElement, MouseEvent> = (e) => { // Ignore events that bubbled through portals. if (finalScrollRef() === e.target) { // Prevent focus going to the collection when clicking on the scrollbar. e.preventDefault(); } }; const tryAutoFocus = () => { const autoFocus = access(mergedProps.autoFocus); if (!autoFocus) { return; } const manager = access(mergedProps.selectionManager); const delegate = access(mergedProps.keyboardDelegate); let focusedKey: string | undefined; // Check focus strategy to determine which item to focus if (autoFocus === "first") { focusedKey = delegate.getFirstKey?.(); } if (autoFocus === "last") { focusedKey = delegate.getLastKey?.(); } // If there are any selected keys, make the first one the new focus target const selectedKeys = manager.selectedKeys(); if (selectedKeys.size) { focusedKey = selectedKeys.values().next().value; } manager.setFocused(true); manager.setFocusedKey(focusedKey); const refEl = ref(); // If no default focus key is selected, focus the collection itself. if ( refEl && focusedKey == null && !access(mergedProps.shouldUseVirtualFocus) ) { focusWithoutScrolling(refEl); } }; onMount(() => { if (mergedProps.deferAutoFocus) { setTimeout(tryAutoFocus, 0); // TODO: does this work EVERY time ? } else { tryAutoFocus(); } }); // If not virtualized, scroll the focused element into view when the focusedKey changes. // When virtualized, the Virtualizer should handle this. createEffect( on( [ finalScrollRef, () => access(mergedProps.isVirtualized), () => access(mergedProps.selectionManager).focusedKey(), ], (newValue) => { const [scrollEl, isVirtualized, focusedKey] = newValue; if (isVirtualized) { focusedKey && mergedProps.scrollToKey?.(focusedKey); } else { if (focusedKey && scrollEl) { const element = scrollEl.querySelector( `[data-key="${focusedKey}"]`, ); if (element) { scrollIntoView(scrollEl, element as HTMLElement); } } } }, ), ); // If nothing is focused within the collection, make the collection itself tabbable. // This will be marshalled to either the first or last item depending on where focus came from. // If using virtual focus, don't set a tabIndex at all so that VoiceOver on iOS 14 doesn't try // to move real DOM focus to the element anyway. const tabIndex = createMemo(() => { if (access(mergedProps.shouldUseVirtualFocus)) { return undefined; } return access(mergedProps.selectionManager).focusedKey() == null ? 0 : -1; }); return { tabIndex, onKeyDown, onMouseDown, onFocusIn, onFocusOut, }; }