diff --git a/packages/react/src/ConfirmationDialog/ConfirmationDialog.features.stories.tsx b/packages/react/src/ConfirmationDialog/ConfirmationDialog.features.stories.tsx index f7e643e5413..f6da77882df 100644 --- a/packages/react/src/ConfirmationDialog/ConfirmationDialog.features.stories.tsx +++ b/packages/react/src/ConfirmationDialog/ConfirmationDialog.features.stories.tsx @@ -4,7 +4,8 @@ import type {Meta} from '@storybook/react-vite' import {Button} from '../Button' import {ActionMenu} from '../ActionMenu' import {ActionList} from '../ActionList' -import {ConfirmationDialog, useConfirm} from './ConfirmationDialog' +import {ConfirmationDialog} from './ConfirmationDialog' +import {useConfirm} from './useConfirm' import classes from './ConfirmationDialog.features.stories.module.css' export default { diff --git a/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx b/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx index eeb91d7e9fd..77d5b7fe520 100644 --- a/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx +++ b/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx @@ -6,7 +6,8 @@ import {useCallback, useRef, useState} from 'react' import {ActionMenu} from '../deprecated/ActionMenu' import BaseStyles from '../BaseStyles' import {Button} from '../Button' -import {ConfirmationDialog, useConfirm} from './ConfirmationDialog' +import {ConfirmationDialog} from './ConfirmationDialog' +import {useConfirm} from './useConfirm' import {Stack} from '../Stack' import {implementsClassName} from '../utils/testing' import dialogClasses from '../Dialog/Dialog.module.css' diff --git a/packages/react/src/ConfirmationDialog/ConfirmationDialog.tsx b/packages/react/src/ConfirmationDialog/ConfirmationDialog.tsx index c13fe632cad..bef48c35481 100644 --- a/packages/react/src/ConfirmationDialog/ConfirmationDialog.tsx +++ b/packages/react/src/ConfirmationDialog/ConfirmationDialog.tsx @@ -1,9 +1,7 @@ import type React from 'react' import {useCallback} from 'react' -import {createRoot} from 'react-dom/client' import type {DialogButtonProps, DialogWidth, DialogHeight} from '../Dialog' import {Dialog} from '../Dialog' -import BaseStyles from '../BaseStyles' /** * Props to customize the ConfirmationDialog. @@ -137,41 +135,3 @@ export const ConfirmationDialog: React.FC ) } - -let hostElement: Element | null = null -export type ConfirmOptions = Omit & {content: React.ReactNode} -async function confirm(options: ConfirmOptions): Promise { - const {content, ...confirmationDialogProps} = options - return new Promise(resolve => { - hostElement ||= document.createElement('div') - if (!hostElement.isConnected) document.body.append(hostElement) - const root = createRoot(hostElement) - const onClose: ConfirmationDialogProps['onClose'] = gesture => { - root.unmount() - if (gesture === 'confirm') { - resolve(true) - } else { - resolve(false) - } - } - root.render( - - - {content} - - , - ) - }) -} - -/** - * This hook takes no parameters and returns an `async` function, `confirm`. When `confirm` - * is called, it shows the confirmation dialog. When the dialog is dismissed, a promise is - * resolved with `true` or `false` depending on whether or not the confirm button was used. - */ -export function useConfirm() { - const result = useCallback((options: ConfirmOptions) => { - return confirm(options) - }, []) - return result -} diff --git a/packages/react/src/ConfirmationDialog/useConfirm.ts b/packages/react/src/ConfirmationDialog/useConfirm.ts new file mode 100644 index 00000000000..a7acda6000e --- /dev/null +++ b/packages/react/src/ConfirmationDialog/useConfirm.ts @@ -0,0 +1,44 @@ +import React, {useCallback} from 'react' +import {createRoot} from 'react-dom/client' +import BaseStyles from '../BaseStyles' +import {ConfirmationDialog, type ConfirmationDialogProps} from './ConfirmationDialog' + +let hostElement: Element | null = null + +export type ConfirmOptions = Omit & {content: React.ReactNode} + +async function confirm(options: ConfirmOptions): Promise { + const {content, ...confirmationDialogProps} = options + return new Promise(resolve => { + hostElement ||= document.createElement('div') + if (!hostElement.isConnected) document.body.append(hostElement) + const root = createRoot(hostElement) + const onClose: ConfirmationDialogProps['onClose'] = gesture => { + root.unmount() + if (gesture === 'confirm') { + resolve(true) + } else { + resolve(false) + } + } + root.render( + React.createElement( + BaseStyles, + null, + React.createElement(ConfirmationDialog, {...confirmationDialogProps, onClose}, content), + ), + ) + }) +} + +/** + * This hook takes no parameters and returns an `async` function, `confirm`. When `confirm` + * is called, it shows the confirmation dialog. When the dialog is dismissed, a promise is + * resolved with `true` or `false` depending on whether or not the confirm button was used. + */ +export function useConfirm() { + const result = useCallback((options: ConfirmOptions) => { + return confirm(options) + }, []) + return result +} diff --git a/packages/react/src/ThemeContext.ts b/packages/react/src/ThemeContext.ts new file mode 100644 index 00000000000..ceaeb522ee6 --- /dev/null +++ b/packages/react/src/ThemeContext.ts @@ -0,0 +1,19 @@ +import React from 'react' +import type {ColorMode, ColorModeWithAuto, Theme} from './ThemeProvider' + +export const ThemeContext = React.createContext<{ + theme?: Theme + colorScheme?: string + colorMode?: ColorModeWithAuto + resolvedColorMode?: ColorMode + resolvedColorScheme?: string + dayScheme?: string + nightScheme?: string + setColorMode: React.Dispatch> + setDayScheme: React.Dispatch> + setNightScheme: React.Dispatch> +}>({ + setColorMode: () => null, + setDayScheme: () => null, + setNightScheme: () => null, +}) diff --git a/packages/react/src/ThemeProvider.tsx b/packages/react/src/ThemeProvider.tsx index 9b30341168b..c2876ed7fc5 100644 --- a/packages/react/src/ThemeProvider.tsx +++ b/packages/react/src/ThemeProvider.tsx @@ -4,6 +4,8 @@ import deepmerge from 'deepmerge' import {useId} from './hooks' import {useFeatureFlag} from './FeatureFlags' import {useSyncedState} from './hooks/useSyncedState' +import {ThemeContext} from './ThemeContext' +import {useTheme} from './useTheme' export const defaultColorMode = 'day' const defaultDayScheme = 'light' @@ -11,7 +13,7 @@ const defaultNightScheme = 'dark' // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Theme = {[key: string]: any} -type ColorMode = 'day' | 'night' | 'light' | 'dark' +export type ColorMode = 'day' | 'night' | 'light' | 'dark' export type ColorModeWithAuto = ColorMode | 'auto' export type ThemeProviderProps = { @@ -30,23 +32,6 @@ export type ThemeProviderProps = { contextOnly?: boolean } -const ThemeContext = React.createContext<{ - theme?: Theme - colorScheme?: string - colorMode?: ColorModeWithAuto - resolvedColorMode?: ColorMode - resolvedColorScheme?: string - dayScheme?: string - nightScheme?: string - setColorMode: React.Dispatch> - setDayScheme: React.Dispatch> - setNightScheme: React.Dispatch> -}>({ - setColorMode: () => null, - setDayScheme: () => null, - setNightScheme: () => null, -}) - // inspired from __NEXT_DATA__, we use application/json to avoid CSRF policy with inline scripts const serverHandoffCache = new Map>() const emptyHandoff: Record = {} @@ -167,15 +152,6 @@ export const ThemeProvider: React.FC ) } -export function useTheme() { - return React.useContext(ThemeContext) -} - -export function useColorSchemeVar(values: Partial>, fallback: string) { - const {colorScheme = ''} = useTheme() - return values[colorScheme] ?? fallback -} - function subscribeToSystemColorMode(callback: () => void) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const media = window?.matchMedia?.('(prefers-color-scheme: dark)') diff --git a/packages/react/src/__tests__/ThemeProvider.test.tsx b/packages/react/src/__tests__/ThemeProvider.test.tsx index 1545e372f9f..0e46d975ad4 100644 --- a/packages/react/src/__tests__/ThemeProvider.test.tsx +++ b/packages/react/src/__tests__/ThemeProvider.test.tsx @@ -2,8 +2,9 @@ import {render, screen, waitFor} from '@testing-library/react' import userEvent from '@testing-library/user-event' import {describe, expect, it, vi} from 'vitest' import React from 'react' -import {ThemeProvider, useColorSchemeVar, useTheme} from '../ThemeProvider' +import ThemeProvider from '../ThemeProvider' import {FeatureFlags} from '../FeatureFlags' +import {useColorSchemeVar, useTheme} from '../useTheme' // copied from '@primer/primitives/dist/css/functional/themes/'; const fgDefaultColors = { diff --git a/packages/react/src/experimental/Tabs/Tabs.examples.stories.tsx b/packages/react/src/experimental/Tabs/Tabs.examples.stories.tsx index ebe9a81a5aa..b0c93053901 100644 --- a/packages/react/src/experimental/Tabs/Tabs.examples.stories.tsx +++ b/packages/react/src/experimental/Tabs/Tabs.examples.stories.tsx @@ -1,7 +1,9 @@ import type {Meta} from '@storybook/react-vite' import {action} from 'storybook/actions' import React from 'react' -import {Tabs, TabPanel, useTabList, useTab} from './Tabs' +import {Tabs, TabPanel} from './Tabs' +import {useTab} from './useTab' +import {useTabList} from './useTabList' import {ActionList} from '../../ActionList' import Flash from '../../Flash' diff --git a/packages/react/src/experimental/Tabs/Tabs.tsx b/packages/react/src/experimental/Tabs/Tabs.tsx index 69c891b5350..b8c267cd3d0 100644 --- a/packages/react/src/experimental/Tabs/Tabs.tsx +++ b/packages/react/src/experimental/Tabs/Tabs.tsx @@ -1,59 +1,11 @@ -import React, { - createContext, - useContext, - useId, - useMemo, - type AriaAttributes, - type ElementRef, - type PropsWithChildren, -} from 'react' +import React, {useId, useMemo, type ElementRef} from 'react' import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect' import {useControllableState} from '../../hooks/useControllableState' -import {useProvidedRefOrCreate} from '../../hooks' - -/** - * Props to be used when the Tabs component's state is controlled by the parent - */ -type ControlledTabsProps = { - /** - * Specify the selected tab - */ - value: string - - /** - * `defaultValue` can only be used in the uncontrolled variant of the component - * If you need to use `defaultValue`, please switch to the uncontrolled variant by removing the `value` prop. - */ - defaultValue?: never - - /** - * Provide an optional callback that is called when the selected tab changes - */ - onValueChange: ({value}: {value: string}) => void -} - -/** - * Props to be used when the Tabs component is managing its own state - */ -type UncontrolledTabsProps = { - /** - * Specify the default selected tab - */ - defaultValue: string - - /** - * `value` can only be used in the controlled variant of the component - * If you need to use `value`, please switch to the controlled variant by removing the `defaultValue` prop. - */ - value?: never - - /** - * Provide an optional callback that is called when the selected tab changes - */ - onValueChange?: ({value}: {value: string}) => void -} - -type TabsProps = PropsWithChildren +import {TabsContext} from './TabsContext' +import type {TabListProps, TabPanelProps, TabProps, TabsContextValue, TabsProps} from './types' +import {useTab} from './useTab' +import {useTabList} from './useTabList' +import {useTabPanel} from './useTabPanel' /** * The Tabs component provides the base structure for a tabbed interface, without providing any formal requirement on DOM structure or styling. @@ -91,97 +43,6 @@ function Tabs(props: TabsProps) { return {children} } -type Label = { - 'aria-label': string -} - -type LabelledBy = { - 'aria-labelledby': string -} - -type Labelled = Label | LabelledBy -type TabListProps = Labelled & React.HTMLAttributes - -function useTabList( - props: TabListProps & { - /** Optional ref to use for the tablist. If none is provided, one will be generated automatically */ - ref?: React.RefObject - }, -): { - /** Props to be spread onto the tablist element */ - tabListProps: { - onKeyDown: React.KeyboardEventHandler - 'aria-orientation': AriaAttributes['aria-orientation'] - 'aria-label': AriaAttributes['aria-label'] - 'aria-labelledby': AriaAttributes['aria-labelledby'] - ref: React.RefObject - role: 'tablist' - } -} { - const {'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-orientation': ariaOrientation} = props - - const ref = useProvidedRefOrCreate(props.ref) - - const onKeyDown = (event: React.KeyboardEvent) => { - const {current: tablist} = ref - if (tablist === null) { - return - } - - const tabs = getFocusableTabs(tablist) - - const isVertical = ariaOrientation === 'vertical' - const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight' - const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft' - - if (event.key === nextKey || event.key === prevKey || event.key === 'Home' || event.key === 'End') { - event.preventDefault() - event.stopPropagation() - } - - if (event.key === nextKey) { - const selectedTabIndex = tabs.findIndex(tab => { - return tab.getAttribute('aria-selected') === 'true' - }) - if (selectedTabIndex === -1) { - return - } - - const nextTabIndex = (selectedTabIndex + 1) % tabs.length - tabs[nextTabIndex].focus() - } else if (event.key === prevKey) { - const selectedTabIndex = tabs.findIndex(tab => { - return tab.getAttribute('aria-selected') === 'true' - }) - if (selectedTabIndex === -1) { - return - } - - const nextTabIndex = (tabs.length + selectedTabIndex - 1) % tabs.length - tabs[nextTabIndex].focus() - } else if (event.key === 'Home') { - if (tabs[0]) { - tabs[0].focus() - } - } else if (event.key === 'End') { - if (tabs.length > 0) { - tabs[tabs.length - 1].focus() - } - } - } - - return { - tabListProps: { - ref, - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledby, - 'aria-orientation': ariaOrientation ?? 'horizontal', - role: 'tablist', - onKeyDown, - }, - } -} - function TabList({children, ...rest}: TabListProps) { const {tabListProps} = useTabList(rest) @@ -193,79 +54,6 @@ function TabList({children, ...rest}: TabListProps) { ) } -function getFocusableTabs(tablist: HTMLElement): Array { - return Array.from(tablist.querySelectorAll('[role="tab"]:not([aria-disabled])')) -} - -type TabProps = React.ComponentPropsWithoutRef<'button'> & { - /** - * Specify whether the tab is disabled - */ - disabled?: boolean - - /** - * Provide a value that uniquely identifies the tab. This should mirror the - * value provided to the corresponding TabPanel - */ - value: string -} - -/** - * A custom hook that provides the props needed for a tab component. - * The props returned should be spread onto the component (typically a button) with the `role=tab`, under a `tablist`. - */ -function useTab( - props: Pick, -): { - /** Props to be spread onto the tab component */ - tabProps: Pick< - React.HTMLProps, - 'aria-controls' | 'aria-disabled' | 'aria-selected' | 'id' | 'tabIndex' | 'onKeyDown' | 'onMouseDown' | 'onFocus' - > & { - role: 'tab' - } -} { - const {disabled, value} = props - const tabs = useTabs() - const selected = tabs.selectedValue === value - const id = `${tabs.groupId}-tab-${value}` - const panelId = `${tabs.groupId}-panel-${value}` - - function onKeyDown(event: React.KeyboardEvent) { - if (event.key === ' ' || event.key === 'Enter') { - tabs.selectTab(value) - } - } - - function onMouseDown(event: React.MouseEvent) { - if (!disabled && event.button === 0 && event.ctrlKey === false) { - tabs.selectTab(value) - } else { - event.preventDefault() - } - } - - function onFocus() { - if (!selected && !disabled) { - tabs.selectTab(value) - } - } - - return { - tabProps: { - 'aria-disabled': disabled ? true : undefined, - 'aria-controls': panelId, - 'aria-selected': selected, - onKeyDown, - onMouseDown, - onFocus, - id, - role: 'tab', - tabIndex: selected ? 0 : -1, - }, - } -} - const Tab = React.forwardRef, TabProps>(function Tab(props, forwardRef) { const {children, disabled, value, ...rest} = props const {tabProps} = useTab({disabled, value}) @@ -285,43 +73,6 @@ const Tab = React.forwardRef, TabProps>(function Tab(props, ) }) -type TabPanelProps = { - /** - * Provide a value that uniquely identifies the tab panel. This should mirror - * the value set for the corresponding tab - */ - value: string -} - -/** Utility hook for tab panels */ -function useTabPanel( - props: TabPanelProps, -): { - /** Props to be spread onto the tabpanel component */ - tabPanelProps: Pick, 'aria-labelledby' | 'id' | 'hidden'> & { - /** - * An identifier to aid in styling when this panel is selected & active - */ - 'data-selected': string | undefined - role: 'tabpanel' - } -} { - const {value} = props - const tabs = useTabs() - const id = `${tabs.groupId}-panel-${value}` - const tabId = `${tabs.groupId}-tab-${value}` - - return { - tabPanelProps: { - 'aria-labelledby': tabId, - 'data-selected': tabs.selectedValue === value ? '' : undefined, - id, - hidden: tabs.selectedValue !== value, - role: 'tabpanel', - }, - } -} - function TabPanel({children, value, ...rest}: React.HTMLAttributes & TabPanelProps) { const {tabPanelProps} = useTabPanel({value}) @@ -332,22 +83,6 @@ function TabPanel({children, value, ...rest}: React.HTMLAttributes(null) - -function useTabs(): TabsContextValue { - const context = useContext(TabsContext) - if (context) { - return context - } - throw new Error('Component must be used within a component') -} - type Handler = (event: E) => void function composeEventHandlers(...handlers: Array | null | undefined>) { @@ -361,5 +96,5 @@ function composeEventHandlers(...handlers: Array | null | undefine } } -export {Tabs, TabList, Tab, TabPanel, useTab, useTabList, useTabPanel} +export {Tabs, TabList, Tab, TabPanel} export type {TabsProps, TabListProps, TabProps, TabPanelProps} diff --git a/packages/react/src/experimental/Tabs/TabsContext.ts b/packages/react/src/experimental/Tabs/TabsContext.ts new file mode 100644 index 00000000000..e456f866093 --- /dev/null +++ b/packages/react/src/experimental/Tabs/TabsContext.ts @@ -0,0 +1,4 @@ +import {createContext} from 'react' +import type {TabsContextValue} from './types' + +export const TabsContext = createContext(null) diff --git a/packages/react/src/experimental/Tabs/index.ts b/packages/react/src/experimental/Tabs/index.ts index ffe015d6a6b..305f3e7bddb 100644 --- a/packages/react/src/experimental/Tabs/index.ts +++ b/packages/react/src/experimental/Tabs/index.ts @@ -1,2 +1,5 @@ -export {Tabs, useTab, useTabList, useTabPanel} from './Tabs' -export type {TabsProps, TabListProps, TabProps, TabPanelProps} from './Tabs' +export {Tabs} from './Tabs' +export {useTab} from './useTab' +export {useTabList} from './useTabList' +export {useTabPanel} from './useTabPanel' +export type {TabsProps, TabListProps, TabProps, TabPanelProps} from './types' diff --git a/packages/react/src/experimental/Tabs/types.ts b/packages/react/src/experimental/Tabs/types.ts new file mode 100644 index 00000000000..45337b1b9e8 --- /dev/null +++ b/packages/react/src/experimental/Tabs/types.ts @@ -0,0 +1,123 @@ +import type React from 'react' +import type {AriaAttributes, PropsWithChildren} from 'react' + +/** + * Props to be used when the Tabs component's state is controlled by the parent + */ +type ControlledTabsProps = { + /** + * Specify the selected tab + */ + value: string + + /** + * `defaultValue` can only be used in the uncontrolled variant of the component + * If you need to use `defaultValue`, please switch to the uncontrolled variant by removing the `value` prop. + */ + defaultValue?: never + + /** + * Provide an optional callback that is called when the selected tab changes + */ + onValueChange: ({value}: {value: string}) => void +} + +/** + * Props to be used when the Tabs component is managing its own state + */ +type UncontrolledTabsProps = { + /** + * Specify the default selected tab + */ + defaultValue: string + + /** + * `value` can only be used in the controlled variant of the component + * If you need to use `value`, please switch to the controlled variant by removing the `defaultValue` prop. + */ + value?: never + + /** + * Provide an optional callback that is called when the selected tab changes + */ + onValueChange?: ({value}: {value: string}) => void +} + +export type TabsProps = PropsWithChildren + +type Label = { + 'aria-label': string +} + +type LabelledBy = { + 'aria-labelledby': string +} + +type Labelled = Label | LabelledBy + +export type TabListProps = Labelled & React.HTMLAttributes + +export type TabProps = React.ComponentPropsWithoutRef<'button'> & { + /** + * Specify whether the tab is disabled + */ + disabled?: boolean + + /** + * Provide a value that uniquely identifies the tab. This should mirror the + * value provided to the corresponding TabPanel + */ + value: string +} + +export type TabPanelProps = { + /** + * Provide a value that uniquely identifies the tab panel. This should mirror + * the value set for the corresponding tab + */ + value: string +} + +export type TabsContextValue = { + groupId: string + selectedValue: string + selectTab(value: string): void +} + +export type TabListHookProps = TabListProps & { + /** Optional ref to use for the tablist. If none is provided, one will be generated automatically */ + ref?: React.RefObject +} + +export type TabListHookResult = { + /** Props to be spread onto the tablist element */ + tabListProps: { + onKeyDown: React.KeyboardEventHandler + 'aria-orientation': AriaAttributes['aria-orientation'] + 'aria-label': AriaAttributes['aria-label'] + 'aria-labelledby': AriaAttributes['aria-labelledby'] + ref: React.RefObject + role: 'tablist' + } +} + +export type TabHookResult = { + /** Props to be spread onto the tab component */ + tabProps: Pick< + React.HTMLProps, + 'aria-controls' | 'aria-disabled' | 'aria-selected' | 'id' | 'tabIndex' | 'onKeyDown' | 'onMouseDown' | 'onFocus' + > & { + role: 'tab' + } +} + +export type TabPanelHookResult = { + /** Props to be spread onto the tabpanel component */ + tabPanelProps: Pick, 'aria-labelledby' | 'id' | 'hidden'> & { + /** + * An identifier to aid in styling when this panel is selected & active + */ + 'data-selected': string | undefined + role: 'tabpanel' + } +} diff --git a/packages/react/src/experimental/Tabs/useTab.ts b/packages/react/src/experimental/Tabs/useTab.ts new file mode 100644 index 00000000000..ab35a525ad5 --- /dev/null +++ b/packages/react/src/experimental/Tabs/useTab.ts @@ -0,0 +1,49 @@ +import type React from 'react' +import type {TabHookResult, TabProps} from './types' +import {useTabs} from './useTabs' + +/** + * A custom hook that provides the props needed for a tab component. + * The props returned should be spread onto the component (typically a button) with the `role=tab`, under a `tablist`. + */ +export function useTab(props: Pick): TabHookResult { + const {disabled, value} = props + const tabs = useTabs() + const selected = tabs.selectedValue === value + const id = `${tabs.groupId}-tab-${value}` + const panelId = `${tabs.groupId}-panel-${value}` + + function onKeyDown(event: React.KeyboardEvent) { + if (event.key === ' ' || event.key === 'Enter') { + tabs.selectTab(value) + } + } + + function onMouseDown(event: React.MouseEvent) { + if (!disabled && event.button === 0 && event.ctrlKey === false) { + tabs.selectTab(value) + } else { + event.preventDefault() + } + } + + function onFocus() { + if (!selected && !disabled) { + tabs.selectTab(value) + } + } + + return { + tabProps: { + 'aria-disabled': disabled ? true : undefined, + 'aria-controls': panelId, + 'aria-selected': selected, + onKeyDown, + onMouseDown, + onFocus, + id, + role: 'tab', + tabIndex: selected ? 0 : -1, + }, + } +} diff --git a/packages/react/src/experimental/Tabs/useTabList.ts b/packages/react/src/experimental/Tabs/useTabList.ts new file mode 100644 index 00000000000..f1f8c487d88 --- /dev/null +++ b/packages/react/src/experimental/Tabs/useTabList.ts @@ -0,0 +1,72 @@ +import type React from 'react' +import {useProvidedRefOrCreate} from '../../hooks' +import type {TabListHookProps, TabListHookResult} from './types' + +export function useTabList(props: TabListHookProps): TabListHookResult { + const {'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-orientation': ariaOrientation} = props + + const ref = useProvidedRefOrCreate(props.ref) + + const onKeyDown = (event: React.KeyboardEvent) => { + const {current: tablist} = ref + if (tablist === null) { + return + } + + const tabs = getFocusableTabs(tablist) + + const isVertical = ariaOrientation === 'vertical' + const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight' + const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft' + + if (event.key === nextKey || event.key === prevKey || event.key === 'Home' || event.key === 'End') { + event.preventDefault() + event.stopPropagation() + } + + if (event.key === nextKey) { + const selectedTabIndex = tabs.findIndex(tab => { + return tab.getAttribute('aria-selected') === 'true' + }) + if (selectedTabIndex === -1) { + return + } + + const nextTabIndex = (selectedTabIndex + 1) % tabs.length + tabs[nextTabIndex].focus() + } else if (event.key === prevKey) { + const selectedTabIndex = tabs.findIndex(tab => { + return tab.getAttribute('aria-selected') === 'true' + }) + if (selectedTabIndex === -1) { + return + } + + const nextTabIndex = (tabs.length + selectedTabIndex - 1) % tabs.length + tabs[nextTabIndex].focus() + } else if (event.key === 'Home') { + if (tabs[0]) { + tabs[0].focus() + } + } else if (event.key === 'End') { + if (tabs.length > 0) { + tabs[tabs.length - 1].focus() + } + } + } + + return { + tabListProps: { + ref, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-orientation': ariaOrientation ?? 'horizontal', + role: 'tablist', + onKeyDown, + }, + } +} + +function getFocusableTabs(tablist: HTMLElement): Array { + return Array.from(tablist.querySelectorAll('[role="tab"]:not([aria-disabled])')) +} diff --git a/packages/react/src/experimental/Tabs/useTabPanel.ts b/packages/react/src/experimental/Tabs/useTabPanel.ts new file mode 100644 index 00000000000..f6ff0282bae --- /dev/null +++ b/packages/react/src/experimental/Tabs/useTabPanel.ts @@ -0,0 +1,20 @@ +import type {TabPanelHookResult, TabPanelProps} from './types' +import {useTabs} from './useTabs' + +/** Utility hook for tab panels */ +export function useTabPanel(props: TabPanelProps): TabPanelHookResult { + const {value} = props + const tabs = useTabs() + const id = `${tabs.groupId}-panel-${value}` + const tabId = `${tabs.groupId}-tab-${value}` + + return { + tabPanelProps: { + 'aria-labelledby': tabId, + 'data-selected': tabs.selectedValue === value ? '' : undefined, + id, + hidden: tabs.selectedValue !== value, + role: 'tabpanel', + }, + } +} diff --git a/packages/react/src/experimental/Tabs/useTabs.ts b/packages/react/src/experimental/Tabs/useTabs.ts new file mode 100644 index 00000000000..d3d75654d93 --- /dev/null +++ b/packages/react/src/experimental/Tabs/useTabs.ts @@ -0,0 +1,11 @@ +import {useContext} from 'react' +import {TabsContext} from './TabsContext' +import type {TabsContextValue} from './types' + +export function useTabs(): TabsContextValue { + const context = useContext(TabsContext) + if (context) { + return context + } + throw new Error('Component must be used within a component') +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 1a5335617d1..781739e8ba1 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -3,7 +3,8 @@ export {default as theme, type ThemeColorPaths, type ThemeShadowPaths} from './theme' export {default as BaseStyles} from './BaseStyles' export type {BaseStylesProps} from './BaseStyles' -export {default as ThemeProvider, useTheme, useColorSchemeVar} from './ThemeProvider' +export {default as ThemeProvider} from './ThemeProvider' +export {useTheme, useColorSchemeVar} from './useTheme' export type {ThemeProviderProps} from './ThemeProvider' // Layout @@ -33,7 +34,7 @@ export type {TouchOrMouseEvent} from './hooks/useOnOutsideClick' export {useOpenAndCloseFocus} from './hooks/useOpenAndCloseFocus' export {useOnEscapePress} from './hooks/useOnEscapePress' export {useOverlay} from './hooks/useOverlay' -export {useConfirm} from './ConfirmationDialog/ConfirmationDialog' +export {useConfirm} from './ConfirmationDialog/useConfirm' export {useFocusTrap} from './hooks/useFocusTrap' export type {FocusTrapHookSettings} from './hooks/useFocusTrap' export {FocusKeys, useFocusZone} from './hooks/useFocusZone' diff --git a/packages/react/src/stories/ThemeProvider.stories.tsx b/packages/react/src/stories/ThemeProvider.stories.tsx index 92d22eb0991..584f262dbab 100644 --- a/packages/react/src/stories/ThemeProvider.stories.tsx +++ b/packages/react/src/stories/ThemeProvider.stories.tsx @@ -1,6 +1,7 @@ import type {Meta, StoryFn} from '@storybook/react-vite' -import {ThemeProvider, type ThemeProviderProps, useTheme} from '../ThemeProvider' +import ThemeProvider, {type ThemeProviderProps} from '../ThemeProvider' +import {useTheme} from '../useTheme' import BaseStyles from '../BaseStyles' import classes from './ThemeProvider.stories.module.css' diff --git a/packages/react/src/useTheme.ts b/packages/react/src/useTheme.ts new file mode 100644 index 00000000000..fa6d870875e --- /dev/null +++ b/packages/react/src/useTheme.ts @@ -0,0 +1,11 @@ +import React from 'react' +import {ThemeContext} from './ThemeContext' + +export function useTheme() { + return React.useContext(ThemeContext) +} + +export function useColorSchemeVar(values: Partial>, fallback: string) { + const {colorScheme = ''} = useTheme() + return values[colorScheme] ?? fallback +}