Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions dev/pages/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
display: 'grid',
gridTemplateRows: 'auto auto',
gridTemplateColumns: '1fr 1fr',
gap: '20px',
}}
>
{/* Demo component section */}
<div style={{ gridColumn: '1 / -1' }}>
<h1>Breadcrumbs</h1>
<Breadcrumbs theme={theme || undefined}>
{items.map((item, index) => (
<BreadcrumbsItem key={index} path={item.path}>
{item.label}
</BreadcrumbsItem>
))}
</Breadcrumbs>
</div>

{/* Configuration section */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '10px' }}>
<h2>Configuration</h2>
<label>
Theme:{' '}
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="">(default)</option>
<option value="slash">slash</option>
</select>
</label>
</div>
</div>
);
}
86 changes: 86 additions & 0 deletions dev/pages/Switch.tsx
Original file line number Diff line number Diff line change
@@ -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<string[]>([]);

const logEvent = (event: string) => {
setEventLog((prev) => [`${new Date().toLocaleTimeString()}: ${event}`, ...prev].slice(0, 100));
};

return (
<div
style={{
display: 'grid',
gridTemplateRows: 'auto auto',
gridTemplateColumns: '1fr 1fr',
gap: '20px',
}}
>
{/* Demo component section */}
<div style={{ gridColumn: '1 / -1' }}>
<h1>Switch</h1>
<Switch
checked={checked}
disabled={disabled}
readonly={readonly}
label={label}
helperText={helperText || undefined}
theme={theme || undefined}
onCheckedChanged={(e: SwitchCheckedChangedEvent) => {
setChecked(e.detail.value);
logEvent(`checked-changed: ${e.detail.value}`);
}}
/>
</div>

{/* Configuration section */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '10px' }}>
<h2>Configuration</h2>
<label>
Label: <input value={label} onChange={(e) => setLabel(e.target.value)} />
</label>
<label>
Helper text: <input value={helperText} onChange={(e) => setHelperText(e.target.value)} />
</label>
<label>
Theme: <input value={theme} onChange={(e) => setTheme(e.target.value)} placeholder="e.g. small" />
</label>
<label>
<input type="checkbox" checked={checked} onChange={(e) => setChecked(e.target.checked)} /> Checked
</label>
<label>
<input type="checkbox" checked={disabled} onChange={(e) => setDisabled(e.target.checked)} /> Disabled
</label>
<label>
<input type="checkbox" checked={readonly} onChange={(e) => setReadonly(e.target.checked)} /> Readonly
</label>
</div>

{/* Event Log section */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<h2>Event Log</h2>
<div
style={{
height: '300px',
overflowY: 'auto',
border: '1px solid #ccc',
padding: '10px',
background: '#f9f9f9',
}}
>
{eventLog.map((log, i) => (
<div key={i}>{log}</div>
))}
</div>
</div>
</div>
);
}
39 changes: 34 additions & 5 deletions packages/react-components/src/utils/createComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,7 @@ type ComponentProps<I, E extends EventNames = {}> = Omit<
EventListeners<E> &
ElementProps<I>;

export type ThemedWebComponentProps<
I extends ThemePropertyMixinClass & HTMLElement,
E extends EventNames = {},
> = ComponentProps<I, E> & {
export type ThemedWebComponentProps<I extends HTMLElement, E extends EventNames = {}> = ComponentProps<I, E> & {
/**
* 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.
Expand All @@ -79,11 +76,28 @@ type AllWebComponentProps<I extends HTMLElement, E extends EventNames = {}> = I

export type WebComponentProps<I extends HTMLElement, E extends EventNames = {}> = Partial<AllWebComponentProps<I, E>>;

/**
* The type of a React component created from a Vaadin web component.
*/
export type ReactWebComponent<I extends HTMLElement, E extends EventNames = {}> = (
props: WebComponentProps<I, E> & RefAttributes<I>,
) => 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<I extends HTMLElement, E extends EventNames = {}> = (
props: Partial<ThemedWebComponentProps<I, E>> & RefAttributes<I>,
) => 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<I extends HTMLElement, E extends EventNames = {}>(
options: Options<I, E>,
): (props: WebComponentProps<I, E> & RefAttributes<I>) => React.ReactElement | null;
): ReactWebComponent<I, E>;

export function createComponent<I extends HTMLElement, E extends EventNames = {}>(options: Options<I, E>): any {
const { elementClass } = options;
Expand All @@ -106,3 +120,18 @@ export function createComponent<I extends HTMLElement, E extends EventNames = {}
: options,
);
}

// A separate declaration is needed here for the same reason as `createComponent`.
export function createThemedComponent<I extends HTMLElement, E extends EventNames = {}>(
options: Options<I, E>,
): ThemedReactWebComponent<I, E>;

// 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<I extends HTMLElement, E extends EventNames = {}>(options: Options<I, E>): any {
return createComponent(options);
}
14 changes: 10 additions & 4 deletions scripts/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$';
Expand Down Expand Up @@ -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}";
Expand All @@ -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}";

Expand All @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions scripts/utils/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ export const eventSettings = new Map<string, EventSettings>([
['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<string>(['Switch', 'Breadcrumbs']);

export const elementsWithMissingEntrypoint = new Set<string>([]);

export const elementToClassNamingConventionViolations = new Map<string, string>([['vaadin-tabsheet', 'TabSheet']]);
18 changes: 18 additions & 0 deletions test/ThemedReactWebComponent.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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(<Switch theme="small"></Switch>);
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(<Breadcrumbs theme="slash"></Breadcrumbs>);
const element = container.querySelector('vaadin-breadcrumbs');
expect(element).not.to.be.undefined;

expect(element).to.have.attribute('theme', 'slash');
});
});
Loading