diff --git a/__tests__/html2/hooks/useStyles/cssModuleTheme.fluent.html b/__tests__/html2/hooks/useStyles/cssModuleTheme.fluent.html new file mode 100644 index 0000000000..fa68c5d8d8 --- /dev/null +++ b/__tests__/html2/hooks/useStyles/cssModuleTheme.fluent.html @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/hooks/useStyles/cssModuleTheme.fluent.html.snap-1.png b/__tests__/html2/hooks/useStyles/cssModuleTheme.fluent.html.snap-1.png new file mode 100644 index 0000000000..ab29e8bcb9 Binary files /dev/null and b/__tests__/html2/hooks/useStyles/cssModuleTheme.fluent.html.snap-1.png differ diff --git a/packages/bundle/.eslintrc.yml b/packages/bundle/.eslintrc.yml index 120e30c270..a4936c5bda 100644 --- a/packages/bundle/.eslintrc.yml +++ b/packages/bundle/.eslintrc.yml @@ -14,3 +14,6 @@ rules: - group: - '@msinternal/botframework-webchat-api-*' message: 'To prevent duplicated import, @msinternal/botframework-webchat-api-* is only allowed to import in botframework-webchat-api.' + paths: + - name: '@msinternal/botframework-webchat-styles/react' + message: 'Every copy of @msinternal/botframework-webchat-styles/react has its own React context, import it from botframework-webchat-component/internal.js instead.' diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx index 5e6aa6a763..53e40b6b8c 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx @@ -1,11 +1,10 @@ /* eslint no-magic-numbers: ["error", { "ignore": [-1, 0, 2] }] */ import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import { type Action as AdaptiveCardAction, type OpenUrlAction, type SubmitAction } from 'adaptivecards'; import { ErrorBoxPolymiddlewareProxy } from 'botframework-webchat-api/middleware.js'; import { getTabIndex, hooks } from 'botframework-webchat-component'; -import { useQueueStaticElement } from 'botframework-webchat-component/internal.js'; +import { useQueueStaticElement, useStyles } from 'botframework-webchat-component/internal.js'; import { type DirectLineCardAction } from 'botframework-webchat-core'; import cx from 'classnames'; import React, { diff --git a/packages/bundle/src/adaptiveCards/Attachment/AnimatedImageContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/AnimatedImageContent.tsx index e16d4fd3e8..2582bf19a1 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AnimatedImageContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/AnimatedImageContent.tsx @@ -1,7 +1,6 @@ import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import { hooks } from 'botframework-webchat-component'; -import { ActivityButton, FixedWidthImage } from 'botframework-webchat-component/internal.js'; +import { ActivityButton, FixedWidthImage, useStyles } from 'botframework-webchat-component/internal.js'; import cx from 'classnames'; import React, { memo, useCallback, useRef, useState, type SyntheticEvent } from 'react'; import { boolean, object, optional, pipe, readonly, string } from 'valibot'; diff --git a/packages/bundle/src/adaptiveCards/Attachment/AnimationCardContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/AnimationCardContent.tsx index 4b05aef877..7a8488c557 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AnimationCardContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/AnimationCardContent.tsx @@ -1,8 +1,8 @@ /* eslint react/no-array-index-key: "off" */ import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import { Components } from 'botframework-webchat-component'; +import { useStyles } from 'botframework-webchat-component/internal.js'; import { onErrorResumeNext } from 'botframework-webchat-core'; import React, { memo } from 'react'; import { boolean, object, optional, pipe, readonly, string } from 'valibot'; diff --git a/packages/bundle/src/adaptiveCards/Attachment/AudioCardContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/AudioCardContent.tsx index 4be222e319..5b90cb61ef 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AudioCardContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/AudioCardContent.tsx @@ -1,8 +1,8 @@ /* eslint react/no-array-index-key: "off" */ import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import { Components } from 'botframework-webchat-component'; +import { useStyles } from 'botframework-webchat-component/internal.js'; import React, { memo } from 'react'; import { boolean, object, optional, pipe, readonly, string } from 'valibot'; diff --git a/packages/bundle/src/adaptiveCards/Attachment/SignInCardContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/SignInCardContent.tsx index 57887dc9ff..05f8512a2b 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/SignInCardContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/SignInCardContent.tsx @@ -1,5 +1,5 @@ import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; +import { useStyles } from 'botframework-webchat-component/internal.js'; import React, { memo } from 'react'; import { boolean, object, optional, pipe, readonly, string } from 'valibot'; diff --git a/packages/bundle/src/adaptiveCards/Attachment/VideoCardContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/VideoCardContent.tsx index 42645da61c..c3cb99460c 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/VideoCardContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/VideoCardContent.tsx @@ -1,8 +1,8 @@ /* eslint react/no-array-index-key: "off" */ import type { InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import { Components } from 'botframework-webchat-component'; +import { useStyles } from 'botframework-webchat-component/internal.js'; import React, { memo } from 'react'; import { boolean, object, optional, parse, pipe, readonly, string } from 'valibot'; diff --git a/packages/bundle/src/boot/actual/internal.ts b/packages/bundle/src/boot/actual/internal.ts index fc8c411144..36614f5d2f 100644 --- a/packages/bundle/src/boot/actual/internal.ts +++ b/packages/bundle/src/boot/actual/internal.ts @@ -2,7 +2,9 @@ export { usePostVoiceActivity, useShouldShowMicrophoneButton } from 'botframework-webchat-api/internal.js'; export { CodeHighlighterComposer, + combineCSSModules, createIconComponent, + CSSModuleThemeComposer, InjectStyleElements, injectStyleElementsPropsSchema, parseDocumentFragmentFromString, @@ -11,7 +13,8 @@ export { serializeDocumentFragmentIntoString, useActivityStyleOptions, useCodeHighlighter, - useLiveRegion + useLiveRegion, + useStyles } from 'botframework-webchat-component/internal.js'; export { getOrgSchemaMessage, diff --git a/packages/component/src/boot/internal.ts b/packages/component/src/boot/internal.ts index 2faa3f1041..521c84a81f 100644 --- a/packages/component/src/boot/internal.ts +++ b/packages/component/src/boot/internal.ts @@ -5,7 +5,15 @@ export { type BetterLinkDocumentModAttributeSetter, type BetterLinkDocumentModDecoration } from '@msinternal/botframework-webchat-component-better-link'; -export { InjectStyleElements, injectStyleElementsPropsSchema } from '@msinternal/botframework-webchat-styles/react'; +// The styles package is bundled into every package that imports it, and each copy has its own React context. +// Other packages must use this copy, so a theme above Web Chat reaches every useStyles(). +export { + combineCSSModules, + CSSModuleThemeComposer, + InjectStyleElements, + injectStyleElementsPropsSchema, + useStyles +} from '@msinternal/botframework-webchat-styles/react'; export { default as ActivityButton } from '../Attachment/Text/private/ActivityButton'; export { CodeHighlighterComposer, diff --git a/packages/fluent-theme/.eslintrc.yml b/packages/fluent-theme/.eslintrc.yml index 8307709bdc..194d6ebbb3 100644 --- a/packages/fluent-theme/.eslintrc.yml +++ b/packages/fluent-theme/.eslintrc.yml @@ -7,5 +7,10 @@ env: browser: true rules: + no-restricted-imports: + - error + - paths: + - name: '@msinternal/botframework-webchat-styles/react' + message: 'Every copy of @msinternal/botframework-webchat-styles/react has its own React context, import it from botframework-webchat/internal.js instead.' react/destructuring-assignment: off react/require-default-props: off diff --git a/packages/fluent-theme/src/__tests__/sharedStylesRuntime.spec.js b/packages/fluent-theme/src/__tests__/sharedStylesRuntime.spec.js new file mode 100644 index 0000000000..079f01305e --- /dev/null +++ b/packages/fluent-theme/src/__tests__/sharedStylesRuntime.spec.js @@ -0,0 +1,84 @@ +/** @jest-environment @happy-dom/jest-environment */ + +// Every copy of the styles runtime has its own CSSModuleThemeContext: a theme provided through one copy is invisible to +// useStyles() of another copy. Web Chat must ship one copy, from botframework-webchat-component. + +const { readdirSync, readFileSync } = require('fs'); +const { dirname, join } = require('path'); + +// Only the styles runtime names its context, minifiers keep string literals. +const STYLES_RUNTIME_MARKER = 'WebChat.CSSModuleThemeContext'; + +// Lists the build outputs that bundle the styles runtime, per format, from esbuild metafiles (tsup `metafile: true`). +function outputsWithStylesRuntime(packageName) { + const distPath = dirname(require.resolve(packageName)); + + return Object.fromEntries( + // Paths come from require.resolve() and the dist folder listing, not from user input. + // eslint-disable-next-line security/detect-non-literal-fs-filename + readdirSync(distPath) + .filter(name => /^metafile-\w+\.json$/u.test(name)) + .map(name => [ + name, + // eslint-disable-next-line security/detect-non-literal-fs-filename + Object.entries(JSON.parse(readFileSync(join(distPath, name), 'utf8')).outputs) + .filter(([, { inputs }]) => + Object.keys(inputs).some(input => input.includes('styles/dist/botframework-webchat-styles.react.')) + ) + .map(([output]) => output) + ]) + ); +} + +test('botframework-webchat/internal.js should re-export the styles runtime of botframework-webchat-component', () => { + const bundleInternal = require('botframework-webchat/internal.js'); + const componentInternal = require('botframework-webchat-component/internal.js'); + + expect(bundleInternal.CSSModuleThemeComposer).toBe(componentInternal.CSSModuleThemeComposer); + expect(bundleInternal.useStyles).toBe(componentInternal.useStyles); +}); + +test('botframework-webchat-component should bundle the styles runtime into one chunk per module format', () => { + const outputs = outputsWithStylesRuntime('botframework-webchat-component'); + + expect(Object.keys(outputs).sort()).toEqual(['metafile-cjs.json', 'metafile-esm.json']); + expect(outputs['metafile-cjs.json']).toHaveLength(1); + expect(outputs['metafile-esm.json']).toHaveLength(1); + + // The marker used by the botframework-webchat-fluent-theme test below is in the styles runtime chunks. + [...outputs['metafile-cjs.json'], ...outputs['metafile-esm.json']].forEach(output => + expect( + // Paths come from the esbuild metafile, not from user input. + // eslint-disable-next-line security/detect-non-literal-fs-filename + readFileSync(join(dirname(require.resolve('botframework-webchat-component')), '..', output), 'utf8') + ).toContain(STYLES_RUNTIME_MARKER) + ); +}); + +test('botframework-webchat should not bundle its own copy of the styles runtime', () => + // IIFE builds (webchat.js) contain one copy, inside the botframework-webchat-component code they inline. + expect(outputsWithStylesRuntime('botframework-webchat')).toEqual({ + 'metafile-cjs.json': [], + 'metafile-esm.json': [], + 'metafile-iife.json': [] + })); + +test('botframework-webchat-fluent-theme should not bundle the styles runtime', () => { + // The package does not publish its esbuild metafiles ("files" in package.json), so check the published scripts. + const distPath = dirname(require.resolve('botframework-webchat-fluent-theme')); + const scripts = readdirSync(distPath).filter(name => /\.m?js$/u.test(name)); + + // CommonJS, ES modules, and the development and production IIFE builds; no chunks. + expect(scripts.sort()).toEqual([ + 'botframework-webchat-fluent-theme.development.js', + 'botframework-webchat-fluent-theme.js', + 'botframework-webchat-fluent-theme.mjs', + 'botframework-webchat-fluent-theme.production.min.js' + ]); + expect( + scripts.filter(name => + // eslint-disable-next-line security/detect-non-literal-fs-filename + readFileSync(join(distPath, name), 'utf8').includes(STYLES_RUNTIME_MARKER) + ) + ).toEqual([]); +}); diff --git a/packages/fluent-theme/src/components/activity/ActivityLoader.tsx b/packages/fluent-theme/src/components/activity/ActivityLoader.tsx index 3c2348f1ef..2e844a2092 100644 --- a/packages/fluent-theme/src/components/activity/ActivityLoader.tsx +++ b/packages/fluent-theme/src/components/activity/ActivityLoader.tsx @@ -1,10 +1,9 @@ import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import cx from 'classnames'; import React, { Fragment, memo } from 'react'; import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; -import { useVariantClassName } from '../../styles'; +import { useStyles, useVariantClassName } from '../../styles'; import SlidingDots from '../assets/SlidingDots'; import styles from './ActivityLoader.module.css'; diff --git a/packages/fluent-theme/src/components/icon/FluentIcon.tsx b/packages/fluent-theme/src/components/icon/FluentIcon.tsx index 416796237a..48ee4d1451 100644 --- a/packages/fluent-theme/src/components/icon/FluentIcon.tsx +++ b/packages/fluent-theme/src/components/icon/FluentIcon.tsx @@ -1,10 +1,10 @@ import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import { createIconComponent } from 'botframework-webchat/internal.js'; import cx from 'classnames'; import React, { memo, useMemo, type CSSProperties } from 'react'; import { object, optional, pipe, readonly, string, union, type InferInput } from 'valibot'; +import { useStyles } from '../../styles'; import styles from './FluentIcon.module.css'; const baseFluentIconPropsSchema = pipe( diff --git a/packages/fluent-theme/src/components/typingIndicator/SlidingDotsTypingIndicator.tsx b/packages/fluent-theme/src/components/typingIndicator/SlidingDotsTypingIndicator.tsx index 6e35a5022b..62cb72f039 100644 --- a/packages/fluent-theme/src/components/typingIndicator/SlidingDotsTypingIndicator.tsx +++ b/packages/fluent-theme/src/components/typingIndicator/SlidingDotsTypingIndicator.tsx @@ -1,8 +1,8 @@ -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import { testIds } from 'botframework-webchat'; import cx from 'classnames'; import React, { memo } from 'react'; +import { useStyles } from '../../styles'; import SlidingDots from '../assets/SlidingDots'; import styles from './SlidingDotsTypingIndicator.module.css'; diff --git a/packages/fluent-theme/src/styles/useStyles.ts b/packages/fluent-theme/src/styles/useStyles.ts index 974db3840f..d5c1e00f0d 100644 --- a/packages/fluent-theme/src/styles/useStyles.ts +++ b/packages/fluent-theme/src/styles/useStyles.ts @@ -1,2 +1,2 @@ -// TODO: refactor after initial approval gained -export { useStyles as default } from '@msinternal/botframework-webchat-styles/react'; +// Use the copy from Web Chat, so Fluent elements read the same CSSModuleThemeContext as Web Chat elements. +export { useStyles as default } from 'botframework-webchat/internal.js'; diff --git a/packages/styles/src/react/index.ts b/packages/styles/src/react/index.ts index 692bb72103..ac3668c1e8 100644 --- a/packages/styles/src/react/index.ts +++ b/packages/styles/src/react/index.ts @@ -1,2 +1,10 @@ +export { localClassName, webChatPrefixedClassName, type ClassNameTransform } from './private/classNameTransforms'; +export { default as combineCSSModules } from './private/combineCSSModules'; +export { + default as CSSModuleThemeComposer, + cssModuleThemeComposerPropsSchema, + type CSSModuleThemeComposerProps +} from './private/CSSModuleThemeComposer'; +export { type CSSModuleTheme } from './private/CSSModuleThemeContext'; export { default as InjectStyleElements, injectStyleElementsPropsSchema } from './private/InjectStyleElements'; export { default as useStyles } from './private/useStyles'; diff --git a/packages/styles/src/react/private/CSSModuleThemeComposer.spec.tsx b/packages/styles/src/react/private/CSSModuleThemeComposer.spec.tsx new file mode 100644 index 0000000000..55a6cb8e41 --- /dev/null +++ b/packages/styles/src/react/private/CSSModuleThemeComposer.spec.tsx @@ -0,0 +1,71 @@ +/** @jest-environment @happy-dom/jest-environment */ +/// + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import React from 'react'; +import { render } from 'react-dom'; +import { act } from 'react-dom/test-utils'; + +import CSSModuleThemeComposer from './CSSModuleThemeComposer'; +import useStyles from './useStyles'; + +const styles = Object.freeze({ bubble: 'wA_bubble' }); + +function Probe({ results }: { readonly results: unknown[] }) { + results.push(useStyles(styles)); + + return null; +} + +function renderApp({ + container, + outerTheme, + results, + theme +}: { + readonly container: HTMLElement; + readonly outerTheme: { readonly [localName: string]: string }; + readonly results: unknown[]; + readonly theme: { readonly [localName: string]: string }; +}) { + act(() => { + render( + + + + + , + container + ); + }); +} + +scenario( + 'CSSModuleThemeComposer', + bdd => { + bdd + .given('an outer composer (e.g. from the host) around an inner composer (e.g. a theme)', () => ({ + container: document.createElement('div'), + outerTheme: Object.freeze({ bubble: 'wUser_bubble' }), + results: [], + theme: Object.freeze({ bubble: 'wTheme_bubble' }) + })) + .when('rendered', props => renderApp(props)) + .then('the outer classes should be added after the inner ones, not replace them', ({ results }) => + expect(results).toStrictEqual([{ bubble: 'bubble wA_bubble wTheme_bubble wUser_bubble' }]) + ) + .when('rendered again with the same theme objects', props => renderApp(props)) + .then('useStyles() should return the same object, the combined theme is memoized', ({ results }) => { + expect(results).toHaveLength(2); + expect(results[1]).toBe(results[0]); + }) + .when('the outer composer gets a new theme', props => + renderApp({ ...props, outerTheme: Object.freeze({ bubble: 'wUser2_bubble' }) }) + ) + .then('useStyles() should return the new classes', ({ results }) => + expect(results[2]).toStrictEqual({ bubble: 'bubble wA_bubble wTheme_bubble wUser2_bubble' }) + ); + }, + JestGlobals +); diff --git a/packages/styles/src/react/private/CSSModuleThemeComposer.tsx b/packages/styles/src/react/private/CSSModuleThemeComposer.tsx new file mode 100644 index 0000000000..2f6aaa9bd3 --- /dev/null +++ b/packages/styles/src/react/private/CSSModuleThemeComposer.tsx @@ -0,0 +1,37 @@ +import { reactNode, validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; +import React, { memo, useContext, useMemo } from 'react'; +import { object, optional, pipe, readonly } from 'valibot'; + +import combineCSSModules from './combineCSSModules'; +import CSSModuleThemeContext, { cssModuleThemeSchema } from './CSSModuleThemeContext'; + +const cssModuleThemeComposerPropsSchema = pipe( + object({ + children: optional(reactNode()), + // Pass a stable object (e.g. built once at module level with combineCSSModules()) to avoid re-rendering every useStyles(). + theme: cssModuleThemeSchema + }), + readonly() +); + +type CSSModuleThemeComposerProps = InferReactProps; + +/** + * Provides class names for CSS module local names to every `useStyles()` below, e.g. a theme styling Web Chat elements. + * + * Classes from a composer above are added to ours, never replace them, so an outer composer can add to a theme. + */ +function CSSModuleThemeComposer(props: CSSModuleThemeComposerProps) { + const { children, theme } = validateProps(cssModuleThemeComposerPropsSchema, props); + + const outerTheme = useContext(CSSModuleThemeContext); + + const context = useMemo(() => combineCSSModules(theme, outerTheme), [outerTheme, theme]); + + return {children}; +} + +CSSModuleThemeComposer.displayName = 'CSSModuleThemeComposer'; + +export default memo(CSSModuleThemeComposer); +export { cssModuleThemeComposerPropsSchema, type CSSModuleThemeComposerProps }; diff --git a/packages/styles/src/react/private/CSSModuleThemeContext.ts b/packages/styles/src/react/private/CSSModuleThemeContext.ts new file mode 100644 index 0000000000..409663239c --- /dev/null +++ b/packages/styles/src/react/private/CSSModuleThemeContext.ts @@ -0,0 +1,19 @@ +import { createContext } from 'react'; +import { pipe, readonly, record, string, type InferOutput } from 'valibot'; + +const cssModuleThemeSchema = pipe(record(string(), string()), readonly()); + +/** + * Extra class names for CSS module local names, e.g. `{ bubble: 'w1abc_bubble w2def_bubble' }`. + * + * `useStyles()` adds the classes for a local name to the element that uses the same local name. + */ +type CSSModuleTheme = InferOutput; + +// Internal: themes provide it via , components read it via useStyles(); not a public API. +const CSSModuleThemeContext = createContext(Object.freeze({})); + +CSSModuleThemeContext.displayName = 'WebChat.CSSModuleThemeContext'; + +export default CSSModuleThemeContext; +export { cssModuleThemeSchema, type CSSModuleTheme }; diff --git a/packages/styles/src/react/private/classNameTransforms.ts b/packages/styles/src/react/private/classNameTransforms.ts new file mode 100644 index 0000000000..d3c0b6def6 --- /dev/null +++ b/packages/styles/src/react/private/classNameTransforms.ts @@ -0,0 +1,17 @@ +/** + * Returns the readable class name to add next to the hashed class name of a CSS module local name, + * or `undefined` to add none. + */ +type ClassNameTransform = (localName: string) => string | undefined; + +/** Today's readable class name: the local name itself, e.g. `bubble`. */ +function localClassName(localName: string): string { + return localName; +} + +/** Stable readable class name for legacy selectors, e.g. `webchat__bubble`. */ +function webChatPrefixedClassName(localName: string): string { + return `webchat__${localName}`; +} + +export { localClassName, webChatPrefixedClassName, type ClassNameTransform }; diff --git a/packages/styles/src/react/private/combineCSSModules.spec.ts b/packages/styles/src/react/private/combineCSSModules.spec.ts new file mode 100644 index 0000000000..3838c9398b --- /dev/null +++ b/packages/styles/src/react/private/combineCSSModules.spec.ts @@ -0,0 +1,71 @@ +/// + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; + +import combineCSSModules from './combineCSSModules'; + +scenario( + 'combineCSSModules', + bdd => { + bdd + .given('two CSS modules that both define "bubble"', () => + Object.freeze([ + Object.freeze({ bubble: 'wA_bubble', 'bubble--from-user': 'wA_bubble--from-user' }), + Object.freeze({ bubble: 'wB_bubble', transcript: 'wB_transcript' }) + ]) + ) + .when('combined', cssModules => combineCSSModules(...cssModules)) + .then('"bubble" should have the class names of both modules, in order', (_, result) => + expect(result).toHaveProperty('bubble', 'wA_bubble wB_bubble') + ) + .and('other local names should be kept as-is', (_, result) => + expect(result).toStrictEqual({ + bubble: 'wA_bubble wB_bubble', + 'bubble--from-user': 'wA_bubble--from-user', + transcript: 'wB_transcript' + }) + ) + .and('the result should be frozen', (_, result) => expect(Object.isFrozen(result)).toBe(true)); + + bdd + .given('a combined module and one of its modules again', () => + Object.freeze([ + Object.freeze({ bubble: 'wA_bubble wB_bubble' }), // Output of an earlier combineCSSModules() + Object.freeze({ bubble: 'wA_bubble' }) + ]) + ) + .when('combined', cssModules => combineCSSModules(...cssModules)) + .then('every class name should appear once', (_, result) => + expect(result).toStrictEqual({ bubble: 'wA_bubble wB_bubble' }) + ); + + bdd + .given('a module with an empty class name', () => Object.freeze([Object.freeze({ bubble: '' })])) + .when('combined', cssModules => combineCSSModules(...cssModules)) + .then('the local name should be left out', (_, result) => expect(result).toStrictEqual({})); + + bdd + .given('no modules', () => Object.freeze([])) + .when('combined', cssModules => combineCSSModules(...cssModules)) + .then('the result should be an empty frozen map', (_, result) => { + expect(result).toStrictEqual({}); + expect(Object.isFrozen(result)).toBe(true); + }); + + bdd + .given('a module with a non-string class name', () => Object.freeze([Object.freeze({ bubble: 1 })])) + .when('combined', cssModules => { + try { + // @ts-expect-error: invalid input on purpose. + combineCSSModules(...cssModules); + } catch (error) { + return error; + } + + return undefined; + }) + .then('it should throw', (_, error) => expect(error).toBeInstanceOf(Error)); + }, + JestGlobals +); diff --git a/packages/styles/src/react/private/combineCSSModules.ts b/packages/styles/src/react/private/combineCSSModules.ts new file mode 100644 index 0000000000..e3f98f4210 --- /dev/null +++ b/packages/styles/src/react/private/combineCSSModules.ts @@ -0,0 +1,33 @@ +import { array, parse } from 'valibot'; + +import { cssModuleThemeSchema, type CSSModuleTheme } from './CSSModuleThemeContext'; + +const cssModulesSchema = array(cssModuleThemeSchema); + +/** + * Combines CSS module class maps into one map for `CSSModuleThemeContext`. + * + * Class names of the same local name are concatenated, never replaced, and appear once: + * `combineCSSModules({ bubble: 'w1_bubble' }, { bubble: 'w2_bubble' })` returns `{ bubble: 'w1_bubble w2_bubble' }`. + */ +export default function combineCSSModules(...cssModules: readonly CSSModuleTheme[]): CSSModuleTheme { + const classNamesMap = new Map>(); + + for (const cssModule of parse(cssModulesSchema, cssModules)) { + for (const [localName, classNames] of Object.entries(cssModule)) { + const combinedClassNames = classNamesMap.get(localName) ?? new Set(); + + for (const className of classNames.split(/\s+/u)) { + className && combinedClassNames.add(className); + } + + combinedClassNames.size && classNamesMap.set(localName, combinedClassNames); + } + } + + return Object.freeze( + Object.fromEntries( + Array.from(classNamesMap, ([localName, classNames]) => [localName, Array.from(classNames).join(' ')]) + ) + ); +} diff --git a/packages/styles/src/react/private/useStyles.spec.tsx b/packages/styles/src/react/private/useStyles.spec.tsx new file mode 100644 index 0000000000..fe44713867 --- /dev/null +++ b/packages/styles/src/react/private/useStyles.spec.tsx @@ -0,0 +1,116 @@ +/** @jest-environment @happy-dom/jest-environment */ +/// + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import React from 'react'; +import { render } from 'react-dom'; +import { act } from 'react-dom/test-utils'; + +import { webChatPrefixedClassName, type ClassNameTransform } from './classNameTransforms'; +import CSSModuleThemeComposer from './CSSModuleThemeComposer'; +import useStyles from './useStyles'; + +// Same shape as a CSS module import: local name → hashed class name. +const styles = Object.freeze({ bubble: 'wA_bubble', 'bubble--from-user': 'wA_bubble--from-user' }); + +const hashedClassNameOnly: ClassNameTransform = () => undefined; + +function Probe({ + results, + transform +}: { + readonly results: unknown[]; + readonly transform?: ClassNameTransform | undefined; +}) { + results.push(useStyles(styles, transform)); + + return null; +} + +scenario( + 'useStyles', + bdd => { + bdd + .given('no CSSModuleThemeComposer', () => ({ results: [] })) + .when('useStyles() is called without a transform', ({ results }) => { + act(() => { + render(, document.createElement('div')); + }); + + return results[0]; + }) + .then('every local name should map to " ", as before', (_, classNames) => + expect(classNames).toStrictEqual({ + bubble: 'bubble wA_bubble', + 'bubble--from-user': 'bubble--from-user wA_bubble--from-user' + }) + ) + .and('the result should be frozen', (_, classNames) => expect(Object.isFrozen(classNames)).toBe(true)); + + bdd + .given('a CSSModuleThemeComposer with "bubble" and "transcript"', () => ({ + results: [], + theme: Object.freeze({ bubble: 'wT_bubble', transcript: 'wT_transcript' }) + })) + .when('useStyles() is called below it', ({ results, theme }) => { + act(() => { + render( + + + , + document.createElement('div') + ); + }); + + return results[0]; + }) + .then('"bubble" should also have the theme class', (_, classNames) => + expect(classNames).toHaveProperty('bubble', 'bubble wA_bubble wT_bubble') + ) + .and('local names not in the theme should be unchanged', (_, classNames) => + expect(classNames).toHaveProperty('bubble--from-user', 'bubble--from-user wA_bubble--from-user') + ) + .and('"transcript" should not be added, the CSS module does not use it', (_, classNames) => + expect(classNames).not.toHaveProperty('transcript') + ); + + bdd + .given('a CSSModuleThemeComposer with "bubble"', () => ({ + results: [], + theme: Object.freeze({ bubble: 'wT_bubble' }) + })) + .when('useStyles() is called with the "webchat__" prefix transform', ({ results, theme }) => { + act(() => { + render( + + + , + document.createElement('div') + ); + }); + + return results[0]; + }) + .then('the readable class names should be prefixed', (_, classNames) => + expect(classNames).toStrictEqual({ + bubble: 'webchat__bubble wA_bubble wT_bubble', + 'bubble--from-user': 'webchat__bubble--from-user wA_bubble--from-user' + }) + ); + + bdd + .given('no CSSModuleThemeComposer', () => ({ results: [] })) + .when('useStyles() is called with a transform that returns undefined', ({ results }) => { + act(() => { + render(, document.createElement('div')); + }); + + return results[0]; + }) + .then('only the hashed class names should be left', (_, classNames) => + expect(classNames).toStrictEqual({ bubble: 'wA_bubble', 'bubble--from-user': 'wA_bubble--from-user' }) + ); + }, + JestGlobals +); diff --git a/packages/styles/src/react/private/useStyles.ts b/packages/styles/src/react/private/useStyles.ts index 216656910d..30927a8fa3 100644 --- a/packages/styles/src/react/private/useStyles.ts +++ b/packages/styles/src/react/private/useStyles.ts @@ -1,19 +1,31 @@ -import { useMemo } from 'react'; +import { useContext, useMemo } from 'react'; + +import { localClassName, type ClassNameTransform } from './classNameTransforms'; +import CSSModuleThemeContext, { type CSSModuleTheme } from './CSSModuleThemeContext'; + +/** + * Maps every local name of a CSS module to the class names its element should carry: + * + * 1. the readable class name from `transform` (default: the local name itself), + * 2. the module's own hashed class name, + * 3. the classes of the same local name from `CSSModuleThemeContext`, if any. + */ +function useStyles(styles: T, transform: ClassNameTransform = localClassName): T { + const theme = useContext(CSSModuleThemeContext); -function useStyles(styles: T): T { // @ts-expect-error: entries/fromEntries don't allow to specify keys type - return useMemo( - () => - Object.freeze( - Object.fromEntries( - Object.entries(styles).map(([baseClassName, resultClassName]) => [ - baseClassName, - `${baseClassName} ${resultClassName}` - ]) - ) - ), - [styles] - ); + return useMemo(() => { + const themeClassNamesMap = new Map(Object.entries(theme)); + + return Object.freeze( + Object.fromEntries( + Object.entries(styles).map(([localName, className]) => [ + localName, + [transform(localName), className, themeClassNamesMap.get(localName)].filter(Boolean).join(' ') + ]) + ) + ); + }, [styles, theme, transform]); } export default useStyles;