From b5fab89dfb0039d273a4eddb3a896f32623fb46a Mon Sep 17 00:00:00 2001 From: web-padawan Date: Fri, 24 Jul 2026 11:09:01 +0300 Subject: [PATCH] feat: support theme for components not using ThemableMixin Switch and Breadcrumbs support the `theme` attribute at runtime but do not use ThemableMixin (planned for removal in Vaadin 26), so they lack ThemePropertyMixinClass. The conditional type in createComponent gated the `theme` prop on that mixin, causing a TS error when setting `theme` on these components. Add a `createThemedComponent` helper (and ReactWebComponent / ThemedReactWebComponent types) that always exposes a `theme` prop at the type level; the runtime is identical to createComponent since @lit/react already forwards unknown props to the DOM as attributes. A `themedElements` registry in the generator settings opts specific components into this variant. Closes #393 Co-Authored-By: Claude Opus 4.8 (1M context) --- dev/pages/Breadcrumbs.tsx | 49 +++++++++++ dev/pages/Switch.tsx | 86 +++++++++++++++++++ .../src/utils/createComponent.ts | 39 +++++++-- scripts/generator.ts | 14 ++- scripts/utils/settings.ts | 5 ++ test/ThemedReactWebComponent.spec.tsx | 18 ++++ 6 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 dev/pages/Breadcrumbs.tsx create mode 100644 dev/pages/Switch.tsx diff --git a/dev/pages/Breadcrumbs.tsx b/dev/pages/Breadcrumbs.tsx new file mode 100644 index 00000000..55e58f7d --- /dev/null +++ b/dev/pages/Breadcrumbs.tsx @@ -0,0 +1,49 @@ +import { Breadcrumbs } from '../../packages/react-components/src/Breadcrumbs.js'; +import { BreadcrumbsItem } from '../../packages/react-components/src/BreadcrumbsItem.js'; +import { useState } from 'react'; + +const items = [ + { path: '/', label: 'Home' }, + { path: '/docs', label: 'Docs' }, + { path: '/docs/components', label: 'Components' }, + { label: 'Breadcrumbs' }, +]; + +export default function BreadcrumbsPage() { + const [theme, setTheme] = useState(''); + + return ( +
+ {/* Demo component section */} +
+

Breadcrumbs

+ + {items.map((item, index) => ( + + {item.label} + + ))} + +
+ + {/* Configuration section */} +
+

Configuration

+ +
+
+ ); +} diff --git a/dev/pages/Switch.tsx b/dev/pages/Switch.tsx new file mode 100644 index 00000000..1f127319 --- /dev/null +++ b/dev/pages/Switch.tsx @@ -0,0 +1,86 @@ +import { Switch } from '../../packages/react-components/src/Switch.js'; +import { useState } from 'react'; +import type { SwitchCheckedChangedEvent } from '@vaadin/switch'; + +export default function SwitchPage() { + const [checked, setChecked] = useState(true); + const [disabled, setDisabled] = useState(false); + const [readonly, setReadonly] = useState(false); + const [label, setLabel] = useState('Notifications'); + const [helperText, setHelperText] = useState(''); + const [theme, setTheme] = useState(''); + const [eventLog, setEventLog] = useState([]); + + const logEvent = (event: string) => { + setEventLog((prev) => [`${new Date().toLocaleTimeString()}: ${event}`, ...prev].slice(0, 100)); + }; + + return ( +
+ {/* Demo component section */} +
+

Switch

+ { + setChecked(e.detail.value); + logEvent(`checked-changed: ${e.detail.value}`); + }} + /> +
+ + {/* Configuration section */} +
+

Configuration

+ + + + + + +
+ + {/* Event Log section */} +
+

Event Log

+
+ {eventLog.map((log, i) => ( +
{log}
+ ))} +
+
+
+ ); +} diff --git a/packages/react-components/src/utils/createComponent.ts b/packages/react-components/src/utils/createComponent.ts index 74fe3659..0b01fd64 100644 --- a/packages/react-components/src/utils/createComponent.ts +++ b/packages/react-components/src/utils/createComponent.ts @@ -60,10 +60,7 @@ type ComponentProps = Omit< EventListeners & ElementProps; -export type ThemedWebComponentProps< - I extends ThemePropertyMixinClass & HTMLElement, - E extends EventNames = {}, -> = ComponentProps & { +export type ThemedWebComponentProps = ComponentProps & { /** * Attribute that can be used by the component to apply built-in style variants, * or to propagate its value to the sub-components in Shadow DOM. @@ -79,11 +76,28 @@ type AllWebComponentProps = I export type WebComponentProps = Partial>; +/** + * The type of a React component created from a Vaadin web component. + */ +export type ReactWebComponent = ( + props: WebComponentProps & RefAttributes, +) => React.ReactElement | null; + +/** + * The type of a React component that always accepts a `theme` property, even if + * the underlying web component does not use `ThemableMixin` / does not expose + * `ThemePropertyMixinClass` in its type. Used for components that support theme + * variants through the `theme` attribute without the mixin. + */ +export type ThemedReactWebComponent = ( + props: Partial> & RefAttributes, +) => React.ReactElement | null; + // We need a separate declaration here; otherwise, the TypeScript fails into the // endless loop trying to resolve the typings. export function createComponent( options: Options, -): (props: WebComponentProps & RefAttributes) => React.ReactElement | null; +): ReactWebComponent; export function createComponent(options: Options): any { const { elementClass } = options; @@ -106,3 +120,18 @@ export function createComponent( + options: Options, +): ThemedReactWebComponent; + +// Creates a React component that always accepts a `theme` property, regardless of +// whether the underlying web component uses `ThemableMixin`. This only widens the +// type: the runtime is identical to `createComponent`. `@lit/react` already forwards +// any prop that is not defined on the element prototype (such as `theme` on a +// non-`ThemableMixin` element) to the DOM as an attribute, so no runtime change is +// needed — this function exists purely to expose `theme` at the type level. +export function createThemedComponent(options: Options): any { + return createComponent(options); +} diff --git a/scripts/generator.ts b/scripts/generator.ts index 4cc20581..d3399bf5 100644 --- a/scripts/generator.ts +++ b/scripts/generator.ts @@ -25,7 +25,7 @@ import { transform, convertElementNameToClassName, } from './utils/misc.js'; -import { eventSettings, genericElements, NonGenericInterface } from './utils/settings.js'; +import { eventSettings, genericElements, NonGenericInterface, themedElements } from './utils/settings.js'; // Placeholders const CALL_EXPRESSION = '$CALL_EXPRESSION$'; @@ -319,6 +319,12 @@ function generateReactComponent({ name, js }: SchemaHTMLElement, { packageName, namedEvents?.some(({ name }) => !eventsToRemove?.includes(name) && !eventsToBeUnknown?.includes(name)) || false; const genericElementInfo = genericElements.get(elementName); + // Components that support the `theme` attribute but do not use `ThemableMixin` + // are generated with `createThemedComponent` so the `theme` prop is available. + const isThemed = themedElements.has(elementName); + const createFn = isThemed ? 'createThemedComponent' : 'createComponent'; + const themeSuffix = isThemed ? ' & { theme?: string }' : ''; + const ast = template( ` import type { EventName } from "${LIT_REACT_PATH}"; @@ -328,7 +334,7 @@ import { ${[...new Set(genericElementInfo?.typeConstraints || [])].map((constraint) => `type ${constraint}`)} } from "${MODULE_PATH}"; import * as React from "react"; -import { createComponent, type WebComponentProps } from "${CREATE_COMPONENT_PATH}"; +import { ${createFn}, type WebComponentProps } from "${CREATE_COMPONENT_PATH}"; export * from "${MODULE_PATH}"; @@ -338,8 +344,8 @@ export { export type ${EVENT_MAP}; const events = ${EVENTS_DECLARATION} as ${EVENT_MAP_REF_IN_EVENTS}; -export type ${COMPONENT_NAME}Props = WebComponentProps<${COMPONENT_NAME}Element, ${EVENT_MAP}>; -export const ${COMPONENT_NAME} = createComponent({ +export type ${COMPONENT_NAME}Props = WebComponentProps<${COMPONENT_NAME}Element, ${EVENT_MAP}>${themeSuffix}; +export const ${COMPONENT_NAME} = ${createFn}({ elementClass: ${COMPONENT_NAME}Element, events, react: React, diff --git a/scripts/utils/settings.ts b/scripts/utils/settings.ts index 8dcc5d23..b1f581ea 100644 --- a/scripts/utils/settings.ts +++ b/scripts/utils/settings.ts @@ -41,6 +41,11 @@ export const eventSettings = new Map([ ['GridPro', { makeUnknown: ['size-changed', 'data-provider-changed'] }], ]); +// Components that support the `theme` attribute (theme variants or propagation) +// but do not use `ThemableMixin` / expose `ThemePropertyMixinClass` in their type. +// These are generated with `createThemedComponent` so the `theme` prop is available. +export const themedElements = new Set(['Switch', 'Breadcrumbs']); + export const elementsWithMissingEntrypoint = new Set([]); export const elementToClassNamingConventionViolations = new Map([['vaadin-tabsheet', 'TabSheet']]); diff --git a/test/ThemedReactWebComponent.spec.tsx b/test/ThemedReactWebComponent.spec.tsx index b71a9678..43fdf76b 100644 --- a/test/ThemedReactWebComponent.spec.tsx +++ b/test/ThemedReactWebComponent.spec.tsx @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-react'; import { Accordion } from '../packages/react-components/src/Accordion.js'; +import { Breadcrumbs } from '../packages/react-components/src/Breadcrumbs.js'; +import { Switch } from '../packages/react-components/src/Switch.js'; describe('ThemedReactWebComponent', () => { it('should add a "theme" attribute', async () => { @@ -10,4 +12,20 @@ describe('ThemedReactWebComponent', () => { expect(element).to.have.attribute('theme', 'primary'); }); + + it('should add a "theme" attribute to a component without ThemableMixin (Switch)', async () => { + const { container } = await render(); + const element = container.querySelector('vaadin-switch'); + expect(element).not.to.be.undefined; + + expect(element).to.have.attribute('theme', 'small'); + }); + + it('should add a "theme" attribute to a component without ThemableMixin (Breadcrumbs)', async () => { + const { container } = await render(); + const element = container.querySelector('vaadin-breadcrumbs'); + expect(element).not.to.be.undefined; + + expect(element).to.have.attribute('theme', 'slash'); + }); });