Skip to content

Commit

Permalink
TS Strict Stately List (adobe#6567)
Browse files Browse the repository at this point in the history
* TS Strict Stately List
  • Loading branch information
snowystinger authored Aug 21, 2024
1 parent 69cc445 commit 8f3c0ea
Show file tree
Hide file tree
Showing 14 changed files with 123 additions and 84 deletions.
2 changes: 1 addition & 1 deletion packages/@react-aria/tabs/src/useTabPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export interface TabPanelAria {
* Provides the behavior and accessibility implementation for a tab panel. A tab panel is a container for
* the contents of a tab, and is shown when the tab is selected.
*/
export function useTabPanel<T>(props: AriaTabPanelProps, state: TabListState<T>, ref: RefObject<Element | null>): TabPanelAria {
export function useTabPanel<T>(props: AriaTabPanelProps, state: TabListState<T> | null, ref: RefObject<Element | null>): TabPanelAria {
// The tabpanel should have tabIndex=0 when there are no tabbable elements within it.
// Otherwise, tabbing from the focused tab should go directly to the first tabbable element
// within the tabpanel.
Expand Down
6 changes: 5 additions & 1 deletion packages/@react-aria/tabs/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import {TabListState} from '@react-stately/tabs';

export const tabsIds = new WeakMap<TabListState<unknown>, string>();

export function generateId<T>(state: TabListState<T>, key: Key, role: string) {
export function generateId<T>(state: TabListState<T> | null, key: Key | null | undefined, role: string) {
if (!state) {
// this case should only happen in the first render before the tabs are registered
return '';
}
if (typeof key === 'string') {
key = key.replace(/\s+/g, '');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ function _SearchAutocompleteBase<T extends object>(props: SpectrumSearchAutocomp
{...listBoxProps}
ref={listBoxRef}
disallowEmptySelection
autoFocus={state.focusStrategy}
autoFocus={state.focusStrategy ?? undefined}
shouldSelectOnPressUp
focusOnPointerEnter
layout={layout}
Expand Down
75 changes: 38 additions & 37 deletions packages/@react-spectrum/tabs/src/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@

import {AriaTabPanelProps, SpectrumTabListProps, SpectrumTabPanelsProps, SpectrumTabsProps} from '@react-types/tabs';
import {classNames, SlotProvider, unwrapDOMRef, useDOMRef, useStyleProps} from '@react-spectrum/utils';
import {DOMProps, DOMRef, Key, Node, Orientation, StyleProps} from '@react-types/shared';
import {DOMProps, DOMRef, DOMRefValue, Key, Node, Orientation, RefObject, StyleProps} from '@react-types/shared';
import {filterDOMProps, mergeProps, useId, useLayoutEffect, useResizeObserver} from '@react-aria/utils';
import {FocusRing} from '@react-aria/focus';
import {Item, Picker} from '@react-spectrum/picker';
import {ListCollection} from '@react-stately/list';
import React, {
MutableRefObject,
CSSProperties,
HTMLAttributes,
ReactElement,
ReactNode,
useCallback,
Expand All @@ -40,22 +41,20 @@ import {useTab, useTabList, useTabPanel} from '@react-aria/tabs';
interface TabsContext<T> {
tabProps: SpectrumTabsProps<T>,
tabState: {
tabListState: TabListState<T>,
tabListState: TabListState<T> | null,
setTabListState: (state: TabListState<T>) => void,
selectedTab: HTMLElement,
selectedTab: HTMLElement | null,
collapsed: boolean
},
refs: {
wrapperRef: MutableRefObject<HTMLDivElement>,
tablistRef: MutableRefObject<HTMLDivElement>
},
tabPanelProps: {
'aria-labelledby': string
wrapperRef: RefObject<HTMLDivElement | null>,
tablistRef: RefObject<HTMLDivElement | null>
},
tabPanelProps: HTMLAttributes<HTMLElement>,
tabLineState: Array<DOMRect>
}

const TabContext = React.createContext<TabsContext<any>>(null);
const TabContext = React.createContext<TabsContext<any> | null>(null);

function Tabs<T extends object>(props: SpectrumTabsProps<T>, ref: DOMRef<HTMLDivElement>) {
props = useProviderProps(props);
Expand All @@ -67,20 +66,20 @@ function Tabs<T extends object>(props: SpectrumTabsProps<T>, ref: DOMRef<HTMLDiv
} = props;

let domRef = useDOMRef(ref);
let tablistRef = useRef<HTMLDivElement>(undefined);
let wrapperRef = useRef<HTMLDivElement>(undefined);
let tablistRef = useRef<HTMLDivElement>(null);
let wrapperRef = useRef<HTMLDivElement>(null);

let {direction} = useLocale();
let {styleProps} = useStyleProps(otherProps);
let [collapsed, setCollapsed] = useState(false);
let [selectedTab, setSelectedTab] = useState<HTMLElement>();
const [tabListState, setTabListState] = useState<TabListState<T>>(null);
let [tabPositions, setTabPositions] = useState([]);
let prevTabPositions = useRef(tabPositions);
let [selectedTab, setSelectedTab] = useState<HTMLElement | null>(null);
const [tabListState, setTabListState] = useState<TabListState<T> | null>(null);
let [tabPositions, setTabPositions] = useState<DOMRect[]>([]);
let prevTabPositions = useRef<DOMRect[]>(tabPositions);

useEffect(() => {
if (tablistRef.current) {
let selectedTab: HTMLElement = tablistRef.current.querySelector(`[data-key="${CSS.escape(tabListState?.selectedKey?.toString())}"]`);
let selectedTab: HTMLElement | null = tablistRef.current.querySelector(`[data-key="${CSS.escape(tabListState?.selectedKey?.toString() ?? '')}"]`);

if (selectedTab != null) {
setSelectedTab(selectedTab);
Expand All @@ -92,15 +91,16 @@ function Tabs<T extends object>(props: SpectrumTabsProps<T>, ref: DOMRef<HTMLDiv
let checkShouldCollapse = useCallback(() => {
if (wrapperRef.current && orientation !== 'vertical') {
let tabsComponent = wrapperRef.current;
let tabs = tablistRef.current.querySelectorAll('[role="tab"]');
let tabDimensions = [...tabs].map(tab => tab.getBoundingClientRect());
let tabs: NodeListOf<Element> = tablistRef.current?.querySelectorAll('[role="tab"]') ?? new NodeList() as NodeListOf<Element>;
let tabDimensions = [...tabs].map((tab: Element) => tab.getBoundingClientRect());

let end = direction === 'rtl' ? 'left' : 'right';
let farEdgeTabList = tabsComponent.getBoundingClientRect()[end];
let farEdgeLastTab = tabDimensions[tabDimensions.length - 1][end];
let shouldCollapse = direction === 'rtl' ? farEdgeLastTab < farEdgeTabList : farEdgeTabList < farEdgeLastTab;
setCollapsed(shouldCollapse);
if (tabDimensions.length !== prevTabPositions.current.length || tabDimensions.some((box, index) => box?.left !== prevTabPositions.current[index]?.left || box?.right !== prevTabPositions.current[index]?.right)) {
if (tabDimensions.length !== prevTabPositions.current.length
|| tabDimensions.some((box, index) => box?.left !== prevTabPositions.current[index]?.left || box?.right !== prevTabPositions.current[index]?.right)) {
setTabPositions(tabDimensions);
prevTabPositions.current = tabDimensions;
}
Expand All @@ -113,7 +113,7 @@ function Tabs<T extends object>(props: SpectrumTabsProps<T>, ref: DOMRef<HTMLDiv

useResizeObserver({ref: wrapperRef, onResize: checkShouldCollapse});

let tabPanelProps = {
let tabPanelProps: HTMLAttributes<HTMLElement> = {
'aria-labelledby': undefined
};

Expand Down Expand Up @@ -202,8 +202,8 @@ function Tab<T>(props: TabProps<T>) {

interface TabLineProps {
orientation?: Orientation,
selectedTab?: HTMLElement,
selectedKey?: Key
selectedTab?: HTMLElement | null,
selectedKey?: Key | null
}

// @private
Expand All @@ -218,18 +218,20 @@ function TabLine(props: TabLineProps) {

let {direction} = useLocale();
let {scale} = useProvider();
let {tabLineState} = useContext(TabContext);
let {tabLineState} = useContext(TabContext)!;

let [style, setStyle] = useState({
let [style, setStyle] = useState<CSSProperties>({
width: undefined,
height: undefined
});

let onResize = useCallback(() => {
if (selectedTab) {
let styleObj = {transform: undefined, width: undefined, height: undefined};
let styleObj: CSSProperties = {transform: undefined, width: undefined, height: undefined};
// In RTL, calculate the transform from the right edge of the tablist so that resizing the window doesn't break the Tabline position due to offsetLeft changes
let offset = direction === 'rtl' ? -1 * ((selectedTab.offsetParent as HTMLElement)?.offsetWidth - selectedTab.offsetWidth - selectedTab.offsetLeft) : selectedTab.offsetLeft;
let offset = direction === 'rtl' ?
-1 * ((selectedTab.offsetParent as HTMLElement)?.offsetWidth - selectedTab.offsetWidth - selectedTab.offsetLeft) :
selectedTab.offsetLeft;
styleObj.transform = orientation === 'vertical'
? `translateY(${selectedTab.offsetTop}px)`
: `translateX(${offset}px)`;
Expand All @@ -255,7 +257,7 @@ function TabLine(props: TabLineProps) {
* The keys of the items within the <TabList> must match up with a corresponding item inside the <TabPanels>.
*/
export function TabList<T>(props: SpectrumTabListProps<T>) {
const tabContext = useContext(TabContext);
const tabContext = useContext(TabContext)!;
const {refs, tabState, tabProps, tabPanelProps} = tabContext;
const {isQuiet, density, isEmphasized, orientation} = tabProps;
const {selectedTab, collapsed, setTabListState} = tabState;
Expand Down Expand Up @@ -330,13 +332,13 @@ export function TabList<T>(props: SpectrumTabListProps<T>) {
* TabPanels is used within Tabs as a container for the content of each tab.
* The keys of the items within the <TabPanels> must match up with a corresponding item inside the <TabList>.
*/
export function TabPanels<T>(props: SpectrumTabPanelsProps<T>) {
const {tabState, tabProps} = useContext(TabContext);
export function TabPanels<T extends object>(props: SpectrumTabPanelsProps<T>) {
const {tabState, tabProps} = useContext(TabContext)!;
const {tabListState} = tabState;

const factory = useCallback(nodes => new ListCollection(nodes), []);
const factory = useCallback((nodes: Iterable<Node<T>>) => new ListCollection(nodes), []);
const collection = useCollection({items: tabProps.items, ...props}, factory, {suppressTextValueWarning: true});
const selectedItem = tabListState ? collection.getItem(tabListState.selectedKey) : null;
const selectedItem = tabListState && tabListState.selectedKey != null ? collection.getItem(tabListState.selectedKey) : null;

return (
<TabPanel {...props} key={tabListState?.selectedKey}>
Expand All @@ -351,9 +353,9 @@ interface TabPanelProps extends AriaTabPanelProps, StyleProps {

// @private
function TabPanel(props: TabPanelProps) {
const {tabState, tabPanelProps: ctxTabPanelProps} = useContext(TabContext);
const {tabState, tabPanelProps: ctxTabPanelProps} = useContext(TabContext)!;
const {tabListState} = tabState;
let ref = useRef(undefined);
let ref = useRef<HTMLDivElement | null>(null);
const {tabPanelProps} = useTabPanel(props, tabListState, ref);
let {styleProps} = useStyleProps(props);

Expand Down Expand Up @@ -392,8 +394,8 @@ function TabPicker<T>(props: TabPickerProps<T>) {
visible
} = props;

let ref = useRef(undefined);
let [pickerNode, setPickerNode] = useState(null);
let ref = useRef<DOMRefValue<HTMLDivElement>>(null);
let [pickerNode, setPickerNode] = useState<HTMLElement | null>(null);

useEffect(() => {
let node = unwrapDOMRef(ref);
Expand All @@ -408,7 +410,6 @@ function TabPicker<T>(props: TabPickerProps<T>) {

const style : React.CSSProperties = visible ? {} : {visibility: 'hidden', position: 'absolute'};

// TODO: Figure out if tabListProps should go onto the div here, v2 doesn't do it
return (
<div
className={classNames(
Expand Down
46 changes: 31 additions & 15 deletions packages/@react-stately/combobox/src/useComboBoxState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface ComboBoxState<T> extends SelectState<T>, FormValidationState{
/** Selects the currently focused item and updates the input value. */
commit(): void,
/** Controls which item will be auto focused when the menu opens. */
readonly focusStrategy: FocusStrategy,
readonly focusStrategy: FocusStrategy | null,
/** Opens the menu. */
open(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void,
/** Toggles the menu. */
Expand Down Expand Up @@ -64,7 +64,7 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T

let [showAllItems, setShowAllItems] = useState(false);
let [isFocused, setFocusedState] = useState(false);
let [focusStrategy, setFocusStrategy] = useState<FocusStrategy>(null);
let [focusStrategy, setFocusStrategy] = useState<FocusStrategy | null>(null);

let onSelectionChange = (key) => {
if (props.onSelectionChange) {
Expand All @@ -79,15 +79,29 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
}
};

let {collection, selectionManager, selectedKey, setSelectedKey, selectedItem, disabledKeys} = useSingleSelectListState({
let {collection,
selectionManager,
selectedKey,
setSelectedKey,
selectedItem,
disabledKeys
} = useSingleSelectListState({
...props,
onSelectionChange,
items: props.items ?? props.defaultItems
});
let defaultInputValue: string | null | undefined = props.defaultInputValue;
if (defaultInputValue == null) {
if (selectedKey == null) {
defaultInputValue = '';
} else {
defaultInputValue = collection.getItem(selectedKey)?.textValue ?? '';
}
}

let [inputValue, setInputValue] = useControlledState(
props.inputValue,
props.defaultInputValue ?? collection.getItem(selectedKey)?.textValue ?? '',
defaultInputValue!,
props.onInputChange
);

Expand All @@ -102,7 +116,7 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
let [lastCollection, setLastCollection] = useState(filteredCollection);

// Track what action is attempting to open the menu
let menuOpenTrigger = useRef('focus' as MenuTriggerAction);
let menuOpenTrigger = useRef<MenuTriggerAction | undefined>('focus');
let onOpenChange = (open: boolean) => {
if (props.onOpenChange) {
props.onOpenChange(open, open ? menuOpenTrigger.current : undefined);
Expand All @@ -115,7 +129,7 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
};

let triggerState = useOverlayTriggerState({...props, onOpenChange, isOpen: undefined, defaultOpen: undefined});
let open = (focusStrategy: FocusStrategy = null, trigger?: MenuTriggerAction) => {
let open = (focusStrategy: FocusStrategy | null = null, trigger?: MenuTriggerAction) => {
let displayAllItems = (trigger === 'manual' || (trigger === 'focus' && menuTrigger === 'focus'));
// Prevent open operations from triggering if there is nothing to display
// Also prevent open operations from triggering if items are uncontrolled but defaultItems is empty, even if displayAllItems is true.
Expand All @@ -132,7 +146,7 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
}
};

let toggle = (focusStrategy: FocusStrategy = null, trigger?: MenuTriggerAction) => {
let toggle = (focusStrategy: FocusStrategy | null = null, trigger?: MenuTriggerAction) => {
let displayAllItems = (trigger === 'manual' || (trigger === 'focus' && menuTrigger === 'focus'));
// If the menu is closed and there is nothing to display, early return so toggle isn't called to prevent extraneous onOpenChange
if (!(allowsEmptyCollection || filteredCollection.size > 0 || (displayAllItems && originalCollection.size > 0) || props.items) && !triggerState.isOpen) {
Expand All @@ -158,7 +172,7 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T

// If menu is going to close, save the current collection so we can freeze the displayed collection when the
// user clicks outside the popover to close the menu. Prevents the menu contents from updating as the menu closes.
let toggleMenu = useCallback((focusStrategy: FocusStrategy = null) => {
let toggleMenu = useCallback((focusStrategy: FocusStrategy | null = null) => {
if (triggerState.isOpen) {
updateLastCollection();
}
Expand All @@ -176,13 +190,15 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T

let [lastValue, setLastValue] = useState(inputValue);
let resetInputValue = () => {
let itemText = collection.getItem(selectedKey)?.textValue ?? '';
let itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : '';
setLastValue(itemText);
setInputValue(itemText);
};

let lastSelectedKey = useRef(props.selectedKey ?? props.defaultSelectedKey ?? null);
let lastSelectedKeyText = useRef(collection.getItem(selectedKey)?.textValue ?? '');
let lastSelectedKeyText = useRef(
selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : ''
);
// intentional omit dependency array, want this to happen on every render
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
Expand Down Expand Up @@ -245,7 +261,7 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
// This is to handle cases where a selectedKey is specified but the items aren't available (async loading) or the selected item's text value updates.
// Only reset if the user isn't currently within the field so we don't erroneously modify user input.
// If inputValue is controlled, it is the user's responsibility to update the inputValue when items change.
let selectedItemText = collection.getItem(selectedKey)?.textValue ?? '';
let selectedItemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : '';
if (!isFocused && selectedKey != null && props.inputValue === undefined && selectedKey === lastSelectedKey.current) {
if (lastSelectedKeyText.current !== selectedItemText) {
setLastValue(selectedItemText);
Expand Down Expand Up @@ -280,10 +296,10 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
let commitSelection = () => {
// If multiple things are controlled, call onSelectionChange
if (props.selectedKey !== undefined && props.inputValue !== undefined) {
props.onSelectionChange(selectedKey);
props.onSelectionChange?.(selectedKey);

// Stop menu from reopening from useEffect
let itemText = collection.getItem(selectedKey)?.textValue ?? '';
let itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : '';
setLastValue(itemText);
closeMenu();
} else {
Expand All @@ -295,7 +311,7 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T

const commitValue = () => {
if (allowsCustomValue) {
const itemText = collection.getItem(selectedKey)?.textValue ?? '';
const itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : '';
(inputValue === itemText) ? commitSelection() : commitCustomValue();
} else {
// Reset inputValue and close menu
Expand Down Expand Up @@ -376,7 +392,7 @@ function filterCollection<T extends object>(collection: Collection<Node<T>>, inp
}

function filterNodes<T>(collection: Collection<Node<T>>, nodes: Iterable<Node<T>>, inputValue: string, filter: FilterFn): Iterable<Node<T>> {
let filteredNode = [];
let filteredNode: Node<T>[] = [];
for (let node of nodes) {
if (node.type === 'section' && node.hasChildNodes) {
let filtered = filterNodes(collection, getChildNodes(node, collection), inputValue, filter);
Expand Down
Loading

0 comments on commit 8f3c0ea

Please sign in to comment.