Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/loud-wolves-lead.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@frontify/fondue-components": patch
"@frontify/fondue": patch
---

feat: add inheritance to theme provider
4 changes: 2 additions & 2 deletions packages/components/src/components/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ export const DialogContent = (
}: DialogContentProps,
ref: ForwardedRef<HTMLDivElement>,
) => {
const { theme, dir, locale } = useFondueTheme();
const { dir } = useFondueTheme();
const contentRef = useRef<HTMLDivElement>(null);
const { dismissable } = useContext(DialogContext);

Expand Down Expand Up @@ -271,7 +271,7 @@ export const DialogContent = (

return (
<RadixDialog.Portal>
<ThemeProvider theme={theme} dir={dir} locale={locale}>
<ThemeProvider>
<DialogUnderlay showUnderlay={showUnderlay}>
<RadixDialog.Content
style={
Expand Down
8 changes: 4 additions & 4 deletions packages/components/src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,11 @@ export const DropdownContent = (
ref: ForwardedRef<HTMLDivElement>,
) => {
const localRef = useRef<HTMLDivElement>(null);
const { theme, dir, locale } = useFondueTheme();
const { dir } = useFondueTheme();
const actualRef = ref || localRef;
return (
<RadixDropdown.Portal forceMount={forceMount || undefined}>
<ThemeProvider theme={theme} dir={dir} locale={locale}>
<ThemeProvider>
<RadixDropdown.Content
// @ts-expect-error - dir prop works at runtime but is not in the Radix UI type definition
dir={dir}
Expand Down Expand Up @@ -291,11 +291,11 @@ export const DropdownSubContent = (
{ children, 'data-test-id': dataTestId = 'fondue-dropdown-subcontent' }: DropdownSubContentProps,
ref: ForwardedRef<HTMLDivElement>,
) => {
const { theme, dir, locale } = useFondueTheme();
const { dir } = useFondueTheme();

return (
<RadixDropdown.Portal>
<ThemeProvider theme={theme} dir={dir} locale={locale}>
<ThemeProvider>
<RadixDropdown.SubContent
// @ts-expect-error - dir prop works at runtime but is not in the Radix UI type definition
dir={dir}
Expand Down
4 changes: 2 additions & 2 deletions packages/components/src/components/Flyout/Flyout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export const FlyoutContent = (
}: FlyoutContentProps,
ref: ForwardedRef<HTMLDivElement>,
) => {
const { theme, dir, locale } = useFondueTheme();
const { dir } = useFondueTheme();

const getAdjustedSide = (side?: 'top' | 'right' | 'bottom' | 'left') => {
if (!side || dir === 'ltr') {
Expand All @@ -173,7 +173,7 @@ export const FlyoutContent = (

return (
<RadixPopover.Portal>
<ThemeProvider theme={theme} dir={dir} locale={locale}>
<ThemeProvider>
<div data-test-id="fondue-flyout-overlay" className={styles.overlay} />
<RadixPopover.Content
dir={dir}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export const SelectMenu = ({
spacious: 24,
};

const { theme, dir, locale } = useFondueTheme();
const { dir } = useFondueTheme();

const getAdjustedSide = (side: 'left' | 'right' | 'bottom' | 'top') => {
if (dir === 'ltr') {
Expand All @@ -115,7 +115,7 @@ export const SelectMenu = ({

return (
<RadixPopover.Portal>
<ThemeProvider theme={theme} dir={dir} locale={locale}>
<ThemeProvider>
<RadixPopover.Content
dir={dir}
align={align}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ 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 is propagated to portaled content (e.g. Dropdown, Tooltip, Dialog) so scoped styles are still applied.
* @default ""
*/
className?: string;
/**
* Change the default rendered element for the one passed as a child, merging their props and behavior.
* @default false
Expand All @@ -48,12 +54,14 @@ type ThemeContextValue = {
theme: AvailableTheme;
dir: 'ltr' | 'rtl';
locale: LocaleConfig;
className: string;
};

export const ThemeContext = createContext<ThemeContextValue>({
theme: 'light',
dir: 'ltr',
locale: enUS,
className: '',
});
ThemeContext.displayName = 'ThemeContext';

Expand All @@ -68,23 +76,30 @@ export const useFondueTheme = () => {

export const ThemeProvider = forwardRef<HTMLDivElement, ThemeProviderProps>(
(
{ children, theme = 'light', dir = 'ltr', translations = enUS, locale, asChild = false },
{ children, theme, dir, translations, locale, className, asChild = false },
forwardedRef: ForwardedRef<HTMLDivElement>,
) => {
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,
className: className ?? existingContext.className,
}),
[dir, theme, locale, translations],
[dir, theme, locale, translations, className, existingContext],
);

return (
<ThemeContext.Provider value={contextValue}>
<Comp ref={forwardedRef} dir={dir} className={`${styles[theme]} fondue-theme-provider`}>
<Comp
ref={forwardedRef}
dir={contextValue.dir}
className={['fondue-theme-provider', styles[contextValue.theme], contextValue.className].join(' ')}
>
{children}
</Comp>
</ThemeContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
@@ -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, className } = useFondueTheme();
return (
<div
data-test-id={id}
data-theme={theme}
data-dir={dir}
data-locale={locale.translationStrings.Dialog_close}
data-scope={className}
/>
);
};

describe('ThemeProvider context inheritance', () => {
it('exposes default context values when no props are provided', () => {
render(
<ThemeProvider>
<ContextProbe id="probe" />
</ThemeProvider>,
);

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(
<ThemeProvider theme="dark" dir="rtl" locale={deDE} className="my-scope">
<ContextProbe id="probe" />
</ThemeProvider>,
);

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(
<ThemeProvider theme="dark" dir="rtl" locale={deDE} className="parent-scope">
<ThemeProvider>
<ContextProbe id="probe" />
</ThemeProvider>
</ThemeProvider>,
);

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(
<ThemeProvider theme="dark" dir="rtl" locale={deDE} className="parent-scope">
<ThemeProvider theme="light">
<ContextProbe id="probe" />
</ThemeProvider>
</ThemeProvider>,
);

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(
<ThemeProvider theme="dark" dir="rtl" locale={deDE} className="outer-scope">
<ThemeProvider locale={frFR}>
<ThemeProvider dir="ltr">
<ContextProbe id="probe" />
</ThemeProvider>
</ThemeProvider>
</ThemeProvider>,
);

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(
<ThemeProvider locale={deDE}>
<ContextProbe id="probe" />
</ThemeProvider>,
);

const probe = screen.getByTestId('probe');
expect(probe.dataset.locale).toBe(deDE.translationStrings.Dialog_close);
});

it('prefers locale over the deprecated translations prop', () => {
render(
<ThemeProvider locale={frFR}>
<ContextProbe id="probe" />
</ThemeProvider>,
);

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(
<ThemeProvider theme="dark" className="parent-scope">
<ThemeProvider>
<span data-test-id="child" />
</ThemeProvider>
</ThemeProvider>,
);

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');
});
});
4 changes: 2 additions & 2 deletions packages/components/src/components/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export const TooltipContent = (
}: TooltipContentProps,
ref: ForwardedRef<HTMLDivElement>,
) => {
const { theme, dir, locale } = useFondueTheme();
const { dir } = useFondueTheme();

const getAdjustedSide = (side?: 'top' | 'right' | 'bottom' | 'left') => {
if (!side || dir === 'ltr') {
Expand All @@ -108,7 +108,7 @@ export const TooltipContent = (

return (
<RadixTooltip.Portal>
<ThemeProvider theme={theme} dir={dir} locale={locale}>
<ThemeProvider>
<RadixTooltip.Content
dir={dir}
data-test-id={dataTestId}
Expand Down
Loading