From 64e762a9930c314662ed14b10e919970ade090ed Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Thu, 30 Apr 2026 16:11:41 +0530 Subject: [PATCH 1/2] feat(theme): scoped theming with ThemeScope component Any element with `data-theme` (or wrapped in ``) now creates an isolated theme region. Descendants resolve all `--rs-color-*` tokens, `color-scheme`, and the smooth theme-switch transition from the nearest scoped ancestor. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/app/examples/scoped-theme/page.tsx | 135 ++++++++++++++++++ .../src/content/docs/theme/overview/index.mdx | 80 +++++++++++ .../src/content/docs/theme/overview/props.ts | 20 +++ .../__tests__/theme-scope.test.tsx | 127 ++++++++++++++++ .../components/theme-provider/index.tsx | 7 +- .../components/theme-provider/theme-scope.tsx | 48 +++++++ .../components/theme-provider/theme.tsx | 6 +- .../components/theme-provider/types.ts | 26 ++-- packages/raystack/index.tsx | 2 + packages/raystack/styles/colors.css | 11 +- .../raystack/styles/primitives/accent.css | 8 +- .../raystack/styles/primitives/appearance.css | 28 ++-- packages/raystack/styles/primitives/gray.css | 6 +- 13 files changed, 471 insertions(+), 33 deletions(-) create mode 100644 apps/www/src/app/examples/scoped-theme/page.tsx create mode 100644 packages/raystack/components/theme-provider/__tests__/theme-scope.test.tsx create mode 100644 packages/raystack/components/theme-provider/theme-scope.tsx diff --git a/apps/www/src/app/examples/scoped-theme/page.tsx b/apps/www/src/app/examples/scoped-theme/page.tsx new file mode 100644 index 000000000..6624cc565 --- /dev/null +++ b/apps/www/src/app/examples/scoped-theme/page.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { + Button, + Callout, + Flex, + IconButton, + Text, + ThemeScope +} from '@raystack/apsara'; +import { Moon, Sun } from 'lucide-react'; +import { useState } from 'react'; +import { useTheme } from '@/components/theme'; + +type ScopeTheme = 'light' | 'dark'; + +const panelStyle = { + minWidth: 320, + padding: 'var(--rs-space-7)', + borderRadius: 'var(--rs-space-3)', + border: '1px solid var(--rs-color-border-base-primary)', + backgroundColor: 'var(--rs-color-background-base-primary)' +}; + +const Page = () => { + const { theme, setTheme } = useTheme(); + const [scopeTheme, setScopeTheme] = useState('dark'); + const [calloutScopeTheme, setCalloutScopeTheme] = + useState('light'); + const GlobalIcon = theme === 'dark' ? Sun : Moon; + + return ( + + + + Scoped Theming + + + setTheme({ theme: theme === 'dark' ? 'light' : 'dark' }) + } + > + + + + + + } + > + + + Scoped box + + + setScopeTheme(scopeTheme === 'dark' ? 'light' : 'dark') + } + > + {scopeTheme === 'dark' ? : } + + + + This box themes itself via{' '} + data-theme="{scopeTheme}", independent of the + page. + + + + + + + + + } + > + + + Semantic colors in scope + + + setCalloutScopeTheme( + calloutScopeTheme === 'dark' ? 'light' : 'dark' + ) + } + > + {calloutScopeTheme === 'dark' ? : } + + + + Accent, success, danger, and attention tokens all re-resolve at the + scope. + + Accent — informational message + Success — operation completed + Danger — something went wrong + Attention — review before continuing + + + ); +}; + +export default Page; diff --git a/apps/www/src/content/docs/theme/overview/index.mdx b/apps/www/src/content/docs/theme/overview/index.mdx index 5d72b418b..82b5ce0b8 100644 --- a/apps/www/src/content/docs/theme/overview/index.mdx +++ b/apps/www/src/content/docs/theme/overview/index.mdx @@ -132,3 +132,83 @@ export default function RootLayout({ children }) { ``` The `suppressHydrationWarning` is required because the theme script modifies the HTML element before React hydrates. + +## Scoped Theming + +Themes are not limited to the document root. Any element with a `data-theme` attribute creates an isolated theme scope — descendants resolve every design token from the nearest scoped ancestor. This enables theme preview cards, split-screen comparisons, and dark sidebars in light apps without any extra plumbing. + +### Bare attribute + +Because scoping is implemented in CSS, you can opt in by simply setting the attribute on any element: + +```tsx + + {/* Page is dark */} +
+ {/* This subtree renders with light tokens */} + +
+ +``` + +The package's stylesheet handles the rest: every `--rs-color-*` token, `color-scheme` for native form controls and scrollbars, and the smooth transition during theme switches all follow the scoped attribute. + +### `ThemeScope` component + +For a typed convenience wrapper, use `ThemeScope`: + +```tsx +import { ThemeScope } from "@raystack/apsara"; + + + Dark scoped card + +``` + +`ThemeScope` writes `data-theme` (and optionally `data-accent-color`, `data-gray-color`, `data-style`) onto a wrapper element. By default it renders a `
`. Use the `render` prop to fuse the scope onto an element you already have, with no extra wrapping div: + +```tsx +}> + ... + ... + +``` + +### Combining with accent and gray overrides + +A scope can override accent or gray independently of theme. This is useful for highlighting a section without changing its color scheme: + +```tsx + + + + + + Dark mint-on-slate card + +``` + +### State management + +`ThemeScope` is stateless — the consumer owns the theme value. For an interactive scope, drive it with React state: + +```tsx +const [scopeTheme, setScopeTheme] = useState<'light' | 'dark'>('dark'); + +}> + setScopeTheme(t => t === 'dark' ? 'light' : 'dark')}> + Toggle this scope + + ... + +``` + +If you need persistence across page loads, manage it yourself with `localStorage` and a `useEffect`. `ThemeScope` deliberately avoids touching storage to keep the component pure and to avoid disambiguation issues when multiple scopes share a page. + + + +### When to reach for `ThemeScope` vs. the bare attribute + +- Use the **bare `data-theme` attribute** when you're already rendering a custom element and don't want another wrapper. The CSS handles everything — components inside will theme correctly. +- Use **`ThemeScope`** when you want typed props (`theme`, `accentColor`, etc.), defaults handled for you, and a single import that documents intent. +- Both produce the same DOM output when `ThemeScope` is given a `render` prop. diff --git a/apps/www/src/content/docs/theme/overview/props.ts b/apps/www/src/content/docs/theme/overview/props.ts index e76751474..2fc3fd153 100644 --- a/apps/www/src/content/docs/theme/overview/props.ts +++ b/apps/www/src/content/docs/theme/overview/props.ts @@ -72,6 +72,26 @@ export type ThemeProviderProps = { children?: React.ReactNode; }; +export type ThemeScopeProps = { + /** Color scheme for this subtree. Sets `data-theme` on the rendered element. */ + theme?: 'light' | 'dark'; + + /** Accent color for this subtree. Sets `data-accent-color`. */ + accentColor?: 'indigo' | 'orange' | 'mint'; + + /** Gray variant for this subtree. Sets `data-gray-color`. */ + grayColor?: 'gray' | 'mauve' | 'slate'; + + /** Style variant for this subtree. Sets `data-style`. */ + styleVariant?: 'modern' | 'traditional'; + + /** Element to render the scope on. Defaults to a `
`. Use this to fuse the scope onto an existing layout element and avoid an extra wrapper. */ + render?: React.ReactElement; + + /** React children rendered inside the scope. */ + children?: React.ReactNode; +}; + export type UseThemeProps = { /** Current theme name ("light", "dark", or "system") */ theme?: string; diff --git a/packages/raystack/components/theme-provider/__tests__/theme-scope.test.tsx b/packages/raystack/components/theme-provider/__tests__/theme-scope.test.tsx new file mode 100644 index 000000000..feee985c7 --- /dev/null +++ b/packages/raystack/components/theme-provider/__tests__/theme-scope.test.tsx @@ -0,0 +1,127 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { ThemeScope } from '../theme-scope'; + +describe('ThemeScope', () => { + describe('Default rendering', () => { + it('renders a div by default with the provided children', () => { + render( + + inside + + ); + + const node = screen.getByTestId('scope'); + expect(node.tagName).toBe('DIV'); + expect(screen.getByText('inside')).toBeInTheDocument(); + }); + + it('writes data-theme when theme is provided', () => { + render(); + expect(screen.getByTestId('scope')).toHaveAttribute('data-theme', 'dark'); + }); + + it('omits data attributes when their props are not provided', () => { + render(); + + const node = screen.getByTestId('scope'); + expect(node).not.toHaveAttribute('data-theme'); + expect(node).not.toHaveAttribute('data-accent-color'); + expect(node).not.toHaveAttribute('data-gray-color'); + expect(node).not.toHaveAttribute('data-style'); + }); + + it('writes every supported data attribute', () => { + render( + + ); + + const node = screen.getByTestId('scope'); + expect(node).toHaveAttribute('data-theme', 'light'); + expect(node).toHaveAttribute('data-accent-color', 'orange'); + expect(node).toHaveAttribute('data-gray-color', 'mauve'); + expect(node).toHaveAttribute('data-style', 'traditional'); + }); + + it('forwards arbitrary HTML attributes', () => { + render( + + ); + + const node = screen.getByTestId('scope'); + expect(node).toHaveAttribute('id', 'my-scope'); + expect(node).toHaveClass('custom-class'); + }); + + it('passes through user-provided style', () => { + render( + + ); + + expect(screen.getByTestId('scope')).toHaveStyle({ background: 'red' }); + }); + }); + + describe('render prop', () => { + it('renders the provided element instead of a default div', () => { + render( + }> + inside + + ); + + const node = screen.getByTestId('scope'); + expect(node.tagName).toBe('SECTION'); + expect(screen.getByText('inside')).toBeInTheDocument(); + }); + + it('merges data attributes onto the rendered element', () => { + render( + } + /> + ); + + const node = screen.getByTestId('scope'); + expect(node).toHaveAttribute('data-theme', 'dark'); + expect(node).toHaveAttribute('data-accent-color', 'mint'); + }); + + it('preserves the rendered element’s own attributes alongside the merged ones', () => { + render( + + } + /> + ); + + const node = screen.getByTestId('scope'); + expect(node).toHaveAttribute('id', 'my-section'); + expect(node).toHaveClass('base-class'); + expect(node).toHaveAttribute('data-theme', 'light'); + }); + }); +}); diff --git a/packages/raystack/components/theme-provider/index.tsx b/packages/raystack/components/theme-provider/index.tsx index f6ba57169..5972173fe 100644 --- a/packages/raystack/components/theme-provider/index.tsx +++ b/packages/raystack/components/theme-provider/index.tsx @@ -1,4 +1,5 @@ -export { ThemeSwitcher } from "./switcher"; -export { ThemeProvider, useTheme } from "./theme"; -export { ThemeProviderProps } from "./types"; +export { ThemeSwitcher } from './switcher'; +export { ThemeProvider, useTheme } from './theme'; +export { ThemeScope, type ThemeScopeProps } from './theme-scope'; +export { ThemeProviderProps } from './types'; // Note: This themeProvider folder is a merge of old and the new themeProvider. Both old and the v1 folder contains the exact copy of themeProvider which was merged. diff --git a/packages/raystack/components/theme-provider/theme-scope.tsx b/packages/raystack/components/theme-provider/theme-scope.tsx new file mode 100644 index 000000000..c028b7ee5 --- /dev/null +++ b/packages/raystack/components/theme-provider/theme-scope.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { mergeProps, useRender } from '@base-ui/react'; +import type { + AccentColor, + ColorScheme, + GrayColor, + StyleVariant +} from './types'; + +export interface ThemeScopeProps extends useRender.ComponentProps<'div'> { + /** Color scheme for this subtree. Sets `data-theme`; native UI (form + * controls, scrollbars) follows via the package's `color-scheme` rule. */ + theme?: ColorScheme; + /** Accent color for this subtree. Sets `data-accent-color`. */ + accentColor?: AccentColor; + /** Gray variant for this subtree. Sets `data-gray-color`. */ + grayColor?: GrayColor; + /** Style variant for this subtree. Sets `data-style`. */ + styleVariant?: StyleVariant; +} + +export function ThemeScope({ + theme, + accentColor, + grayColor, + styleVariant, + render, + ref, + ...props +}: ThemeScopeProps) { + return useRender({ + defaultTagName: 'div', + ref, + render, + props: mergeProps<'div'>( + { + 'data-theme': theme, + 'data-accent-color': accentColor, + 'data-gray-color': grayColor, + 'data-style': styleVariant + } as useRender.ComponentProps<'div'>, + props + ) + }); +} + +ThemeScope.displayName = 'ThemeScope'; diff --git a/packages/raystack/components/theme-provider/theme.tsx b/packages/raystack/components/theme-provider/theme.tsx index ae9c2bf93..239aa1bb2 100644 --- a/packages/raystack/components/theme-provider/theme.tsx +++ b/packages/raystack/components/theme-provider/theme.tsx @@ -10,10 +10,10 @@ import { useMemo, useState } from 'react'; - import type { ThemeProviderProps, UseThemeProps } from './types'; +import { COLOR_SCHEMES } from './types'; -const colorSchemes = ['light', 'dark']; +const colorSchemes: string[] = [...COLOR_SCHEMES]; const MEDIA = '(prefers-color-scheme: dark)'; const isServer = typeof window === 'undefined'; const ThemeContext = createContext(undefined); @@ -31,7 +31,7 @@ export function ThemeProvider(props: ThemeProviderProps) { ThemeProvider.displayName = 'ThemeProvider'; -const defaultThemes = ['light', 'dark']; +const defaultThemes: string[] = [...COLOR_SCHEMES]; const Theme = ({ forcedTheme, diff --git a/packages/raystack/components/theme-provider/types.ts b/packages/raystack/components/theme-provider/types.ts index 897aad606..301ed2e3c 100644 --- a/packages/raystack/components/theme-provider/types.ts +++ b/packages/raystack/components/theme-provider/types.ts @@ -2,6 +2,16 @@ interface ValueObject { [themeName: string]: string; } +export const COLOR_SCHEMES = ['light', 'dark'] as const; +export const ACCENT_COLORS = ['indigo', 'orange', 'mint'] as const; +export const GRAY_COLORS = ['gray', 'mauve', 'slate'] as const; +export const STYLE_VARIANTS = ['modern', 'traditional'] as const; + +export type ColorScheme = (typeof COLOR_SCHEMES)[number]; +export type AccentColor = (typeof ACCENT_COLORS)[number]; +export type GrayColor = (typeof GRAY_COLORS)[number]; +export type StyleVariant = (typeof STYLE_VARIANTS)[number]; + export interface UseThemeProps { /** List of all available theme names */ themes: string[]; @@ -14,7 +24,7 @@ export interface UseThemeProps { /** If `enableSystem` is true and the active theme is "system", this returns whether the system preference resolved to "dark" or "light". Otherwise, identical to `theme` */ resolvedTheme?: string; /** If enableSystem is true, returns the System theme preference ("dark" or "light"), regardless what the active theme is */ - systemTheme?: "dark" | "light"; + systemTheme?: 'dark' | 'light'; } export interface ThemeProviderProps { @@ -33,17 +43,17 @@ export interface ThemeProviderProps { /** Default theme name (for v0.0.12 and lower the default was light). If `enableSystem` is false, the default theme is light */ defaultTheme?: string; /** HTML attribute modified based on the active theme. Accepts `class` and `data-*` (meaning any data attribute, `data-mode`, `data-color`, etc.) */ - attribute?: string | "class"; + attribute?: string | 'class'; /** Mapping of theme name to HTML attribute value. Object where key is the theme name and value is the attribute value */ value?: ValueObject; /** Nonce string to pass to the inline script for CSP headers */ nonce?: string; /** React children to be rendered within the ThemeProvider */ children?: React.ReactNode; - /** Style variant of the theme, either 'modern' or 'traditional'. Affects the radius and font properties. */ - style?: 'modern' | 'traditional'; - /** Accent color for the theme, options are 'indigo', 'orange', or 'mint' */ - accentColor?: 'indigo' | 'orange' | 'mint'; - /** Gray color variant for the theme, options are 'gray', 'mauve', or 'slate' */ - grayColor?: 'gray' | 'mauve' | 'slate'; + /** Style variant of the theme. Affects the radius and font properties. */ + style?: StyleVariant; + /** Accent color for the theme. */ + accentColor?: AccentColor; + /** Gray color variant for the theme. */ + grayColor?: GrayColor; } diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index cd849b3b3..bd96f6071 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -74,6 +74,8 @@ export { TextArea } from './components/text-area'; export { ThemeProvider, ThemeProviderProps, + ThemeScope, + ThemeScopeProps, ThemeSwitcher, useTheme } from './components/theme-provider'; diff --git a/packages/raystack/styles/colors.css b/packages/raystack/styles/colors.css index 729fff03e..d8fc2940c 100644 --- a/packages/raystack/styles/colors.css +++ b/packages/raystack/styles/colors.css @@ -1,9 +1,10 @@ -/* - * Note: Use color variables from this file only. - * Avoid using color variables from other files in the v1/styles folder directly. +/* + * Note: Use color variables from this file only. + * Avoid using color variables from other files in the styles folder directly. */ -:root { +[data-theme='light'], +[data-theme='dark'] { /* Base Foreground Colors */ --rs-color-foreground-base-primary: var(--rs-neutral-12); --rs-color-foreground-base-secondary: var(--rs-neutral-11); @@ -108,7 +109,7 @@ --rs-color-foreground-danger-emphasis: var(--rs-danger-contrast); --rs-color-foreground-attention-emphasis: var(--rs-attention-contrast); --rs-color-foreground-success-emphasis: var(--rs-success-contrast); - + /* Visualization Colors */ /* Sky */ --rs-color-viz-sky-11: var(--rs-viz-sky-11); diff --git a/packages/raystack/styles/primitives/accent.css b/packages/raystack/styles/primitives/accent.css index 3f8a65da0..ae445015e 100644 --- a/packages/raystack/styles/primitives/accent.css +++ b/packages/raystack/styles/primitives/accent.css @@ -1,11 +1,11 @@ -/* - * Note: Do not use this file directly. - * It is used for generating the final colors file. +/* + * Note: Do not use this file directly. + * It is used for generating the final colors file. * All variables must be used from colors.css file only. */ /* [data-accent-color='indigo'] { */ -:root { +[data-theme='light'] { --rs-accent-1: oklch(0.9943 0.0013 286.38); --rs-accent-2: oklch(0.9823 0.0083 271.33); --rs-accent-3: oklch(0.9609 0.017 267.79); diff --git a/packages/raystack/styles/primitives/appearance.css b/packages/raystack/styles/primitives/appearance.css index 6c4640d4c..60b3af6b3 100644 --- a/packages/raystack/styles/primitives/appearance.css +++ b/packages/raystack/styles/primitives/appearance.css @@ -1,14 +1,26 @@ -/* - * Note: Do not use this file directly. - * It is used for generating the final colors file. +/* + * Note: Do not use this file directly. + * It is used for generating the final colors file. * All variables must be used from colors.css file only. */ -/* Light Theme Colors */ -:root { - /* Smooth theme switch transition */ +/* Smooth theme switch transition — applies regardless of which theme is active */ +[data-theme="light"], +[data-theme="dark"] { transition: background-color 0.4s ease, color 0.4s ease; +} + +/* Native UI (form controls, scrollbars, text selection) follows the scoped theme */ +[data-theme="light"] { + color-scheme: light; +} +[data-theme="dark"] { + color-scheme: dark; +} + +/* Light Theme Colors */ +[data-theme="light"] { /* Neutral Colors */ --rs-neutral-1: var(--rs-gray-1); --rs-neutral-2: var(--rs-gray-2); @@ -134,7 +146,7 @@ } /* Dark Theme Colors */ -html[data-theme="dark"] { +[data-theme="dark"] { /* Neutral Colors */ --rs-neutral-1: var(--rs-gray-1); --rs-neutral-2: var(--rs-gray-2); @@ -256,4 +268,4 @@ html[data-theme="dark"] { /* Overlay color */ --rs-overlay-1: oklch(1 0 0 / 0.302); /* 30% opacity */ -} \ No newline at end of file +} diff --git a/packages/raystack/styles/primitives/gray.css b/packages/raystack/styles/primitives/gray.css index 6c7d32884..66c7e901e 100644 --- a/packages/raystack/styles/primitives/gray.css +++ b/packages/raystack/styles/primitives/gray.css @@ -5,7 +5,8 @@ */ -[data-gray-color='gray'] { +[data-gray-color='gray'], +[data-theme='light'] { --rs-gray-1: oklch(0.9911 0 0); --rs-gray-2: oklch(0.9821 0 0); --rs-gray-3: oklch(0.9551 0 0); @@ -20,7 +21,8 @@ --rs-gray-12: oklch(0.2435 0 0); } -[data-gray-color='gray'][data-theme='dark'] { +[data-gray-color='gray'][data-theme='dark'], +[data-theme='dark'] { --rs-gray-1: oklch(0.1776 0 0); --rs-gray-2: oklch(0.2134 0 0); --rs-gray-3: oklch(0.252 0 0); From c54ada3a37b5888612eb068d4467bf7b93382747 Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Fri, 15 May 2026 15:37:33 +0530 Subject: [PATCH 2/2] refactor(theme-provider): unify ThemeScope into nested ThemeProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThemeProvider auto-detects scope vs root via context: nested usage renders a wrapper `
` with `data-*` overrides instead of a no-op fragment. Drops the ThemeScope component (and its render-prop/Base UI dep) so consumers have one entry point. useTheme() inside a scope still returns the root provider's state — scoped mode is stateless by design. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/app/examples/scoped-theme/page.tsx | 136 ++++++------ .../src/content/docs/theme/overview/index.mdx | 42 ++-- .../src/content/docs/theme/overview/props.ts | 20 -- .../__tests__/theme-scope.test.tsx | 203 ++++++++---------- .../components/theme-provider/index.tsx | 1 - .../components/theme-provider/theme-scope.tsx | 48 ----- .../components/theme-provider/theme.tsx | 25 ++- packages/raystack/index.tsx | 2 - 8 files changed, 190 insertions(+), 287 deletions(-) delete mode 100644 packages/raystack/components/theme-provider/theme-scope.tsx diff --git a/apps/www/src/app/examples/scoped-theme/page.tsx b/apps/www/src/app/examples/scoped-theme/page.tsx index 6624cc565..388ddad2a 100644 --- a/apps/www/src/app/examples/scoped-theme/page.tsx +++ b/apps/www/src/app/examples/scoped-theme/page.tsx @@ -6,7 +6,7 @@ import { Flex, IconButton, Text, - ThemeScope + ThemeProvider } from '@raystack/apsara'; import { Moon, Sun } from 'lucide-react'; import { useState } from 'react'; @@ -54,80 +54,76 @@ const Page = () => { - - } - > - - - Scoped box + + + + + Scoped box + + + setScopeTheme(scopeTheme === 'dark' ? 'light' : 'dark') + } + > + {scopeTheme === 'dark' ? : } + + + + This box themes itself via{' '} + data-theme="{scopeTheme}", independent of the + page. - - setScopeTheme(scopeTheme === 'dark' ? 'light' : 'dark') - } - > - {scopeTheme === 'dark' ? : } - - - - This box themes itself via{' '} - data-theme="{scopeTheme}", independent of the - page. - - - - + + + + - + - - } - > - - - Semantic colors in scope + + + + + Semantic colors in scope + + + setCalloutScopeTheme( + calloutScopeTheme === 'dark' ? 'light' : 'dark' + ) + } + > + {calloutScopeTheme === 'dark' ? : } + + + + Accent, success, danger, and attention tokens all re-resolve at the + scope. - - setCalloutScopeTheme( - calloutScopeTheme === 'dark' ? 'light' : 'dark' - ) - } - > - {calloutScopeTheme === 'dark' ? : } - + Accent — informational message + Success — operation completed + Danger — something went wrong + + Attention — review before continuing + - - Accent, success, danger, and attention tokens all re-resolve at the - scope. - - Accent — informational message - Success — operation completed - Danger — something went wrong - Attention — review before continuing - + ); }; diff --git a/apps/www/src/content/docs/theme/overview/index.mdx b/apps/www/src/content/docs/theme/overview/index.mdx index 82b5ce0b8..240876389 100644 --- a/apps/www/src/content/docs/theme/overview/index.mdx +++ b/apps/www/src/content/docs/theme/overview/index.mdx @@ -153,62 +153,52 @@ Because scoping is implemented in CSS, you can opt in by simply setting the attr The package's stylesheet handles the rest: every `--rs-color-*` token, `color-scheme` for native form controls and scrollbars, and the smooth transition during theme switches all follow the scoped attribute. -### `ThemeScope` component +### Nested `ThemeProvider` -For a typed convenience wrapper, use `ThemeScope`: +For a typed convenience wrapper, nest `ThemeProvider`: ```tsx -import { ThemeScope } from "@raystack/apsara"; +import { ThemeProvider } from "@raystack/apsara"; - + Dark scoped card - + ``` -`ThemeScope` writes `data-theme` (and optionally `data-accent-color`, `data-gray-color`, `data-style`) onto a wrapper element. By default it renders a `
`. Use the `render` prop to fuse the scope onto an element you already have, with no extra wrapping div: - -```tsx -}> - ... - ... - -``` +When `ThemeProvider` is rendered inside another `ThemeProvider`, it switches to scope mode: it writes `data-theme` (and optionally `data-accent-color`, `data-gray-color`, `data-style`) onto a wrapper `
` rather than the document root. The outer provider's state remains the source of truth for `useTheme()`. ### Combining with accent and gray overrides A scope can override accent or gray independently of theme. This is useful for highlighting a section without changing its color scheme: ```tsx - + - + - + Dark mint-on-slate card - + ``` ### State management -`ThemeScope` is stateless — the consumer owns the theme value. For an interactive scope, drive it with React state: +Nested `ThemeProvider` is stateless — the consumer owns the theme value. For an interactive scope, drive it with React state: ```tsx const [scopeTheme, setScopeTheme] = useState<'light' | 'dark'>('dark'); -}> + setScopeTheme(t => t === 'dark' ? 'light' : 'dark')}> Toggle this scope ... - + ``` -If you need persistence across page loads, manage it yourself with `localStorage` and a `useEffect`. `ThemeScope` deliberately avoids touching storage to keep the component pure and to avoid disambiguation issues when multiple scopes share a page. - - +If you need persistence across page loads, manage it yourself with `localStorage` and a `useEffect`. Nested providers deliberately avoid touching storage to keep them pure and to avoid disambiguation issues when multiple scopes share a page. -### When to reach for `ThemeScope` vs. the bare attribute +### When to reach for a nested provider vs. the bare attribute - Use the **bare `data-theme` attribute** when you're already rendering a custom element and don't want another wrapper. The CSS handles everything — components inside will theme correctly. -- Use **`ThemeScope`** when you want typed props (`theme`, `accentColor`, etc.), defaults handled for you, and a single import that documents intent. -- Both produce the same DOM output when `ThemeScope` is given a `render` prop. +- Use a **nested `ThemeProvider`** when you want typed props (`forcedTheme`, `accentColor`, etc.) and a single import that documents intent. diff --git a/apps/www/src/content/docs/theme/overview/props.ts b/apps/www/src/content/docs/theme/overview/props.ts index 5ee129674..8d89f5f8d 100644 --- a/apps/www/src/content/docs/theme/overview/props.ts +++ b/apps/www/src/content/docs/theme/overview/props.ts @@ -78,26 +78,6 @@ export type ThemeProviderProps = { children?: React.ReactNode; }; -export type ThemeScopeProps = { - /** Color scheme for this subtree. Sets `data-theme` on the rendered element. */ - theme?: 'light' | 'dark'; - - /** Accent color for this subtree. Sets `data-accent-color`. */ - accentColor?: 'indigo' | 'orange' | 'mint'; - - /** Gray variant for this subtree. Sets `data-gray-color`. */ - grayColor?: 'gray' | 'mauve' | 'slate'; - - /** Style variant for this subtree. Sets `data-style`. */ - styleVariant?: 'modern' | 'traditional'; - - /** Element to render the scope on. Defaults to a `
`. Use this to fuse the scope onto an existing layout element and avoid an extra wrapper. */ - render?: React.ReactElement; - - /** React children rendered inside the scope. */ - children?: React.ReactNode; -}; - export type UseThemeProps = { /** Current theme name ("light", "dark", or "system") */ theme?: string; diff --git a/packages/raystack/components/theme-provider/__tests__/theme-scope.test.tsx b/packages/raystack/components/theme-provider/__tests__/theme-scope.test.tsx index feee985c7..a5206c870 100644 --- a/packages/raystack/components/theme-provider/__tests__/theme-scope.test.tsx +++ b/packages/raystack/components/theme-provider/__tests__/theme-scope.test.tsx @@ -1,127 +1,96 @@ import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { ThemeScope } from '../theme-scope'; - -describe('ThemeScope', () => { - describe('Default rendering', () => { - it('renders a div by default with the provided children', () => { - render( - - inside - - ); +import { describe, expect, it, vi } from 'vitest'; +import { ThemeProvider } from '../theme'; + +// jsdom doesn't ship these; the root ThemeProvider needs them on mount. +Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn() + } +}); - const node = screen.getByTestId('scope'); - expect(node.tagName).toBe('DIV'); - expect(screen.getByText('inside')).toBeInTheDocument(); - }); +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })) +}); - it('writes data-theme when theme is provided', () => { - render(); - expect(screen.getByTestId('scope')).toHaveAttribute('data-theme', 'dark'); - }); +// Nested usage: ThemeProvider renders a scoped wrapper with data-* attrs. +describe('ThemeProvider (scoped)', () => { + it('renders a div wrapper with children when nested', () => { + render( + + + inside + + + ); + + const child = screen.getByTestId('child'); + expect(child.parentElement?.tagName).toBe('DIV'); + expect(screen.getByText('inside')).toBeInTheDocument(); + }); - it('omits data attributes when their props are not provided', () => { - render(); + it('writes data-theme on the scoped wrapper', () => { + render( + + + + + + ); + + expect(screen.getByTestId('child').parentElement).toHaveAttribute( + 'data-theme', + 'dark' + ); + }); - const node = screen.getByTestId('scope'); - expect(node).not.toHaveAttribute('data-theme'); - expect(node).not.toHaveAttribute('data-accent-color'); - expect(node).not.toHaveAttribute('data-gray-color'); - expect(node).not.toHaveAttribute('data-style'); - }); + it('omits data attributes when their props are not provided', () => { + render( + + + + + + ); + + const wrapper = screen.getByTestId('child').parentElement!; + expect(wrapper).not.toHaveAttribute('data-theme'); + expect(wrapper).not.toHaveAttribute('data-accent-color'); + expect(wrapper).not.toHaveAttribute('data-gray-color'); + expect(wrapper).not.toHaveAttribute('data-style'); + }); - it('writes every supported data attribute', () => { - render( - { + render( + + - ); - - const node = screen.getByTestId('scope'); - expect(node).toHaveAttribute('data-theme', 'light'); - expect(node).toHaveAttribute('data-accent-color', 'orange'); - expect(node).toHaveAttribute('data-gray-color', 'mauve'); - expect(node).toHaveAttribute('data-style', 'traditional'); - }); - - it('forwards arbitrary HTML attributes', () => { - render( - - ); - - const node = screen.getByTestId('scope'); - expect(node).toHaveAttribute('id', 'my-scope'); - expect(node).toHaveClass('custom-class'); - }); - - it('passes through user-provided style', () => { - render( - - ); - - expect(screen.getByTestId('scope')).toHaveStyle({ background: 'red' }); - }); - }); - - describe('render prop', () => { - it('renders the provided element instead of a default div', () => { - render( - }> - inside - - ); - - const node = screen.getByTestId('scope'); - expect(node.tagName).toBe('SECTION'); - expect(screen.getByText('inside')).toBeInTheDocument(); - }); - - it('merges data attributes onto the rendered element', () => { - render( - } - /> - ); - - const node = screen.getByTestId('scope'); - expect(node).toHaveAttribute('data-theme', 'dark'); - expect(node).toHaveAttribute('data-accent-color', 'mint'); - }); - - it('preserves the rendered element’s own attributes alongside the merged ones', () => { - render( - - } - /> - ); - - const node = screen.getByTestId('scope'); - expect(node).toHaveAttribute('id', 'my-section'); - expect(node).toHaveClass('base-class'); - expect(node).toHaveAttribute('data-theme', 'light'); - }); + style='traditional' + > + + + + ); + + const wrapper = screen.getByTestId('child').parentElement!; + expect(wrapper).toHaveAttribute('data-theme', 'light'); + expect(wrapper).toHaveAttribute('data-accent-color', 'orange'); + expect(wrapper).toHaveAttribute('data-gray-color', 'mauve'); + expect(wrapper).toHaveAttribute('data-style', 'traditional'); }); }); diff --git a/packages/raystack/components/theme-provider/index.tsx b/packages/raystack/components/theme-provider/index.tsx index bd3b55c97..f791f82eb 100644 --- a/packages/raystack/components/theme-provider/index.tsx +++ b/packages/raystack/components/theme-provider/index.tsx @@ -1,4 +1,3 @@ export { ThemeSwitcher } from './switcher'; export { ThemeProvider, useTheme } from './theme'; -export { ThemeScope, type ThemeScopeProps } from './theme-scope'; export { ThemeProviderProps } from './types'; diff --git a/packages/raystack/components/theme-provider/theme-scope.tsx b/packages/raystack/components/theme-provider/theme-scope.tsx deleted file mode 100644 index c028b7ee5..000000000 --- a/packages/raystack/components/theme-provider/theme-scope.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client'; - -import { mergeProps, useRender } from '@base-ui/react'; -import type { - AccentColor, - ColorScheme, - GrayColor, - StyleVariant -} from './types'; - -export interface ThemeScopeProps extends useRender.ComponentProps<'div'> { - /** Color scheme for this subtree. Sets `data-theme`; native UI (form - * controls, scrollbars) follows via the package's `color-scheme` rule. */ - theme?: ColorScheme; - /** Accent color for this subtree. Sets `data-accent-color`. */ - accentColor?: AccentColor; - /** Gray variant for this subtree. Sets `data-gray-color`. */ - grayColor?: GrayColor; - /** Style variant for this subtree. Sets `data-style`. */ - styleVariant?: StyleVariant; -} - -export function ThemeScope({ - theme, - accentColor, - grayColor, - styleVariant, - render, - ref, - ...props -}: ThemeScopeProps) { - return useRender({ - defaultTagName: 'div', - ref, - render, - props: mergeProps<'div'>( - { - 'data-theme': theme, - 'data-accent-color': accentColor, - 'data-gray-color': grayColor, - 'data-style': styleVariant - } as useRender.ComponentProps<'div'>, - props - ) - }); -} - -ThemeScope.displayName = 'ThemeScope'; diff --git a/packages/raystack/components/theme-provider/theme.tsx b/packages/raystack/components/theme-provider/theme.tsx index 579d84cb2..4c6a75e6d 100644 --- a/packages/raystack/components/theme-provider/theme.tsx +++ b/packages/raystack/components/theme-provider/theme.tsx @@ -2,7 +2,6 @@ import { createContext, - Fragment, memo, useCallback, useContext, @@ -25,13 +24,33 @@ export const useTheme = () => useContext(ThemeContext) ?? defaultContext; export function ThemeProvider(props: ThemeProviderProps) { const context = useContext(ThemeContext); - // Ignore nested context providers, just passthrough children - if (context) return {props.children}; + // Nested usage: scoped subtree. Render a wrapper element that overrides + // theme tokens locally via `data-*` attributes; the parent provider's + // global state remains the source of truth for descendants reading + // `useTheme()`. + if (context) return ; return ; } ThemeProvider.displayName = 'ThemeProvider'; +const Scoped = ({ + forcedTheme, + accentColor, + grayColor, + style, + children +}: ThemeProviderProps) => ( +
+ {children} +
+); + const defaultThemes: string[] = [...COLOR_SCHEMES]; const Theme = ({ diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 95883660d..3e177e22f 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -87,8 +87,6 @@ export { TextArea } from './components/text-area'; export { ThemeProvider, ThemeProviderProps, - ThemeScope, - ThemeScopeProps, ThemeSwitcher, useTheme } from './components/theme-provider';