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..388ddad2a --- /dev/null +++ b/apps/www/src/app/examples/scoped-theme/page.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { + Button, + Callout, + Flex, + IconButton, + Text, + ThemeProvider +} 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..240876389 100644 --- a/apps/www/src/content/docs/theme/overview/index.mdx +++ b/apps/www/src/content/docs/theme/overview/index.mdx @@ -132,3 +132,73 @@ 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. + +### Nested `ThemeProvider` + +For a typed convenience wrapper, nest `ThemeProvider`: + +```tsx +import { ThemeProvider } from "@raystack/apsara"; + + + Dark scoped card + +``` + +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 + +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`. 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 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 a **nested `ThemeProvider`** when you want typed props (`forcedTheme`, `accentColor`, etc.) and a single import that documents intent. 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..a5206c870 --- /dev/null +++ b/packages/raystack/components/theme-provider/__tests__/theme-scope.test.tsx @@ -0,0 +1,96 @@ +import { render, screen } from '@testing-library/react'; +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() + } +}); + +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() + })) +}); + +// 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('writes data-theme on the scoped wrapper', () => { + render( + + + + + + ); + + expect(screen.getByTestId('child').parentElement).toHaveAttribute( + 'data-theme', + 'dark' + ); + }); + + 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( + + + + + + ); + + 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/theme.tsx b/packages/raystack/components/theme-provider/theme.tsx index 8698d104b..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, @@ -11,10 +10,10 @@ import { useRef, 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); @@ -25,14 +24,34 @@ 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 defaultThemes = ['light', 'dark']; +const Scoped = ({ + forcedTheme, + accentColor, + grayColor, + style, + children +}: ThemeProviderProps) => ( +
+ {children} +
+); + +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 debebb2c7..8d51d7142 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[]; @@ -40,12 +50,12 @@ export interface ThemeProviderProps { 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; /** Called when the active theme changes. `resolvedTheme` is the actual applied theme (`'light'`/`'dark'` when `theme` is `'system'`). Not fired on initial mount. */ onThemeChange?: (theme: string, resolvedTheme: string) => void; } diff --git a/packages/raystack/styles/colors.css b/packages/raystack/styles/colors.css index 95dd91a05..be687f6ac 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. + * 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); 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 5837c13f8..80df5e758 100644 --- a/packages/raystack/styles/primitives/appearance.css +++ b/packages/raystack/styles/primitives/appearance.css @@ -4,26 +4,36 @@ * All variables must be used from colors.css file only. */ -/* Light Theme Colors */ -:root { - /* Smooth theme switch transition */ - transition: - background-color 0.4s ease, - color 0.4s ease; +/* 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; +} - /* Neutral Colors */ - --rs-neutral-1: var(--rs-gray-1); - --rs-neutral-2: var(--rs-gray-2); - --rs-neutral-3: var(--rs-gray-3); - --rs-neutral-4: var(--rs-gray-4); - --rs-neutral-5: var(--rs-gray-5); - --rs-neutral-6: var(--rs-gray-6); - --rs-neutral-7: var(--rs-gray-7); - --rs-neutral-8: var(--rs-gray-8); - --rs-neutral-9: var(--rs-gray-9); - --rs-neutral-10: var(--rs-gray-10); - --rs-neutral-11: var(--rs-gray-11); - --rs-neutral-12: var(--rs-gray-12); +[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); + --rs-neutral-3: var(--rs-gray-3); + --rs-neutral-4: var(--rs-gray-4); + --rs-neutral-5: var(--rs-gray-5); + --rs-neutral-6: var(--rs-gray-6); + --rs-neutral-7: var(--rs-gray-7); + --rs-neutral-8: var(--rs-gray-8); + --rs-neutral-9: var(--rs-gray-9); + --rs-neutral-10: var(--rs-gray-10); + --rs-neutral-11: var(--rs-gray-11); + --rs-neutral-12: var(--rs-gray-12); /* Attention Colors */ --rs-attention-1: oklch(0.9943 0.0028 84.56); @@ -146,20 +156,20 @@ } /* Dark Theme Colors */ -html[data-theme="dark"] { - /* Neutral Colors */ - --rs-neutral-1: var(--rs-gray-1); - --rs-neutral-2: var(--rs-gray-2); - --rs-neutral-3: var(--rs-gray-3); - --rs-neutral-4: var(--rs-gray-4); - --rs-neutral-5: var(--rs-gray-5); - --rs-neutral-6: var(--rs-gray-6); - --rs-neutral-7: var(--rs-gray-7); - --rs-neutral-8: var(--rs-gray-8); - --rs-neutral-9: var(--rs-gray-9); - --rs-neutral-10: var(--rs-gray-10); - --rs-neutral-11: var(--rs-gray-11); - --rs-neutral-12: var(--rs-gray-12); +[data-theme="dark"] { + /* Neutral Colors */ + --rs-neutral-1: var(--rs-gray-1); + --rs-neutral-2: var(--rs-gray-2); + --rs-neutral-3: var(--rs-gray-3); + --rs-neutral-4: var(--rs-gray-4); + --rs-neutral-5: var(--rs-gray-5); + --rs-neutral-6: var(--rs-gray-6); + --rs-neutral-7: var(--rs-gray-7); + --rs-neutral-8: var(--rs-gray-8); + --rs-neutral-9: var(--rs-gray-9); + --rs-neutral-10: var(--rs-gray-10); + --rs-neutral-11: var(--rs-gray-11); + --rs-neutral-12: var(--rs-gray-12); /* Attention Colors */ --rs-attention-1: oklch(0.1976 0.0409 80.17); 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);