From 8610f748e660d0904a9cdc6ee915b467b953b1fe Mon Sep 17 00:00:00 2001 From: Noah Waldner Date: Tue, 12 May 2026 11:56:15 +0200 Subject: [PATCH 1/4] add scoping class name and context inheritance --- .../src/components/Dialog/Dialog.tsx | 4 +- .../src/components/Dropdown/Dropdown.tsx | 8 +- .../src/components/Flyout/Flyout.tsx | 4 +- .../Select/components/SelectMenu.tsx | 4 +- .../ThemeProvider/ThemeProvider.tsx | 29 +++- .../__tests__/ThemeProvider.spec.tsx | 137 ++++++++++++++++++ .../src/components/Tooltip/Tooltip.tsx | 4 +- 7 files changed, 172 insertions(+), 18 deletions(-) create mode 100644 packages/components/src/components/ThemeProvider/__tests__/ThemeProvider.spec.tsx diff --git a/packages/components/src/components/Dialog/Dialog.tsx b/packages/components/src/components/Dialog/Dialog.tsx index 6b846c81a1..d7123df266 100644 --- a/packages/components/src/components/Dialog/Dialog.tsx +++ b/packages/components/src/components/Dialog/Dialog.tsx @@ -229,7 +229,7 @@ export const DialogContent = ( }: DialogContentProps, ref: ForwardedRef, ) => { - const { theme, dir, locale } = useFondueTheme(); + const { dir } = useFondueTheme(); const contentRef = useRef(null); const { dismissable } = useContext(DialogContext); @@ -271,7 +271,7 @@ export const DialogContent = ( return ( - + , ) => { const localRef = useRef(null); - const { theme, dir, locale } = useFondueTheme(); + const { dir } = useFondueTheme(); const actualRef = ref || localRef; return ( - + , ) => { - const { theme, dir, locale } = useFondueTheme(); + const { dir } = useFondueTheme(); return ( - + , ) => { - const { theme, dir, locale } = useFondueTheme(); + const { dir } = useFondueTheme(); const getAdjustedSide = (side?: 'top' | 'right' | 'bottom' | 'left') => { if (!side || dir === 'ltr') { @@ -173,7 +173,7 @@ export const FlyoutContent = ( return ( - +
{ if (dir === 'ltr') { @@ -115,7 +115,7 @@ export const SelectMenu = ({ return ( - + ({ theme: 'light', dir: 'ltr', locale: enUS, + scopeClassName: '', }); ThemeContext.displayName = 'ThemeContext'; @@ -68,23 +76,32 @@ export const useFondueTheme = () => { export const ThemeProvider = forwardRef( ( - { children, theme = 'light', dir = 'ltr', translations = enUS, locale, asChild = false }, + { children, theme, dir, translations, locale, scopeClassName, asChild = false }, forwardedRef: ForwardedRef, ) => { const Comp = asChild ? Slot : 'div'; + const existingContext = useFondueTheme(); + const contextValue = useMemo( () => ({ - theme, - dir, - locale: locale ? locale : translations, + theme: theme ?? existingContext.theme, + dir: dir ?? existingContext.dir, + locale: locale ?? translations ?? existingContext.locale, + scopeClassName: scopeClassName ?? existingContext.scopeClassName, }), - [dir, theme, locale, translations], + [dir, theme, locale, translations, scopeClassName, existingContext], ); return ( - + {children} diff --git a/packages/components/src/components/ThemeProvider/__tests__/ThemeProvider.spec.tsx b/packages/components/src/components/ThemeProvider/__tests__/ThemeProvider.spec.tsx new file mode 100644 index 0000000000..dcce04dd50 --- /dev/null +++ b/packages/components/src/components/ThemeProvider/__tests__/ThemeProvider.spec.tsx @@ -0,0 +1,137 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { deDE, enUS, frFR } from '../../../locales'; +import { ThemeProvider, useFondueTheme } from '../ThemeProvider'; + +const ContextProbe = ({ id }: { id: string }) => { + const { theme, dir, locale, styleScopeClassNames } = useFondueTheme(); + return ( +
+ ); +}; + +describe('ThemeProvider context inheritance', () => { + it('exposes default context values when no props are provided', () => { + render( + + + , + ); + + const probe = screen.getByTestId('probe'); + expect(probe.dataset.theme).toBe('light'); + expect(probe.dataset.dir).toBe('ltr'); + expect(probe.dataset.locale).toBe(enUS.translationStrings.Dialog_close); + expect(probe.dataset.scope).toBe(''); + }); + + it('applies all explicitly provided props', () => { + render( + + + , + ); + + const probe = screen.getByTestId('probe'); + expect(probe.dataset.theme).toBe('dark'); + expect(probe.dataset.dir).toBe('rtl'); + expect(probe.dataset.locale).toBe(deDE.translationStrings.Dialog_close); + expect(probe.dataset.scope).toBe('my-scope'); + }); + + it('inherits all values from the parent provider when no props are provided', () => { + render( + + + + + , + ); + + const probe = screen.getByTestId('probe'); + expect(probe.dataset.theme).toBe('dark'); + expect(probe.dataset.dir).toBe('rtl'); + expect(probe.dataset.locale).toBe(deDE.translationStrings.Dialog_close); + expect(probe.dataset.scope).toBe('parent-scope'); + }); + + it('only overrides values explicitly set on the nested provider', () => { + render( + + + + + , + ); + + const probe = screen.getByTestId('probe'); + expect(probe.dataset.theme).toBe('light'); + expect(probe.dataset.dir).toBe('rtl'); + expect(probe.dataset.locale).toBe(deDE.translationStrings.Dialog_close); + expect(probe.dataset.scope).toBe('parent-scope'); + }); + + it('merges overrides across multiple levels of nesting', () => { + render( + + + + + + + , + ); + + const probe = screen.getByTestId('probe'); + expect(probe.dataset.theme).toBe('dark'); + expect(probe.dataset.dir).toBe('ltr'); + expect(probe.dataset.locale).toBe(frFR.translationStrings.Dialog_close); + expect(probe.dataset.scope).toBe('outer-scope'); + }); + + it('falls back to deprecated translations prop when locale is not provided', () => { + render( + + + , + ); + + const probe = screen.getByTestId('probe'); + expect(probe.dataset.locale).toBe(deDE.translationStrings.Dialog_close); + }); + + it('prefers locale over the deprecated translations prop', () => { + render( + + + , + ); + + const probe = screen.getByTestId('probe'); + expect(probe.dataset.locale).toBe(frFR.translationStrings.Dialog_close); + }); + + it('renders the parent className alongside the inherited scope on a nested provider', () => { + const { container } = render( + + + + + , + ); + + const providers = container.querySelectorAll('.fondue-theme-provider'); + expect(providers).toHaveLength(2); + // The nested provider should re-apply the inherited scope class on its DOM node + expect(providers[1]?.className).toContain('parent-scope'); + }); +}); diff --git a/packages/components/src/components/Tooltip/Tooltip.tsx b/packages/components/src/components/Tooltip/Tooltip.tsx index 64a05636cf..8b5d5dd82a 100644 --- a/packages/components/src/components/Tooltip/Tooltip.tsx +++ b/packages/components/src/components/Tooltip/Tooltip.tsx @@ -89,7 +89,7 @@ export const TooltipContent = ( }: TooltipContentProps, ref: ForwardedRef, ) => { - const { theme, dir, locale } = useFondueTheme(); + const { dir } = useFondueTheme(); const getAdjustedSide = (side?: 'top' | 'right' | 'bottom' | 'left') => { if (!side || dir === 'ltr') { @@ -108,7 +108,7 @@ export const TooltipContent = ( return ( - + Date: Tue, 12 May 2026 11:57:38 +0200 Subject: [PATCH 2/4] adjust jsdoc --- .../components/src/components/ThemeProvider/ThemeProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/components/ThemeProvider/ThemeProvider.tsx b/packages/components/src/components/ThemeProvider/ThemeProvider.tsx index 6c8ed90344..97b81c3718 100644 --- a/packages/components/src/components/ThemeProvider/ThemeProvider.tsx +++ b/packages/components/src/components/ThemeProvider/ThemeProvider.tsx @@ -39,7 +39,7 @@ type ThemeProviderProps = { locale?: LocaleConfig; /** * Additional class name to apply to the theme provider, used to scope styles to a specific component or section of the application. - * The class will be propagated to components' portal targets. + * The class is propagated to portaled content (e.g. Dropdown, Tooltip, Dialog) so scoped styles are still applied. * @default "" */ scopeClassName?: string; From e8ac838e2f48dd5a96a4f6a5b1cea8f9cb5d4134 Mon Sep 17 00:00:00 2001 From: Noah Waldner Date: Tue, 12 May 2026 12:16:04 +0200 Subject: [PATCH 3/4] rename --- .../components/ThemeProvider/ThemeProvider.tsx | 16 +++++++--------- .../__tests__/ThemeProvider.spec.tsx | 14 +++++++------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/components/src/components/ThemeProvider/ThemeProvider.tsx b/packages/components/src/components/ThemeProvider/ThemeProvider.tsx index 97b81c3718..501f6d6cd9 100644 --- a/packages/components/src/components/ThemeProvider/ThemeProvider.tsx +++ b/packages/components/src/components/ThemeProvider/ThemeProvider.tsx @@ -42,7 +42,7 @@ type ThemeProviderProps = { * The class is propagated to portaled content (e.g. Dropdown, Tooltip, Dialog) so scoped styles are still applied. * @default "" */ - scopeClassName?: string; + className?: string; /** * Change the default rendered element for the one passed as a child, merging their props and behavior. * @default false @@ -54,14 +54,14 @@ type ThemeContextValue = { theme: AvailableTheme; dir: 'ltr' | 'rtl'; locale: LocaleConfig; - scopeClassName: string; + className: string; }; export const ThemeContext = createContext({ theme: 'light', dir: 'ltr', locale: enUS, - scopeClassName: '', + className: '', }); ThemeContext.displayName = 'ThemeContext'; @@ -76,7 +76,7 @@ export const useFondueTheme = () => { export const ThemeProvider = forwardRef( ( - { children, theme, dir, translations, locale, scopeClassName, asChild = false }, + { children, theme, dir, translations, locale, className, asChild = false }, forwardedRef: ForwardedRef, ) => { const Comp = asChild ? Slot : 'div'; @@ -88,9 +88,9 @@ export const ThemeProvider = forwardRef( theme: theme ?? existingContext.theme, dir: dir ?? existingContext.dir, locale: locale ?? translations ?? existingContext.locale, - scopeClassName: scopeClassName ?? existingContext.scopeClassName, + className: className ?? existingContext.className, }), - [dir, theme, locale, translations, scopeClassName, existingContext], + [dir, theme, locale, translations, className, existingContext], ); return ( @@ -98,9 +98,7 @@ export const ThemeProvider = forwardRef( {children} diff --git a/packages/components/src/components/ThemeProvider/__tests__/ThemeProvider.spec.tsx b/packages/components/src/components/ThemeProvider/__tests__/ThemeProvider.spec.tsx index dcce04dd50..44af185756 100644 --- a/packages/components/src/components/ThemeProvider/__tests__/ThemeProvider.spec.tsx +++ b/packages/components/src/components/ThemeProvider/__tests__/ThemeProvider.spec.tsx @@ -7,14 +7,14 @@ import { deDE, enUS, frFR } from '../../../locales'; import { ThemeProvider, useFondueTheme } from '../ThemeProvider'; const ContextProbe = ({ id }: { id: string }) => { - const { theme, dir, locale, styleScopeClassNames } = useFondueTheme(); + const { theme, dir, locale, className } = useFondueTheme(); return (
); }; @@ -36,7 +36,7 @@ describe('ThemeProvider context inheritance', () => { it('applies all explicitly provided props', () => { render( - + , ); @@ -50,7 +50,7 @@ describe('ThemeProvider context inheritance', () => { it('inherits all values from the parent provider when no props are provided', () => { render( - + @@ -66,7 +66,7 @@ describe('ThemeProvider context inheritance', () => { it('only overrides values explicitly set on the nested provider', () => { render( - + @@ -82,7 +82,7 @@ describe('ThemeProvider context inheritance', () => { it('merges overrides across multiple levels of nesting', () => { render( - + @@ -122,7 +122,7 @@ describe('ThemeProvider context inheritance', () => { it('renders the parent className alongside the inherited scope on a nested provider', () => { const { container } = render( - + From df9638182c56d174d0c00faafd3eb03103e03a1f Mon Sep 17 00:00:00 2001 From: Noah Waldner Date: Tue, 12 May 2026 12:31:28 +0200 Subject: [PATCH 4/4] changeset --- .changeset/loud-wolves-lead.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/loud-wolves-lead.md diff --git a/.changeset/loud-wolves-lead.md b/.changeset/loud-wolves-lead.md new file mode 100644 index 0000000000..372b743565 --- /dev/null +++ b/.changeset/loud-wolves-lead.md @@ -0,0 +1,6 @@ +--- +"@frontify/fondue-components": patch +"@frontify/fondue": patch +--- + +feat: add inheritance to theme provider