66 useState ,
77 type HTMLAttributes ,
88 type KeyboardEvent ,
9+ type RefObject ,
910 type ReactNode ,
1011 type Ref ,
1112} from 'react' ;
@@ -17,7 +18,10 @@ interface TabsContextValue {
1718 activeTab : string ;
1819 setActiveTab : ( id : string ) => void ;
1920 registerTab : ( id : string ) => void ;
20- tabIds : React . MutableRefObject < string [ ] > ;
21+ tabIds : RefObject < string [ ] > ;
22+ /** Stable prefix generated once per Tabs instance — shared by Tab and TabPanel
23+ * so aria-controls / aria-labelledby IDs always match. */
24+ instanceId : string ;
2125}
2226
2327const TabsContext = createContext < TabsContextValue | null > ( null ) ;
@@ -70,6 +74,7 @@ export function Tabs({
7074 ref,
7175 ...rest
7276} : TabsProps ) {
77+ const instanceId = useId ( ) ;
7378 const tabIds = useRef < string [ ] > ( [ ] ) ;
7479 const [ internalTab , setInternalTab ] = useState ( defaultTab ?? '' ) ;
7580
@@ -94,7 +99,7 @@ export function Tabs({
9499 const classNames = [ styles . tabs , className ] . filter ( Boolean ) . join ( ' ' ) ;
95100
96101 return (
97- < TabsContext value = { { activeTab, setActiveTab, registerTab, tabIds } } >
102+ < TabsContext value = { { activeTab, setActiveTab, registerTab, tabIds, instanceId } } >
98103 < div ref = { ref } className = { classNames } { ...rest } >
99104 { children }
100105 </ div >
@@ -148,40 +153,55 @@ export interface TabProps extends Omit<HTMLAttributes<HTMLButtonElement>, 'id'>
148153 * An individual tab button. Must be a direct child of `<TabList>`.
149154 *
150155 * Keyboard navigation:
151- * - `ArrowLeft` / `ArrowRight` — move focus between tabs
152- * - `Home` — focus first tab
153- * - `End` — focus last tab
154- * - `Enter` / `Space` — activate focused tab
156+ * - `ArrowLeft` / `ArrowRight` — move focus between tabs (skips disabled tabs)
157+ * - `Home` — focus first enabled tab
158+ * - `End` — focus last enabled tab
159+ * - `Enter` / `Space` — activate focused tab (native button behaviour)
155160 */
156161export function Tab ( { id, disabled = false , children, className, ref, ...rest } : TabProps ) {
157- const { activeTab, setActiveTab, registerTab, tabIds } = useTabsContext ( ) ;
158- const generatedId = useId ( ) ;
159- const buttonId = `tab-${ id } -${ generatedId } ` ;
160- const panelId = `tabpanel-${ id } -${ generatedId . replace ( / : / g, '' ) } ` ;
162+ const { activeTab, setActiveTab, registerTab, tabIds, instanceId } = useTabsContext ( ) ;
163+
164+ // IDs derived from the shared instanceId so Tab and TabPanel always agree
165+ const buttonId = `tab-${ instanceId } -${ id } ` ;
166+ const panelId = `tabpanel-${ instanceId } -${ id } ` ;
161167
162- // Register on first render
163168 registerTab ( id ) ;
164169
165170 const isActive = activeTab === id ;
166171 const classNames = [ styles . tab , isActive ? styles . active : '' , className ] . filter ( Boolean ) . join ( ' ' ) ;
167172
173+ const isTabDisabled = ( tabId : string ) => {
174+ const btn = document . querySelector < HTMLButtonElement > ( `[data-tab-id="${ tabId } "]` ) ;
175+ return btn ?. disabled ?? false ;
176+ } ;
177+
168178 const handleKeyDown = ( e : KeyboardEvent < HTMLButtonElement > ) => {
169179 const ids = tabIds . current ;
170180 const currentIndex = ids . indexOf ( id ) ;
171181
182+ const findNext = ( startIdx : number , direction : 1 | - 1 ) : number | null => {
183+ for ( let i = 1 ; i < ids . length ; i ++ ) {
184+ const idx = ( startIdx + direction * i + ids . length ) % ids . length ;
185+ if ( ! isTabDisabled ( ids [ idx ] ) ) return idx ;
186+ }
187+ return null ;
188+ } ;
189+
172190 let nextIndex : number | null = null ;
173- if ( e . key === 'ArrowRight' ) nextIndex = ( currentIndex + 1 ) % ids . length ;
174- else if ( e . key === 'ArrowLeft' ) nextIndex = ( currentIndex - 1 + ids . length ) % ids . length ;
175- else if ( e . key === 'Home' ) nextIndex = 0 ;
176- else if ( e . key === 'End' ) nextIndex = ids . length - 1 ;
191+ if ( e . key === 'ArrowRight' ) nextIndex = findNext ( currentIndex , 1 ) ;
192+ else if ( e . key === 'ArrowLeft' ) nextIndex = findNext ( currentIndex , - 1 ) ;
193+ else if ( e . key === 'Home' ) nextIndex = ids . findIndex ( ( tid ) => ! isTabDisabled ( tid ) ) ;
194+ else if ( e . key === 'End' ) {
195+ for ( let i = ids . length - 1 ; i >= 0 ; i -- ) {
196+ if ( ! isTabDisabled ( ids [ i ] ) ) { nextIndex = i ; break ; }
197+ }
198+ }
177199
178- if ( nextIndex !== null ) {
200+ if ( nextIndex !== null && nextIndex >= 0 ) {
179201 e . preventDefault ( ) ;
180202 const nextId = ids [ nextIndex ] ;
181203 setActiveTab ( nextId ) ;
182- // Move DOM focus to the next tab button
183- const nextButton = document . querySelector < HTMLButtonElement > ( `[data-tab-id="${ nextId } "]` ) ;
184- nextButton ?. focus ( ) ;
204+ document . querySelector < HTMLButtonElement > ( `[data-tab-id="${ nextId } "]` ) ?. focus ( ) ;
185205 }
186206
187207 rest . onKeyDown ?.( e ) ;
@@ -228,10 +248,11 @@ export interface TabPanelProps extends HTMLAttributes<HTMLDivElement> {
228248 * ```
229249 */
230250export function TabPanel ( { id, children, className, ref, ...rest } : TabPanelProps ) {
231- const { activeTab } = useTabsContext ( ) ;
232- const generatedId = useId ( ) ;
233- const panelId = `tabpanel-${ id } -${ generatedId . replace ( / : / g, '' ) } ` ;
234- const buttonId = `tab-${ id } -${ generatedId } ` ;
251+ const { activeTab, instanceId } = useTabsContext ( ) ;
252+
253+ // IDs derived from the shared instanceId — must mirror what Tab produces
254+ const panelId = `tabpanel-${ instanceId } -${ id } ` ;
255+ const buttonId = `tab-${ instanceId } -${ id } ` ;
235256
236257 if ( activeTab !== id ) return null ;
237258
@@ -251,3 +272,4 @@ export function TabPanel({ id, children, className, ref, ...rest }: TabPanelProp
251272 </ div >
252273 ) ;
253274}
275+
0 commit comments