diff --git a/zeppelin-web-angular/projects/zeppelin-react/README.md b/zeppelin-web-angular/projects/zeppelin-react/README.md index a32e90643f3..f9d1b1d6317 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/README.md +++ b/zeppelin-web-angular/projects/zeppelin-react/README.md @@ -89,6 +89,7 @@ src/ │ └── PublishedParagraph.tsx # entry component + mount() ├── templates/ │ └── SingleResultRenderer.tsx # routes result types to renderers +├── theme/ # host theme detection, antd + chart.js theming ├── utils/ # tableUtils, textUtils, exportFile └── main.ts # re-exports for Module Federation ``` @@ -109,12 +110,18 @@ export function mount(element: HTMLElement, props: Props): ReactMountHandle; 1. Create a component (e.g. `src/components//ExampleFeature.tsx`). 2. Wrap its render tree in ``. -3. Export a `mount(element, props)` function that: +3. Wrap it in `` as well (see `src/theme/`), otherwise + antd builds its styles from the default light algorithm and the module only + looks right in dark mode while the shell's global `.ant-*` rules happen to + cover the components in use. Pass surface specific tokens through its + `token` prop, and read `useHostThemeMode()` when you draw outside antd, as + a canvas chart does. +4. Export a `mount(element, props)` function that: - Creates a single `Root` via `createRoot(element)`. - Calls `root.render()` on initial mount AND on every `update(newProps)` call. React's reconciler preserves state. - Returns `{ update, unmount }`. `unmount` calls `root.unmount()`. -4. Register in `webpack.config.js` under `exposes`: +5. Register in `webpack.config.js` under `exposes`: ```js exposes: { './PublishedParagraph': './src/pages/PublishedParagraph', @@ -122,14 +129,14 @@ export function mount(element: HTMLElement, props: Props): ReactMountHandle; './ExampleFeature': './src/components//ExampleFeature' } ``` -5. Re-export from `main.ts`: +6. Re-export from `main.ts`: ```ts export { ExampleFeature, mount as mountExampleFeature } from './components//ExampleFeature'; ``` -6. Use from Angular by adding the directive to your template: +7. Use from Angular by adding the directive to your template: ```html
{ const [currentMode, setCurrentMode] = useState(config?.graph.mode || 'table'); const chartRef = useRef(null); + const themeMode = useHostThemeMode(); const tableData = useMemo(() => parseTableData(result.data), [result.data]); @@ -86,6 +88,10 @@ export const TableVisualization = ({ result, config }: TableVisualizationProps) const ChartConstructor = module.Chart || module.default; + // Ticks, legend labels and grid lines all resolve from these two + // globals, and a canvas is out of reach of the shell's stylesheets. + applyChartTheme(ChartConstructor, themeMode); + const canvas = document.createElement('canvas'); canvas.style.width = '100%'; canvas.style.height = '100%'; @@ -222,7 +228,7 @@ export const TableVisualization = ({ result, config }: TableVisualizationProps) container.innerHTML = ''; } }; - }, [currentMode, tableData]); + }, [currentMode, tableData, themeMode]); return (
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/PublishedParagraph.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/pages/PublishedParagraph.tsx index 2d48314d5c1..04ea1024b78 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/pages/PublishedParagraph.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/PublishedParagraph.tsx @@ -11,30 +11,26 @@ */ import { createRoot } from 'react-dom/client'; -import { ConfigProvider } from 'antd'; import { Empty } from '@/components'; import { SingleResultRenderer } from '@/templates'; +import { ZeppelinThemeProvider } from '@/theme'; import type { ParagraphConfigResults, ParagraphIResultsMsgItem } from '@zeppelin/sdk'; +const RESULT_FONT_FAMILY = "'Lucida Console', Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace"; + export interface PublishedParagraphProps { paragraphId: string; results?: ParagraphIResultsMsgItem[]; config?: ParagraphConfigResults; } -export const PublishedParagraph = ({ results, config }: PublishedParagraphProps) => { - if (!results || results.length === 0) { - return ; - } - - return ( - +export const PublishedParagraph = ({ results, config }: PublishedParagraphProps) => ( + // The empty state is inside the provider too: antd's Empty illustration is + // themed, so leaving it outside would leak a light widget into a dark page. + + {!results || results.length === 0 ? ( + + ) : (
{results.map((result, index) => (
@@ -42,9 +38,9 @@ export const PublishedParagraph = ({ results, config }: PublishedParagraphProps)
))}
-
- ); -}; + )} + +); export const mount = (element: HTMLElement, props?: PublishedParagraphProps) => { if (!element) { diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.spec.tsx new file mode 100644 index 00000000000..d70daee60a2 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.spec.tsx @@ -0,0 +1,93 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import { theme as antdTheme } from 'antd'; +import { afterEach, describe, expect, it } from 'vitest'; +import { useHostThemeMode, ZeppelinThemeProvider } from './ZeppelinThemeProvider'; +import { HostThemeMode } from './hostTheme'; + +const Probe = () => { + const { token } = antdTheme.useToken(); + return ( + <> + {token.colorBgContainer} + {token.fontFamily} + {useHostThemeMode()} + + ); +}; + +const setHostTheme = (mode: HostThemeMode) => { + document.documentElement.setAttribute('data-theme', mode); +}; + +describe('ZeppelinThemeProvider', () => { + afterEach(() => { + document.documentElement.removeAttribute('data-theme'); + }); + + it('builds antd tokens from the dark algorithm when the shell is dark', () => { + setHostTheme('dark'); + render( + + + + ); + + // Light tokens would put a white container on the shell's dark page; today + // that only goes unnoticed because the shell's global .ant-* rules cover it. + expect(screen.getByTestId('container-bg').textContent).toBe('#141414'); + expect(screen.getByTestId('mode').textContent).toBe('dark'); + }); + + it('builds antd tokens from the default algorithm when the shell is light', () => { + setHostTheme('light'); + render( + + + + ); + + expect(screen.getByTestId('container-bg').textContent).toBe('#ffffff'); + expect(screen.getByTestId('mode').textContent).toBe('light'); + }); + + it('re-themes in place when the shell toggles the theme', async () => { + setHostTheme('light'); + render( + + + + ); + expect(screen.getByTestId('container-bg').textContent).toBe('#ffffff'); + + await act(async () => { + setHostTheme('dark'); + }); + + expect(screen.getByTestId('container-bg').textContent).toBe('#141414'); + }); + + it('keeps surface tokens while switching algorithms', () => { + setHostTheme('dark'); + render( + + + + ); + + expect(screen.getByTestId('font').textContent).toBe('Consolas'); + expect(screen.getByTestId('container-bg').textContent).toBe('#141414'); + }); +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.tsx new file mode 100644 index 00000000000..1ee2181aaae --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.tsx @@ -0,0 +1,47 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createContext, ReactNode, useContext } from 'react'; +import { ConfigProvider, theme as antdTheme, ThemeConfig } from 'antd'; +import { HostThemeMode, useHostTheme } from './hostTheme'; + +const HostThemeContext = createContext('light'); + +/** Resolved host theme for code that draws outside antd, such as canvas charts. */ +export const useHostThemeMode = (): HostThemeMode => useContext(HostThemeContext); + +export interface ZeppelinThemeProviderProps { + children: ReactNode; + /** Extra tokens for a single surface, e.g. a monospace result font. */ + token?: ThemeConfig['token']; +} + +/** + * Every exposed module should render inside this provider. Without it antd + * builds its styles from the default (light) algorithm, and the remote looks + * dark only for as long as the shell's global `.ant-*` rules happen to cover + * the components in use. + */ +export const ZeppelinThemeProvider = ({ children, token }: ZeppelinThemeProviderProps) => { + const mode = useHostTheme(); + + return ( + + {children} + + ); +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/theme/chartTheme.spec.ts b/zeppelin-web-angular/projects/zeppelin-react/src/theme/chartTheme.spec.ts new file mode 100644 index 00000000000..973bacdcdad --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/theme/chartTheme.spec.ts @@ -0,0 +1,51 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { applyChartTheme, CHART_THEME } from './chartTheme'; + +// chart.js ships '#666' text and 'rgba(0, 0, 0, 0.1)' grid lines, both of +// which are meant for a light canvas. +const chartJsDefaults = () => ({ defaults: { color: '#666', borderColor: 'rgba(0, 0, 0, 0.1)' } }); + +describe('applyChartTheme', () => { + it('replaces the chart.js defaults with the dark palette', () => { + const chart = chartJsDefaults(); + + applyChartTheme(chart, 'dark'); + + expect(chart.defaults.color).toBe(CHART_THEME.dark.text); + expect(chart.defaults.borderColor).toBe(CHART_THEME.dark.grid); + }); + + it('replaces the chart.js defaults with the light palette', () => { + const chart = chartJsDefaults(); + + applyChartTheme(chart, 'light'); + + expect(chart.defaults.color).toBe(CHART_THEME.light.text); + expect(chart.defaults.borderColor).toBe(CHART_THEME.light.grid); + }); + + it('leaves no chart.js default in place for either mode', () => { + // The point of the issue: axis labels at '#666' sit at about 3.2:1 against + // the shell's dark background, below the 4.5:1 the rest of the UI meets. + const untouched = chartJsDefaults().defaults; + + for (const mode of ['light', 'dark'] as const) { + const chart = chartJsDefaults(); + applyChartTheme(chart, mode); + expect(chart.defaults.color).not.toBe(untouched.color); + expect(chart.defaults.borderColor).not.toBe(untouched.borderColor); + } + }); +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/theme/chartTheme.ts b/zeppelin-web-angular/projects/zeppelin-react/src/theme/chartTheme.ts new file mode 100644 index 00000000000..01b563f95c2 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/theme/chartTheme.ts @@ -0,0 +1,37 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { HostThemeMode } from './hostTheme'; + +/** + * Charts are painted on a canvas, so no stylesheet reaches them. chart.js + * defaults to '#666' text and 'rgba(0, 0, 0, 0.1)' grid lines, which leaves + * axis labels at roughly 3.2:1 against the dark background and the grid + * invisible. These values follow antd's secondary text and split tokens. + */ +export const CHART_THEME: Record = { + light: { text: 'rgba(0, 0, 0, 0.65)', grid: 'rgba(0, 0, 0, 0.06)' }, + dark: { text: 'rgba(255, 255, 255, 0.65)', grid: 'rgba(255, 255, 255, 0.12)' } +}; + +/** The two globals chart.js resolves ticks, legend labels and grid lines from. */ +export interface ChartThemeTarget { + defaults: { + color: unknown; + borderColor: unknown; + }; +} + +export const applyChartTheme = (chart: ChartThemeTarget, mode: HostThemeMode): void => { + chart.defaults.color = CHART_THEME[mode].text; + chart.defaults.borderColor = CHART_THEME[mode].grid; +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/theme/hostTheme.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/theme/hostTheme.spec.tsx new file mode 100644 index 00000000000..4661daff283 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/theme/hostTheme.spec.tsx @@ -0,0 +1,122 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { HostThemeMode, readHostTheme, useHostTheme } from './hostTheme'; + +const Probe = () => {useHostTheme()}; + +const setHostTheme = (mode: HostThemeMode) => { + document.documentElement.setAttribute('data-theme', mode); + document.documentElement.classList.remove('light', 'dark'); + document.documentElement.classList.add(mode); +}; + +const stubMatchMedia = (matches: boolean) => { + const listeners = new Set<() => void>(); + const mql = { + matches, + addEventListener: (_: string, cb: () => void) => listeners.add(cb), + removeEventListener: (_: string, cb: () => void) => listeners.delete(cb) + }; + (window as unknown as { matchMedia?: unknown }).matchMedia = () => mql; + return { + set: (next: boolean) => { + mql.matches = next; + listeners.forEach(cb => cb()); + }, + listenerCount: () => listeners.size + }; +}; + +describe('readHostTheme', () => { + afterEach(() => { + document.documentElement.removeAttribute('data-theme'); + document.documentElement.classList.remove('light', 'dark'); + delete (window as unknown as { matchMedia?: unknown }).matchMedia; + }); + + it('reads the theme the shell writes to the document root', () => { + setHostTheme('dark'); + expect(readHostTheme()).toBe('dark'); + + setHostTheme('light'); + expect(readHostTheme()).toBe('light'); + }); + + it('falls back to the root class when the attribute is missing', () => { + document.documentElement.classList.add('dark'); + expect(readHostTheme()).toBe('dark'); + }); + + it('falls back to the OS preference when the shell declares nothing', () => { + stubMatchMedia(true); + expect(readHostTheme()).toBe('dark'); + }); + + it('defaults to light when neither the shell nor matchMedia is available', () => { + expect(readHostTheme()).toBe('light'); + }); +}); + +describe('useHostTheme', () => { + afterEach(() => { + document.documentElement.removeAttribute('data-theme'); + document.documentElement.classList.remove('light', 'dark'); + delete (window as unknown as { matchMedia?: unknown }).matchMedia; + }); + + it('starts from the declared theme', () => { + setHostTheme('dark'); + render(); + + expect(screen.getByTestId('mode').textContent).toBe('dark'); + }); + + it('follows the shell when the user toggles the theme while mounted', async () => { + setHostTheme('light'); + render(); + expect(screen.getByTestId('mode').textContent).toBe('light'); + + await act(async () => { + setHostTheme('dark'); + }); + + expect(screen.getByTestId('mode').textContent).toBe('dark'); + }); + + it('follows the OS only while the shell has declared nothing', () => { + const media = stubMatchMedia(false); + render(); + expect(screen.getByTestId('mode').textContent).toBe('light'); + expect(media.listenerCount()).toBe(1); + + act(() => { + media.set(true); + }); + + expect(screen.getByTestId('mode').textContent).toBe('dark'); + }); + + it('does not subscribe to the OS when the shell declares a theme', () => { + const media = stubMatchMedia(true); + setHostTheme('light'); + render(); + + // The shell already resolved 'system' for us, so a second source would + // let the OS override an explicit light/dark choice. + expect(screen.getByTestId('mode').textContent).toBe('light'); + expect(media.listenerCount()).toBe(0); + }); +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/theme/hostTheme.ts b/zeppelin-web-angular/projects/zeppelin-react/src/theme/hostTheme.ts new file mode 100644 index 00000000000..36368c6e5d0 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/theme/hostTheme.ts @@ -0,0 +1,78 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useState } from 'react'; + +export type HostThemeMode = 'light' | 'dark'; + +/** + * The Angular shell's ThemeService resolves 'system' for us and writes the + * result to the document root as `data-theme` plus a `dark`/`light` class. + * Reading that is what keeps the remote in step with the host without the + * host having to thread a prop through every mount point. + */ +export const readHostTheme = (): HostThemeMode => { + const root = document.documentElement; + const declared = root.getAttribute('data-theme'); + if (declared === 'dark' || declared === 'light') { + return declared; + } + if (root.classList.contains('dark')) { + return 'dark'; + } + if (root.classList.contains('light')) { + return 'light'; + } + + // Standalone dev server (port 3001) has no shell, so fall back to the OS + // preference the shell would have resolved itself. + if (typeof window.matchMedia === 'function') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + + return 'light'; +}; + +const hostDeclaresTheme = (): boolean => { + const root = document.documentElement; + return root.hasAttribute('data-theme') || root.classList.contains('dark') || root.classList.contains('light'); +}; + +/** Resolved host theme, kept up to date while mounted. */ +export const useHostTheme = (): HostThemeMode => { + const [mode, setMode] = useState(readHostTheme); + + useEffect(() => { + // The shell can apply its theme after the remote mounts, so re-read once + // the subscription is in place rather than trusting the initial render. + setMode(readHostTheme()); + const sync = () => setMode(readHostTheme()); + + const observer = new MutationObserver(sync); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] }); + + // Only follow the OS while the shell has not declared a theme; once it + // has, its value already accounts for the 'system' setting. + let media: MediaQueryList | undefined; + if (!hostDeclaresTheme() && typeof window.matchMedia === 'function') { + media = window.matchMedia('(prefers-color-scheme: dark)'); + media.addEventListener('change', sync); + } + + return () => { + observer.disconnect(); + media?.removeEventListener('change', sync); + }; + }, []); + + return mode; +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/theme/index.ts b/zeppelin-web-angular/projects/zeppelin-react/src/theme/index.ts new file mode 100644 index 00000000000..a9c900c9e28 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/theme/index.ts @@ -0,0 +1,15 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type { HostThemeMode } from './hostTheme'; +export { ZeppelinThemeProvider, useHostThemeMode } from './ZeppelinThemeProvider'; +export { applyChartTheme } from './chartTheme';