diff --git a/.changeset/tame-bats-strive.md b/.changeset/tame-bats-strive.md new file mode 100644 index 0000000000..1aa369f5d1 --- /dev/null +++ b/.changeset/tame-bats-strive.md @@ -0,0 +1,6 @@ +--- +"@frontify/fondue-components": minor +"@frontify/fondue": patch +--- + +fix(Tabs): allow Tabs to be disabled after init diff --git a/packages/components/src/components/Tabs/Tabs.stories.tsx b/packages/components/src/components/Tabs/Tabs.stories.tsx index 33f4480ba3..4fe6c1bb75 100644 --- a/packages/components/src/components/Tabs/Tabs.stories.tsx +++ b/packages/components/src/components/Tabs/Tabs.stories.tsx @@ -6,6 +6,7 @@ import { useState } from 'react'; import { action } from 'storybook/actions'; import { Badge } from '../Badge/Badge'; +import { Button } from '../Button/Button'; import { Tabs, TabsContent, TabsRoot, TabsTab, TabsTrigger } from './Tabs'; @@ -181,6 +182,45 @@ export const DisabledTabs: Story = { ), }; +export const DynamicallyDisabledTabs: Story = { + render: (args) => { + const [isSecondTabDisabled, setIsSecondTabDisabled] = useState(false); + return ( +
+ + + + First Tab + + First Content + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut + labore et dolore magna aliqua. + + + + Second Tab + + Second Content + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut + labore et dolore magna aliqua. + + + + Third Tab + + Third Content + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut + labore et dolore magna aliqua. + + + +
+ ); + }, +}; + export const WithDecorator: Story = { render: (args) => ( diff --git a/packages/components/src/components/Tabs/Tabs.tsx b/packages/components/src/components/Tabs/Tabs.tsx index ff498548be..f6507d2937 100644 --- a/packages/components/src/components/Tabs/Tabs.tsx +++ b/packages/components/src/components/Tabs/Tabs.tsx @@ -208,7 +208,6 @@ export const TabsTrigger = ({ children, ...props }: TabsTriggerProps, ref: Forwa const { addTrigger } = useContext(TabTriggerContext); const localRef = useRef(null); - const previousElement = useRef(undefined); useEffect(() => { addTrigger({ @@ -217,11 +216,9 @@ export const TabsTrigger = ({ children, ...props }: TabsTriggerProps, ref: Forwa disabled, props, element: children, - previousElement: previousElement.current, }); - previousElement.current = children; // eslint-disable-next-line @eslint-react/exhaustive-deps - }, [children]); + }, [children, disabled, value, addTrigger]); return null; }; diff --git a/packages/components/src/components/Tabs/__tests__/Tabs.ct.tsx b/packages/components/src/components/Tabs/__tests__/Tabs.ct.tsx index 6e4fceab03..3d762dbf91 100644 --- a/packages/components/src/components/Tabs/__tests__/Tabs.ct.tsx +++ b/packages/components/src/components/Tabs/__tests__/Tabs.ct.tsx @@ -239,6 +239,64 @@ test('should not allow disabled tab to be selected', async ({ mount }) => { await expect(component.getByTestId(THIRD_TAB_CONTENT_TEST_ID)).toBeVisible(); }); +test('should update the disabled state after the initial render', async ({ mount }) => { + const wrapper = await mount( + + + First Tab + First Content + + + Second Tab + Second Content + + , + ); + const component = wrapper.getByTestId(TABS_ROOT_TEST_ID); + + await expect(component.getByTestId(SECOND_TAB_TRIGGER_TEST_ID)).not.toBeDisabled(); + + await wrapper.update( + + + First Tab + First Content + + + Second Tab + Second Content + + , + ); + + await expect(component.getByTestId(SECOND_TAB_TRIGGER_TEST_ID)).toBeDisabled(); + + await component.getByTestId(SECOND_TAB_TRIGGER_TEST_ID).click({ force: true }); + + await expect(component.getByTestId(FIRST_TAB_TRIGGER_TEST_ID)).toHaveAttribute('data-state', 'active'); + await expect(component.getByTestId(SECOND_TAB_CONTENT_TEST_ID)).not.toBeVisible(); + + await wrapper.update( + + + First Tab + First Content + + + Second Tab + Second Content + + , + ); + + await expect(component.getByTestId(SECOND_TAB_TRIGGER_TEST_ID)).not.toBeDisabled(); + + await component.getByTestId(SECOND_TAB_TRIGGER_TEST_ID).click(); + + await expect(component.getByTestId(SECOND_TAB_TRIGGER_TEST_ID)).toHaveAttribute('data-state', 'active'); + await expect(component.getByTestId(SECOND_TAB_CONTENT_TEST_ID)).toBeVisible(); +}); + test('should allow looping when switching tabs with keyboard', async ({ mount, page }) => { const wrapper = await mount( diff --git a/packages/components/src/components/Tabs/hooks/useTabTriggers.ts b/packages/components/src/components/Tabs/hooks/useTabTriggers.ts index 9338ca5e36..06a1f5b172 100644 --- a/packages/components/src/components/Tabs/hooks/useTabTriggers.ts +++ b/packages/components/src/components/Tabs/hooks/useTabTriggers.ts @@ -1,6 +1,6 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ -import { type RefObject, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { type TabTrigger } from '../types'; @@ -140,17 +140,19 @@ export const useTabTriggers = ({ } }, [triggers, triggerListRef, activeIndicatorRef]); - const addTrigger = (trigger: TabTrigger) => { - if (!trigger.previousElement) { - setTriggers((prev) => [...prev, trigger]); - } - + const addTrigger = useCallback((trigger: TabTrigger) => { setTriggers((prev) => { - const index = prev.findIndex((element) => element.element === trigger.previousElement); - prev[index] = trigger; - return [...prev]; + const index = prev.findIndex((element) => element.value === trigger.value); + + if (index === -1) { + return [...prev, trigger]; + } + + const next = [...prev]; + next[index] = trigger; + return next; }); - }; + }, []); return { triggerListRef, diff --git a/packages/components/src/components/Tabs/types.ts b/packages/components/src/components/Tabs/types.ts index e331b8c06c..6d27f1d0b3 100644 --- a/packages/components/src/components/Tabs/types.ts +++ b/packages/components/src/components/Tabs/types.ts @@ -5,7 +5,6 @@ import { type HTMLAttributes, type ReactNode } from 'react'; export type TabTrigger = { ref?: { current: HTMLButtonElement | null }; element: ReactNode; - previousElement?: ReactNode; props: HTMLAttributes; disabled?: boolean; value: string;