import React from 'react'; import { defaultGetId, useListModel, useOverflowListModel, } from '@workday/canvas-kit-react/collection'; import {createModelHook, slugify, useModalityType} from '@workday/canvas-kit-react/common'; import {useMenuModel} from '@workday/canvas-kit-react/menu'; /** * This constant is used as the stable identifier for the overflow button in the tabs collection. * It uses a `__` prefix to avoid conflicts with user-provided item IDs. */ export const TABS_OVERFLOW_BUTTON_ID = '__tabs-overflow__'; /** * The TabsModel extends the [Collection * System](/get-started/for-developers/guides/collection-api/). Tabs have tab items and * panels. Tabs can be overflowed if there isn't enough room to render and will overflow to a * {@link MenuModel} sub-model. * * ```tsx * const model = useTabsModel({ * onSelect(data) { * console.log('Selected Tab', data) * } * }) * * {Tabs child components} * ``` */ export const useTabsModel = createModelHook({ defaultConfig: { ...useOverflowListModel.defaultConfig, /** * Optional id for the whole `Tabs` group. The `aria-controls` of the `Tab.Item` and `id` of the * `Tab.Panel` will automatically derived from this id. If not provided, a unique id will be * created. * @default useUniqueId() */ id: '', /** * An initially selected tab. This value must match the `name` of the `Tab.Item` component. If * not provided, the first tab will be selected. */ initialTab: '', /** * The default Tabs sub-components only handle rendering of tabs in a horizontal orientation, but * the sub-components could be replaced to handle vertical orientations. * @default 'horizontal' */ orientation: 'horizontal' as typeof useOverflowListModel.defaultConfig.orientation, menuConfig: {} as typeof useMenuModel.TConfig, }, requiredConfig: useOverflowListModel.requiredConfig, contextOverride: useOverflowListModel.Context, })(config => { const initialSelectedRef = React.useRef(config.initialTab); const userGetId = config.getId || defaultGetId; const modality = useModalityType(); // Stable object so getId can recognize the synthetic overflow item. Callers // often pass a getId that only understands their own item shape (for example // `item => item.contextId`). Without this, the overflow item gets an empty id // and Tabs.List renders it as a normal tab. const overflowItemRef = React.useRef({ id: TABS_OVERFLOW_BUTTON_ID, text: 'More', }); const getId = (item: Parameters[0]) => { if (item === overflowItemRef.current) { return TABS_OVERFLOW_BUTTON_ID; } const id = userGetId(item); if (id === TABS_OVERFLOW_BUTTON_ID) { throw new Error( `Tabs item id "${TABS_OVERFLOW_BUTTON_ID}" is reserved for the overflow tab. Choose a different id.` ); } return id; }; // When using dynamic items, append a synthetic overflow button item to the // items array. This ensures the overflow button is included in the navigation // sequence for roving tabindex. The overflow button component will handle // conditional rendering (not showing when there are no hidden items). // The overflow button is NOT included in itemSizeCache (measured separately // via setOverflowTargetSize), so it won't be incorrectly hidden. const itemsWithOverflowButton = React.useMemo(() => { if (config.items?.length) { return [...config.items, overflowItemRef.current]; } return config.items; }, [config.items]); // Track whether the overflow button should be non-interactive based on previous // render's hiddenIds. We use a ref because hiddenIds isn't available until after // the model is created, but we need to pass nonInteractiveIds to the model config. // On first render, assume non-interactive (no overflow calculated yet). // On subsequent renders, use the previous hiddenIds.length to determine. const prevHiddenIdsLengthRef = React.useRef(0); const [, setNonInteractiveTick] = React.useState(0); const overflowButtonNonInteractive = !!config.items?.length && prevHiddenIdsLengthRef.current === 0; // Compute nonInteractiveIds to pass to the model, including overflow button when appropriate const nonInteractiveIdsForModel = React.useMemo(() => { const baseIds = config.nonInteractiveIds || []; if (overflowButtonNonInteractive) { return [...baseIds, TABS_OVERFLOW_BUTTON_ID]; } return baseIds; }, [config.nonInteractiveIds, overflowButtonNonInteractive]); // Stores the ID of a tab that should receive focus after the menu closes. // This is used when selecting a tab from the overflow menu - we want focus // to go to the newly selected tab, not back to the overflow button. const pendingFocusTabIdRef = React.useRef(null); const pendingFocusRafOuterRef = React.useRef(0); const pendingFocusRafInnerRef = React.useRef(0); React.useLayoutEffect(() => { return () => { cancelAnimationFrame(pendingFocusRafOuterRef.current); cancelAnimationFrame(pendingFocusRafInnerRef.current); }; }, []); const model = useOverflowListModel( useOverflowListModel.mergeConfig(config, { items: itemsWithOverflowButton, getId, nonInteractiveIds: nonInteractiveIdsForModel, shouldCalculateOverflow: modality !== 'touch', orientation: config.orientation || 'horizontal', onRegisterItem(data) { if (!initialSelectedRef.current) { initialSelectedRef.current = data.id; events.select({id: initialSelectedRef.current}); } }, initialSelectedIds: config.initialTab ? [config.initialTab] : config.items?.length ? [getId(config.items![0])] : [], shouldVirtualize: false, }) ); // Update the ref after each render so the next render knows the current hiddenIds.length. // If hiddenIds.length changed (e.g. overflow just calculated), force a re-render so // we pass the correct nonInteractiveIds and the overflow button is not stuck disabled. React.useLayoutEffect(() => { const nextLen = model.state.hiddenIds.length; if (prevHiddenIdsLengthRef.current !== nextLen) { prevHiddenIdsLengthRef.current = nextLen; setNonInteractiveTick(t => t + 1); } else { prevHiddenIdsLengthRef.current = nextLen; } }, [model.state.hiddenIds.length]); // When the cursor is on an item that isn't in the DOM, move it to a visible one so the // roving tabindex lands on a focusable element. This covers: (1) cursor on a hidden tab when // only "More" is visible, and (2) cursor on the overflow button when we widen and it unmounts. React.useLayoutEffect(() => { const {cursorId, hiddenIds, items} = model.state; if (!cursorId) { return; } const currentCursorId = typeof cursorId === 'string' ? cursorId : (cursorId.slice(-1)[0] ?? ''); const cursorOnHidden = currentCursorId ? hiddenIds.includes(currentCursorId) : false; const cursorOnOverflowWhenNoOverflow = currentCursorId === TABS_OVERFLOW_BUTTON_ID && hiddenIds.length === 0; if (cursorOnHidden) { // Move to first visible item (including overflow button when it's the only one visible) const firstVisible = items.find(item => !hiddenIds.includes(item.id)); if (firstVisible) { model.events.goTo({id: firstVisible.id}); } } else if (cursorOnOverflowWhenNoOverflow) { // Overflow button unmounted; move to selected tab or first tab (not the overflow button) const selectedIds = model.state.selectedIds; const selectedId = selectedIds !== 'all' && Array.isArray(selectedIds) && selectedIds.length ? selectedIds[0] : undefined; const firstVisibleTab = items.find( item => item.id !== TABS_OVERFLOW_BUTTON_ID && !hiddenIds.includes(item.id) ); const targetId = selectedId && !hiddenIds.includes(selectedId) ? selectedId : firstVisibleTab?.id; if (targetId) { model.events.goTo({id: targetId}); } } // Use field-level deps instead of `model.state` so this does not re-run on every model update. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ model.state.cursorId, model.state.hiddenIds, model.state.items, model.state.selectedIds, model.events, ]); const panels = useListModel(); const state = { ...model.state, getId, orientation: config.orientation || 'horizontal', /** * A list of panels. Uses `ListModel` */ panels: panels.state.items, /** * A React.Ref of the current item index. A ref is used to allow for updating outside the normal * React state cycle to ensure accurate index tracking as items are registered within the same * state setting phase. */ panelIndexRef: panels.state.indexRef, }; const overflowItems = React.useMemo( () => config.items ? config.items.filter(item => state.hiddenIds.includes(getId(item))) : undefined, // eslint-disable-next-line react-hooks/exhaustive-deps [state.hiddenIds, config.items] ); const events = { ...model.events, /** * This event registers panels with state.panels. Called when a panel is mounted. */ registerPanel: panels.events.registerItem, /** * This event unregisters panels with state.panels. Called when a panel is unmounted. */ unregisterPanel: panels.events.unregisterItem, }; const mergedMenuConfig = useMenuModel.mergeConfig(config.menuConfig, { id: `menu-${model.state.id}`, items: overflowItems, // `getId`/`getTextValue` aren't callbacks or guards, so `mergeConfig` would let these // unconditionally clobber a `menuConfig.getId`/`getTextValue` override. Fall back to the // top-level Tabs config only when the caller hasn't set one on `menuConfig` directly. getId: config.menuConfig?.getId || getId, getTextValue: config.menuConfig?.getTextValue || config.getTextValue, nonInteractiveIds: state.nonInteractiveIds.filter(key => !state.hiddenIds.includes(key)), onSelect(data) { // Store the tab ID to focus after the menu closes and React re-renders pendingFocusTabIdRef.current = data.id; // Update state: select the tab, move cursor, then close menu events.select(data); model.events.goTo({id: data.id}); menu.events.hide(); // Focus the selected tab AFTER React has re-rendered. // Double rAF is required because useReturnFocus restores focus to the overflow // button on a single rAF after hide; the second frame runs after that restore. pendingFocusRafOuterRef.current = requestAnimationFrame(() => { pendingFocusRafInnerRef.current = requestAnimationFrame(() => { const tabId = pendingFocusTabIdRef.current; if (tabId) { const tabElement = document.querySelector( `[data-focus-id="${slugify(`${model.state.id}-${tabId}`)}"]` ); if (tabElement) { tabElement.focus(); } pendingFocusTabIdRef.current = null; } }); }); }, onShow() { // Always select the first item when the menu is opened menu.events.goToFirst(); }, }); const menu = useMenuModel(mergedMenuConfig); return { ...model, state, events, menu, }; });