Skip to content

Commit

Permalink
Tabs: align to standard compound components structure (#66225)
Browse files Browse the repository at this point in the history
Co-authored-by: ciampo <[email protected]>
Co-authored-by: tyxla <[email protected]>
  • Loading branch information
3 people authored Oct 18, 2024
1 parent 5ee948a commit 240180a
Show file tree
Hide file tree
Showing 4 changed files with 191 additions and 172 deletions.
2 changes: 1 addition & 1 deletion packages/components/src/private-apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createPrivateSlotFill } from './slot-fill';
import { DropdownMenuV2 } from './dropdown-menu-v2';
import { ComponentsContext } from './context/context-system-provider';
import Theme from './theme';
import Tabs from './tabs';
import { Tabs } from './tabs';
import { kebabCase } from './utils/strings';
import { lock } from './lock-unlock';

Expand Down
355 changes: 186 additions & 169 deletions packages/components/src/tabs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,192 +25,209 @@ import { Tab } from './tab';
import { TabList } from './tablist';
import { TabPanel } from './tabpanel';

function Tabs( {
selectOnMove = true,
defaultTabId,
orientation = 'horizontal',
onSelect,
children,
selectedTabId,
}: TabsProps ) {
const instanceId = useInstanceId( Tabs, 'tabs' );
const store = Ariakit.useTabStore( {
selectOnMove,
orientation,
defaultSelectedId: defaultTabId && `${ instanceId }-${ defaultTabId }`,
setSelectedId: ( selectedId ) => {
const strippedDownId =
typeof selectedId === 'string'
? selectedId.replace( `${ instanceId }-`, '' )
: selectedId;
onSelect?.( strippedDownId );
},
selectedId: selectedTabId && `${ instanceId }-${ selectedTabId }`,
rtl: isRTL(),
} );

const isControlled = selectedTabId !== undefined;

const { items, selectedId, activeId } = useStoreState( store );
const { setSelectedId, setActiveId } = store;

// Keep track of whether tabs have been populated. This is used to prevent
// certain effects from firing too early while tab data and relevant
// variables are undefined during the initial render.
const tabsHavePopulatedRef = useRef( false );
if ( items.length > 0 ) {
tabsHavePopulatedRef.current = true;
}
/**
* Display one panel of content at a time with a tabbed interface, based on the
* WAI-ARIA Tabs Pattern⁠.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
* ```
*/
export const Tabs = Object.assign(
function Tabs( {
selectOnMove = true,
defaultTabId,
orientation = 'horizontal',
onSelect,
children,
selectedTabId,
}: TabsProps ) {
const instanceId = useInstanceId( Tabs, 'tabs' );
const store = Ariakit.useTabStore( {
selectOnMove,
orientation,
defaultSelectedId:
defaultTabId && `${ instanceId }-${ defaultTabId }`,
setSelectedId: ( selectedId ) => {
const strippedDownId =
typeof selectedId === 'string'
? selectedId.replace( `${ instanceId }-`, '' )
: selectedId;
onSelect?.( strippedDownId );
},
selectedId: selectedTabId && `${ instanceId }-${ selectedTabId }`,
rtl: isRTL(),
} );

const selectedTab = items.find( ( item ) => item.id === selectedId );
const firstEnabledTab = items.find( ( item ) => {
// Ariakit internally refers to disabled tabs as `dimmed`.
return ! item.dimmed;
} );
const initialTab = items.find(
( item ) => item.id === `${ instanceId }-${ defaultTabId }`
);

// Handle selecting the initial tab.
useLayoutEffect( () => {
if ( isControlled ) {
return;
}
const isControlled = selectedTabId !== undefined;

const { items, selectedId, activeId } = useStoreState( store );
const { setSelectedId, setActiveId } = store;

// Wait for the denoted initial tab to be declared before making a
// selection. This ensures that if a tab is declared lazily it can
// still receive initial selection, as well as ensuring no tab is
// selected if an invalid `defaultTabId` is provided.
if ( defaultTabId && ! initialTab ) {
return;
// Keep track of whether tabs have been populated. This is used to prevent
// certain effects from firing too early while tab data and relevant
// variables are undefined during the initial render.
const tabsHavePopulatedRef = useRef( false );
if ( items.length > 0 ) {
tabsHavePopulatedRef.current = true;
}

// If the currently selected tab is missing (i.e. removed from the DOM),
// fall back to the initial tab or the first enabled tab if there is
// one. Otherwise, no tab should be selected.
if ( ! items.find( ( item ) => item.id === selectedId ) ) {
if ( initialTab && ! initialTab.dimmed ) {
setSelectedId( initialTab?.id );
const selectedTab = items.find( ( item ) => item.id === selectedId );
const firstEnabledTab = items.find( ( item ) => {
// Ariakit internally refers to disabled tabs as `dimmed`.
return ! item.dimmed;
} );
const initialTab = items.find(
( item ) => item.id === `${ instanceId }-${ defaultTabId }`
);

// Handle selecting the initial tab.
useLayoutEffect( () => {
if ( isControlled ) {
return;
}

if ( firstEnabledTab ) {
setSelectedId( firstEnabledTab.id );
} else if ( tabsHavePopulatedRef.current ) {
setSelectedId( null );
// Wait for the denoted initial tab to be declared before making a
// selection. This ensures that if a tab is declared lazily it can
// still receive initial selection, as well as ensuring no tab is
// selected if an invalid `defaultTabId` is provided.
if ( defaultTabId && ! initialTab ) {
return;
}
}
}, [
firstEnabledTab,
initialTab,
defaultTabId,
isControlled,
items,
selectedId,
setSelectedId,
] );

// Handle the currently selected tab becoming disabled.
useLayoutEffect( () => {
if ( ! selectedTab?.dimmed ) {
return;
}

// In controlled mode, we trust that disabling tabs is done
// intentionally, and don't select a new tab automatically.
if ( isControlled ) {
setSelectedId( null );
return;
}

// If the currently selected tab becomes disabled, fall back to the
// `defaultTabId` if possible. Otherwise select the first
// enabled tab (if there is one).
if ( initialTab && ! initialTab.dimmed ) {
setSelectedId( initialTab.id );
return;
}

if ( firstEnabledTab ) {
setSelectedId( firstEnabledTab.id );
}
}, [
firstEnabledTab,
initialTab,
isControlled,
selectedTab?.dimmed,
setSelectedId,
] );

// Clear `selectedId` if the active tab is removed from the DOM in controlled mode.
useLayoutEffect( () => {
if ( ! isControlled ) {
return;
}

// Once the tabs have populated, if the `selectedTabId` still can't be
// found, clear the selection.
if (
tabsHavePopulatedRef.current &&
!! selectedTabId &&
! selectedTab
) {
setSelectedId( null );
}
}, [ isControlled, selectedTab, selectedTabId, setSelectedId ] );
// If the currently selected tab is missing (i.e. removed from the DOM),
// fall back to the initial tab or the first enabled tab if there is
// one. Otherwise, no tab should be selected.
if ( ! items.find( ( item ) => item.id === selectedId ) ) {
if ( initialTab && ! initialTab.dimmed ) {
setSelectedId( initialTab?.id );
return;
}

if ( firstEnabledTab ) {
setSelectedId( firstEnabledTab.id );
} else if ( tabsHavePopulatedRef.current ) {
setSelectedId( null );
}
}
}, [
firstEnabledTab,
initialTab,
defaultTabId,
isControlled,
items,
selectedId,
setSelectedId,
] );

// Handle the currently selected tab becoming disabled.
useLayoutEffect( () => {
if ( ! selectedTab?.dimmed ) {
return;
}

useEffect( () => {
// If there is no active tab, fallback to place focus on the first enabled tab
// so there is always an active element
if ( selectedTabId === null && ! activeId && firstEnabledTab?.id ) {
setActiveId( firstEnabledTab.id );
}
}, [ selectedTabId, activeId, firstEnabledTab?.id, setActiveId ] );
// In controlled mode, we trust that disabling tabs is done
// intentionally, and don't select a new tab automatically.
if ( isControlled ) {
setSelectedId( null );
return;
}

useEffect( () => {
if ( ! isControlled ) {
return;
}
// If the currently selected tab becomes disabled, fall back to the
// `defaultTabId` if possible. Otherwise select the first
// enabled tab (if there is one).
if ( initialTab && ! initialTab.dimmed ) {
setSelectedId( initialTab.id );
return;
}

requestAnimationFrame( () => {
const focusedElement =
items?.[ 0 ]?.element?.ownerDocument.activeElement;
if ( firstEnabledTab ) {
setSelectedId( firstEnabledTab.id );
}
}, [
firstEnabledTab,
initialTab,
isControlled,
selectedTab?.dimmed,
setSelectedId,
] );

// Clear `selectedId` if the active tab is removed from the DOM in controlled mode.
useLayoutEffect( () => {
if ( ! isControlled ) {
return;
}

// Once the tabs have populated, if the `selectedTabId` still can't be
// found, clear the selection.
if (
! focusedElement ||
! items.some( ( item ) => focusedElement === item.element )
tabsHavePopulatedRef.current &&
!! selectedTabId &&
! selectedTab
) {
return; // Return early if no tabs are focused.
setSelectedId( null );
}
}, [ isControlled, selectedTab, selectedTabId, setSelectedId ] );

// If, after ariakit re-computes the active tab, that tab doesn't match
// the currently focused tab, then we force an update to ariakit to avoid
// any mismatches, especially when navigating to previous/next tab with
// arrow keys.
if ( activeId !== focusedElement.id ) {
setActiveId( focusedElement.id );
useEffect( () => {
// If there is no active tab, fallback to place focus on the first enabled tab
// so there is always an active element
if ( selectedTabId === null && ! activeId && firstEnabledTab?.id ) {
setActiveId( firstEnabledTab.id );
}
}, [ selectedTabId, activeId, firstEnabledTab?.id, setActiveId ] );

useEffect( () => {
if ( ! isControlled ) {
return;
}
} );
}, [ activeId, isControlled, items, setActiveId ] );

const contextValue = useMemo(
() => ( {
store,
instanceId,
requestAnimationFrame( () => {
const focusedElement =
items?.[ 0 ]?.element?.ownerDocument.activeElement;

if (
! focusedElement ||
! items.some( ( item ) => focusedElement === item.element )
) {
return; // Return early if no tabs are focused.
}

// If, after ariakit re-computes the active tab, that tab doesn't match
// the currently focused tab, then we force an update to ariakit to avoid
// any mismatches, especially when navigating to previous/next tab with
// arrow keys.
if ( activeId !== focusedElement.id ) {
setActiveId( focusedElement.id );
}
} );
}, [ activeId, isControlled, items, setActiveId ] );

const contextValue = useMemo(
() => ( {
store,
instanceId,
} ),
[ store, instanceId ]
);

return (
<TabsContext.Provider value={ contextValue }>
{ children }
</TabsContext.Provider>
);
},
{
Tab: Object.assign( Tab, {
displayName: 'Tabs.Tab',
} ),
TabList: Object.assign( TabList, {
displayName: 'Tabs.TabList',
} ),
[ store, instanceId ]
);

return (
<TabsContext.Provider value={ contextValue }>
{ children }
</TabsContext.Provider>
);
}

Tabs.TabList = TabList;
Tabs.Tab = Tab;
Tabs.TabPanel = TabPanel;
Tabs.Context = TabsContext;

export default Tabs;
TabPanel: Object.assign( TabPanel, {
displayName: 'Tabs.TabPanel',
} ),
Context: Object.assign( TabsContext, {
displayName: 'Tabs.Context',
} ),
}
);
Loading

0 comments on commit 240180a

Please sign in to comment.