diff --git a/packages/react-components/react-tabs/library/src/components/Tab/index.ts b/packages/react-components/react-tabs/library/src/components/Tab/index.ts index eb0ec6b763e1d..a6177d47031b3 100644 --- a/packages/react-components/react-tabs/library/src/components/Tab/index.ts +++ b/packages/react-components/react-tabs/library/src/components/Tab/index.ts @@ -1,8 +1,7 @@ export { Tab } from './Tab'; export type { TabInternalSlots, TabBaseProps, TabProps, TabSlots, TabBaseState, TabState, TabValue } from './Tab.types'; export { renderTab_unstable } from './renderTab'; -export { useTab_unstable } from './useTab'; -export { useTabBase_unstable } from './useTabBase'; +export { useTab_unstable, useTabBase_unstable, useTabFocusAttributes_unstable } from './useTab'; export { tabClassNames, tabReservedSpaceClassNames, diff --git a/packages/react-components/react-tabs/library/src/components/Tab/useTab.ts b/packages/react-components/react-tabs/library/src/components/Tab/useTab.ts index bb7ea8336a99b..190fe5d9f488a 100644 --- a/packages/react-components/react-tabs/library/src/components/Tab/useTab.ts +++ b/packages/react-components/react-tabs/library/src/components/Tab/useTab.ts @@ -1,10 +1,11 @@ 'use client'; import * as React from 'react'; -import { omit, slot } from '@fluentui/react-utilities'; -import type { TabProps, TabState } from './Tab.types'; +import { type TabsterDOMAttribute, useTabsterAttributes } from '@fluentui/react-tabster'; +import { mergeCallbacks, useEventCallback, useMergedRefs, slot, omit } from '@fluentui/react-utilities'; +import type { TabProps, TabState, TabBaseProps, TabBaseState } from './Tab.types'; import { useTabListContext_unstable } from '../TabList'; -import { useTabBase_unstable } from './useTabBase'; +import type { SelectTabEvent } from '../TabList'; /** * Create the state required to render Tab. @@ -12,6 +13,7 @@ import { useTabBase_unstable } from './useTabBase'; * The returned state can be modified with hooks such as useTabStyles_unstable, * before being passed to renderTab_unstable. * + * @internal * @param props - props from this instance of Tab * @param ref - reference to root HTMLElement of Tab */ @@ -19,6 +21,7 @@ export const useTab_unstable = (props: TabProps, ref: React.Ref): T const { content } = props; const state = useTabBase_unstable(props, ref); + const focusAttributes = useTabFocusAttributes_unstable(state); const appearance = useTabListContext_unstable(ctx => ctx.appearance); const reserveSelectedTabSpace = useTabListContext_unstable(ctx => ctx.reserveSelectedTabSpace); @@ -31,6 +34,10 @@ export const useTab_unstable = (props: TabProps, ref: React.Ref): T ...state, // eslint-disable-next-line @typescript-eslint/no-deprecated components: { ...state.components, contentReservedSpace: 'span' }, + root: { + ...state.root, + ...focusAttributes, + }, contentReservedSpace: slot.optional(contentReservedSpace, { renderByDefault: !state.selected && !state.iconOnly && reserveSelectedTabSpace, defaultProps: { children: props.children }, @@ -40,3 +47,83 @@ export const useTab_unstable = (props: TabProps, ref: React.Ref): T size, }; }; + +/** + * Create the based state required to render Tab without design specifics and focus attributes. + * + * @param props - props from this instance of Tab + * @param ref - reference to root HTMLElement of Tab + */ +export const useTabBase_unstable = (props: TabBaseProps, ref: React.Ref): TabBaseState => { + const { content, disabled: tabDisabled = false, icon, onClick, onFocus, value, ...rest } = props; + + const selectTabOnFocus = useTabListContext_unstable(ctx => ctx.selectTabOnFocus); + const listDisabled = useTabListContext_unstable(ctx => ctx.disabled); + const selected = useTabListContext_unstable(ctx => ctx.selectedValue === value); + const onRegister = useTabListContext_unstable(ctx => ctx.onRegister); + const onUnregister = useTabListContext_unstable(ctx => ctx.onUnregister); + const onSelect = useTabListContext_unstable(ctx => ctx.onSelect); + const vertical = useTabListContext_unstable(ctx => !!ctx.vertical); + const disabled = listDisabled || tabDisabled; + + const innerRef = React.useRef(null); + const onSelectCallback = (event: SelectTabEvent) => onSelect(event, { value }); + const onTabClick = useEventCallback(mergeCallbacks(onClick, onSelectCallback)); + const onTabFocus = useEventCallback(mergeCallbacks(onFocus, onSelectCallback)); + + React.useEffect(() => { + onRegister({ + value, + ref: innerRef, + }); + + return () => { + onUnregister({ value, ref: innerRef }); + }; + }, [onRegister, onUnregister, innerRef, value]); + + const iconSlot = slot.optional(icon, { elementType: 'span' }); + const contentSlot = slot.always(content, { + defaultProps: { children: props.children }, + elementType: 'span', + }); + const iconOnly = Boolean(iconSlot?.children && !contentSlot.children); + return { + components: { root: 'button', icon: 'span', content: 'span', contentReservedSpace: 'span' }, + root: slot.always( + { + ref: useMergedRefs(ref, innerRef), + role: 'tab', + type: 'button', + // aria-selected undefined indicates it is not selectable + // according to https://www.w3.org/TR/wai-aria-1.1/#aria-selected + 'aria-selected': disabled ? undefined : (`${selected}` as 'true' | 'false'), + value, + ...rest, + disabled, + onClick: onTabClick, + onFocus: selectTabOnFocus ? onTabFocus : onFocus, + }, + { elementType: 'button' }, + ) as TabBaseState['root'], + icon: iconSlot, + iconOnly, + content: contentSlot, + disabled, + selected, + value, + vertical, + }; +}; + +/** + * Hook to return focus attributes to a Tab based on selected state. + * Should be applied on the button with role="tab". + * + * @internal + */ +export const useTabFocusAttributes_unstable = ({ selected }: Pick): TabsterDOMAttribute => { + return useTabsterAttributes({ + focusable: { isDefault: selected }, + }); +}; diff --git a/packages/react-components/react-tabs/library/src/components/Tab/useTabBase.ts b/packages/react-components/react-tabs/library/src/components/Tab/useTabBase.ts deleted file mode 100644 index 49eaee980a5f8..0000000000000 --- a/packages/react-components/react-tabs/library/src/components/Tab/useTabBase.ts +++ /dev/null @@ -1,92 +0,0 @@ -'use client'; - -import * as React from 'react'; -import { useTabsterAttributes } from '@fluentui/react-tabster'; -import { - getIntrinsicElementProps, - mergeCallbacks, - useEventCallback, - useMergedRefs, - slot, -} from '@fluentui/react-utilities'; -import type { TabBaseProps, TabBaseState } from './Tab.types'; -import { useTabListContext_unstable } from '../TabList'; -import type { SelectTabEvent } from '../TabList'; - -/** - * Create the state required to render Tab. - * - * The returned state can be modified with hooks such as useTabStyles_unstable, - * before being passed to renderTab_unstable. - * - * @param props - props from this instance of Tab - * @param ref - reference to root HTMLElement of Tab - */ -export const useTabBase_unstable = (props: TabBaseProps, ref: React.Ref): TabBaseState => { - const { content, disabled: tabDisabled = false, icon, onClick, onFocus, value } = props; - - const selectTabOnFocus = useTabListContext_unstable(ctx => ctx.selectTabOnFocus); - const listDisabled = useTabListContext_unstable(ctx => ctx.disabled); - const selected = useTabListContext_unstable(ctx => ctx.selectedValue === value); - const onRegister = useTabListContext_unstable(ctx => ctx.onRegister); - const onUnregister = useTabListContext_unstable(ctx => ctx.onUnregister); - const onSelect = useTabListContext_unstable(ctx => ctx.onSelect); - const vertical = useTabListContext_unstable(ctx => !!ctx.vertical); - const disabled = listDisabled || tabDisabled; - - const innerRef = React.useRef(null); - const onSelectCallback = (event: SelectTabEvent) => onSelect(event, { value }); - const onTabClick = useEventCallback(mergeCallbacks(onClick, onSelectCallback)); - const onTabFocus = useEventCallback(mergeCallbacks(onFocus, onSelectCallback)); - - const focusProps = useTabsterAttributes({ - focusable: { isDefault: selected }, - }); - - React.useEffect(() => { - onRegister({ - value, - ref: innerRef, - }); - - return () => { - onUnregister({ value, ref: innerRef }); - }; - }, [onRegister, onUnregister, innerRef, value]); - - const iconSlot = slot.optional(icon, { elementType: 'span' }); - const contentSlot = slot.always(content, { - defaultProps: { children: props.children }, - elementType: 'span', - }); - const iconOnly = Boolean(iconSlot?.children && !contentSlot.children); - return { - components: { root: 'button', icon: 'span', content: 'span', contentReservedSpace: 'span' }, - root: slot.always( - getIntrinsicElementProps('button', { - // FIXME: - // `ref` is wrongly assigned to be `HTMLElement` instead of `HTMLButtonElement` - // but since it would be a breaking change to fix it, we are casting ref to it's proper type - ref: useMergedRefs(ref, innerRef) as React.Ref, - role: 'tab', - type: 'button', - // aria-selected undefined indicates it is not selectable - // according to https://www.w3.org/TR/wai-aria-1.1/#aria-selected - 'aria-selected': disabled ? undefined : (`${selected}` as 'true' | 'false'), - ...focusProps, - ...props, - disabled, - onClick: onTabClick, - onFocus: selectTabOnFocus ? onTabFocus : onFocus, - }), - { elementType: 'button' }, - ) as TabBaseState['root'], - icon: iconSlot, - iconOnly, - content: contentSlot, - disabled, - selected, - value, - vertical, - }; -}; diff --git a/packages/react-components/react-tabs/library/src/components/TabList/index.ts b/packages/react-components/react-tabs/library/src/components/TabList/index.ts index 7e89f9f4b3619..dfdb96b018679 100644 --- a/packages/react-components/react-tabs/library/src/components/TabList/index.ts +++ b/packages/react-components/react-tabs/library/src/components/TabList/index.ts @@ -15,7 +15,10 @@ export type { } from './TabList.types'; export { TabListContext, TabListProvider, useTabListContext_unstable } from './TabListContext'; export { renderTabList_unstable } from './renderTabList'; -export { useTabList_unstable } from './useTabList'; -export { useTabListBase_unstable } from './useTabListBase'; +export { + useTabList_unstable, + useTabListBase_unstable, + useTabListFocusAttributes_unstable as useTabListFocusAttributes, +} from './useTabList'; export { useTabListContextValues_unstable } from './useTabListContextValues'; export { tabListClassNames, useTabListStyles_unstable } from './useTabListStyles.styles'; diff --git a/packages/react-components/react-tabs/library/src/components/TabList/useTabList.ts b/packages/react-components/react-tabs/library/src/components/TabList/useTabList.ts index 2417936e21a98..7c79dca6d3b2b 100644 --- a/packages/react-components/react-tabs/library/src/components/TabList/useTabList.ts +++ b/packages/react-components/react-tabs/library/src/components/TabList/useTabList.ts @@ -1,8 +1,18 @@ 'use client'; import * as React from 'react'; -import type { TabListProps, TabListState } from './TabList.types'; -import { useTabListBase_unstable } from './useTabListBase'; +import { type TabsterDOMAttribute, useArrowNavigationGroup } from '@fluentui/react-tabster'; +import { useControllableState, useEventCallback, useMergedRefs, slot } from '@fluentui/react-utilities'; +import type { + TabRegisterData, + SelectTabData, + SelectTabEvent, + TabListBaseProps, + TabListBaseState, + TabListProps, + TabListState, +} from './TabList.types'; +import type { TabValue } from '../Tab'; /** * Create the state required to render TabList. @@ -16,11 +26,136 @@ import { useTabListBase_unstable } from './useTabListBase'; export const useTabList_unstable = (props: TabListProps, ref: React.Ref): TabListState => { const { appearance = 'transparent', reserveSelectedTabSpace = true, size = 'medium' } = props; const state = useTabListBase_unstable(props, ref); + const focusAttributes = useTabListFocusAttributes_unstable({ vertical: state.vertical }); return { ...state, + root: { + ...state.root, + ...focusAttributes, + }, appearance, reserveSelectedTabSpace, size, }; }; + +/** + * Create the state required to render TabList. + * + * The returned state can be modified with hooks such as useTabListStyles_unstable, + * before being passed to renderTabList_unstable. + * + * @param props - props from this instance of TabList + * @param ref - reference to root HTMLElement of TabList + */ +export const useTabListBase_unstable = (props: TabListBaseProps, ref: React.Ref): TabListBaseState => { + const { + disabled = false, + onTabSelect, + selectTabOnFocus = false, + vertical = false, + selectedValue: controlledSelectedValue, + defaultSelectedValue, + ...rest + } = props; + + const innerRef = React.useRef(null); + + const [selectedValue, setSelectedValue] = useControllableState({ + state: controlledSelectedValue, + defaultState: defaultSelectedValue, + initialState: undefined, + }); + + // considered usePrevious, but it is sensitive to re-renders + // this could cause the previous to move to current in the case where the tab list re-renders. + // these refs avoid getRegisteredTabs changing when selectedValue changes and causing + // renders for tabs that have not changed. + const currentSelectedValue = React.useRef(undefined); + const previousSelectedValue = React.useRef(undefined); + + React.useEffect(() => { + previousSelectedValue.current = currentSelectedValue.current; + currentSelectedValue.current = selectedValue; + }, [selectedValue]); + + const onSelect = useEventCallback((event: SelectTabEvent, data: SelectTabData) => { + setSelectedValue(data.value); + onTabSelect?.(event, data); + }); + + const registeredTabs = React.useRef>({}); + + const onRegister = useEventCallback((data: TabRegisterData) => { + const key = JSON.stringify(data.value); + + if (!key && process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.error( + [ + `[@fluentui/react-tabs] The value "${data.value}" cannot be serialized to JSON string.`, + 'Tab component requires serializable values.', + 'Please provide a primitive value (string, number, boolean),', + `or a plain object/array that doesn't contain functions, symbols, or circular references.`, + ].join(' '), + ); + } + + registeredTabs.current[key] = data; + }); + + const onUnregister = useEventCallback((data: TabRegisterData) => { + delete registeredTabs.current[JSON.stringify(data.value)]; + }); + + const getRegisteredTabs = React.useCallback(() => { + return { + selectedValue: currentSelectedValue.current, + previousSelectedValue: previousSelectedValue.current, + registeredTabs: registeredTabs.current, + }; + }, []); + + return { + components: { + root: 'div', + }, + root: slot.always( + { + ref: useMergedRefs(ref, innerRef) as React.Ref, + role: 'tablist', + 'aria-orientation': vertical ? 'vertical' : 'horizontal', + ...rest, + }, + { elementType: 'div' }, + ), + disabled, + selectTabOnFocus, + selectedValue, + onRegister, + onUnregister, + onSelect, + getRegisteredTabs, + vertical, + }; +}; + +/** + * Hook to get Tabster DOM attributes for TabList focus handling + * + * @internal + * @param vertical - whether the TabList is vertical + * @returns Tabster DOM attributes + */ +export const useTabListFocusAttributes_unstable = ({ + vertical, +}: Pick): TabsterDOMAttribute => { + return useArrowNavigationGroup({ + circular: true, + axis: vertical ? 'vertical' : 'horizontal', + memorizeCurrent: false, + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_hasDefault: true, + }); +}; diff --git a/packages/react-components/react-tabs/library/src/components/TabList/useTabListBase.test.tsx b/packages/react-components/react-tabs/library/src/components/TabList/useTabListBase.test.tsx deleted file mode 100644 index 60b028e147273..0000000000000 --- a/packages/react-components/react-tabs/library/src/components/TabList/useTabListBase.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import * as React from 'react'; -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import '@testing-library/jest-dom'; - -import { useTabListBase_unstable } from './useTabListBase'; -import { renderTabList_unstable } from './renderTabList'; -import { renderTab_unstable, TabState, useTabBase_unstable } from '../Tab'; -import { useTabListContextValues_unstable } from './useTabListContextValues'; -import { mergeClasses } from '@griffel/react'; -import { useTabListContext_unstable } from './TabListContext'; -import { TabListState } from './TabList.types'; - -describe('useTabListBase', () => { - type CustomTabAppearance = 'filled' | 'outline'; - - type CustomTabProps = Parameters[0]; - - const CustomTab = React.forwardRef((props, ref) => { - const state = useTabBase_unstable(props, ref); - const appearance = useTabListContext_unstable(ctx => ctx.appearance as CustomTabAppearance); - - state.root.className = mergeClasses( - 'tab', - `tab--${appearance}`, - state.selected && 'tab-selected', - state.root.className, - ); - - return renderTab_unstable(state as TabState); - }); - - type CustomTabListProps = Parameters[0] & { - appearance?: 'filled' | 'outline'; - }; - - const CustomTabList = React.forwardRef( - ({ appearance = 'filled', ...props }, ref) => { - const state = useTabListBase_unstable(props, ref); - Object.assign(state, { appearance }); - const contextValues = useTabListContextValues_unstable(state as TabListState); - - state.root.className = mergeClasses('tab-list', `tab-list--${appearance}`, state.root.className); - - return renderTabList_unstable(state as TabListState, contextValues); - }, - ); - - it('render tabs', () => { - const result = render( - - First - Second - , - ); - - expect(result.getByRole('tablist')).toMatchInlineSnapshot(` -
- - -
- `); - - // Selected the `Second` tab - userEvent.click(result.getByRole('tab', { name: 'Second' })); - - // Ensure the `Second` tab is selected - expect(result.getByRole('tab', { name: 'Second' })).toHaveAttribute('aria-selected', 'true'); - expect(result.getByRole('tab', { name: 'First' })).not.toHaveAttribute('aria-selected', 'true'); - }); -}); diff --git a/packages/react-components/react-tabs/library/src/components/TabList/useTabListBase.ts b/packages/react-components/react-tabs/library/src/components/TabList/useTabListBase.ts deleted file mode 100644 index 14110f9f387e1..0000000000000 --- a/packages/react-components/react-tabs/library/src/components/TabList/useTabListBase.ts +++ /dev/null @@ -1,124 +0,0 @@ -'use client'; - -import * as React from 'react'; -import { useArrowNavigationGroup } from '@fluentui/react-tabster'; -import { - getIntrinsicElementProps, - useControllableState, - useEventCallback, - useMergedRefs, - slot, -} from '@fluentui/react-utilities'; -import type { - TabRegisterData, - SelectTabData, - SelectTabEvent, - TabListBaseProps, - TabListBaseState, -} from './TabList.types'; -import type { TabValue } from '../Tab'; - -/** - * Create the state required to render TabList. - * - * The returned state can be modified with hooks such as useTabListStyles_unstable, - * before being passed to renderTabList_unstable. - * - * @param props - props from this instance of TabList - * @param ref - reference to root HTMLElement of TabList - */ -export const useTabListBase_unstable = (props: TabListBaseProps, ref: React.Ref): TabListBaseState => { - const { disabled = false, onTabSelect, selectTabOnFocus = false, vertical = false } = props; - - const innerRef = React.useRef(null); - - const focusAttributes = useArrowNavigationGroup({ - circular: true, - axis: vertical ? 'vertical' : 'horizontal', - memorizeCurrent: false, - // eslint-disable-next-line @typescript-eslint/naming-convention - unstable_hasDefault: true, - }); - - const [selectedValue, setSelectedValue] = useControllableState({ - state: props.selectedValue, - defaultState: props.defaultSelectedValue, - initialState: undefined, - }); - - // considered usePrevious, but it is sensitive to re-renders - // this could cause the previous to move to current in the case where the tab list re-renders. - // these refs avoid getRegisteredTabs changing when selectedValue changes and causing - // renders for tabs that have not changed. - const currentSelectedValue = React.useRef(undefined); - const previousSelectedValue = React.useRef(undefined); - - React.useEffect(() => { - previousSelectedValue.current = currentSelectedValue.current; - currentSelectedValue.current = selectedValue; - }, [selectedValue]); - - const onSelect = useEventCallback((event: SelectTabEvent, data: SelectTabData) => { - setSelectedValue(data.value); - onTabSelect?.(event, data); - }); - - const registeredTabs = React.useRef>({}); - - const onRegister = useEventCallback((data: TabRegisterData) => { - const key = JSON.stringify(data.value); - - if (!key && process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line no-console - console.error( - [ - `[@fluentui/react-tabs] The value "${data.value}" cannot be serialized to JSON string.`, - 'Tab component requires serializable values.', - 'Please provide a primitive value (string, number, boolean),', - `or a plain object/array that doesn't contain functions, symbols, or circular references.`, - ].join(' '), - ); - } - - registeredTabs.current[key] = data; - }); - - const onUnregister = useEventCallback((data: TabRegisterData) => { - delete registeredTabs.current[JSON.stringify(data.value)]; - }); - - const getRegisteredTabs = React.useCallback(() => { - return { - selectedValue: currentSelectedValue.current, - previousSelectedValue: previousSelectedValue.current, - registeredTabs: registeredTabs.current, - }; - }, []); - - return { - components: { - root: 'div', - }, - root: slot.always( - getIntrinsicElementProps('div', { - // FIXME: - // `ref` is wrongly assigned to be `HTMLElement` instead of `HTMLDivElement` - // but since it would be a breaking change to fix it, we are casting ref to it's proper type - ref: useMergedRefs(ref, innerRef) as React.Ref, - role: 'tablist', - 'aria-orientation': vertical ? 'vertical' : 'horizontal', - ...focusAttributes, - ...props, - } as const), - { elementType: 'div' }, - ), - disabled, - selectTabOnFocus, - selectedValue, - onRegister, - onUnregister, - onSelect, - getRegisteredTabs, - vertical, - }; -}; diff --git a/packages/react-components/react-tabs/library/src/index.ts b/packages/react-components/react-tabs/library/src/index.ts index a9d8bf696501d..c637167caad78 100644 --- a/packages/react-components/react-tabs/library/src/index.ts +++ b/packages/react-components/react-tabs/library/src/index.ts @@ -36,6 +36,6 @@ export { // Experimental APIs - will be uncommented in experimental release // export type { TabBaseProps, TabBaseState } from './Tab'; -// export { useTabBase_unstable } from './Tab'; +// export { useTabBase_unstable, useTabFocusAttributes_unstable } from './Tab'; // export type { TabListBaseProps, TabListBaseState } from './TabList'; -// export { useTabListBase_unstable } from './TabList'; +// export { useTabListBase_unstable, useTabListFocusAttributes_unstable } from './TabList';