Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/complex-selector-popup-theme-target.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] ComplexSelector: expose the popup surface as the `astryx-complex-selector-popup` theme target. The popup content container now paints the surface itself (same background, radius, and shadow tokens as before), so `defineTheme` components — or any plain stylesheet — can restyle the popup's background, border, radius, and width, and `contentXstyle` can override the surface styles too. Rendered defaults are unchanged.

@AKnassa
68 changes: 68 additions & 0 deletions apps/storybook/stories/ComplexSelector.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {TextInput} from '@astryxdesign/core/TextInput';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Token} from '@astryxdesign/core/Token';
import {TreeList, type TreeListItemData} from '@astryxdesign/core/TreeList';
import {Theme, defineTheme} from '@astryxdesign/core/theme';
import {useGridFocus} from '@astryxdesign/core/hooks';
import {
borderVars,
Expand Down Expand Up @@ -728,3 +729,70 @@ export const CategoryTreeSelector: Story = {
},
},
};

/**
* Theme the popup surface via `defineTheme`.
*
* The popup container carries the `astryx-complex-selector-popup` theme
* target and paints the surface itself, so a theme — or any plain
* stylesheet — can restyle the popup's background, border, radius, and
* width without StyleX, the popup counterpart of the trigger's `className`
* route (#4804). Defaults are unchanged; this story only demonstrates the
* override channel.
*/
const popupTheme = defineTheme({
name: 'complex-selector-popup-demo',
components: {
'complex-selector-popup': {
base: {
backgroundColor: 'var(--color-background-muted)',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'var(--color-border)',
boxShadow: 'none',
},
},
},
});

export const ThemedPopupSurface: Story = {
name: 'Themed popup surface',
render: () => {
const [value, setValue] = useState<FruitValue>({
fruit: 'Apple',
ripeness: 'Juicy',
});

return (
<Theme theme={popupTheme} mode="light">
<VStack gap={4} xstyle={styles.wrapper}>
<ComplexSelector<FruitValue>
label="Fruit blend"
description="The popup renders as a flat, bordered, muted panel through the astryx-complex-selector-popup target."
value={value}
onChange={setValue}
triggerLabel={formatFruitValue(value)}
contentXstyle={styles.fruitContent}>
{(selectedValue, onChange, close) => (
<FruitRipenessMatrix
value={selectedValue}
onChange={nextValue => {
onChange(nextValue);
close();
}}
/>
)}
</ComplexSelector>
</VStack>
</Theme>
);
},
parameters: {
docs: {
description: {
story:
'Apps whose menus are bordered flat panels can match the popup to them with defineTheme alone — no StyleX in the consuming app. The border, background, and shadow land on the element that paints the popup surface.',
},
},
},
};
4 changes: 3 additions & 1 deletion packages/core/src/ComplexSelector/ComplexSelector.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const docs = {
theming: {
targets: [
{className: 'astryx-complex-selector', visualProps: ['size', 'status']},
{className: 'astryx-complex-selector-popup'},
{
className: 'astryx-complex-selector-indicator-icon',
states: ['state'],
Expand Down Expand Up @@ -109,7 +110,8 @@ export const docs = {
{
name: 'contentXstyle',
type: 'StyleXStyles',
description: 'StyleX styles for the popup content container.',
description:
'StyleX styles for the popup content container. The container is the popup surface (it carries the astryx-complex-selector-popup theme target), so these can override background, border, radius, and shadow as well as padding.',
},
],
},
Expand Down
180 changes: 180 additions & 0 deletions packages/core/src/ComplexSelector/ComplexSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
import {describe, expect, it, vi} from 'vitest';
import {render, screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as stylex from '@stylexjs/stylex';
import {ComplexSelector} from './ComplexSelector';
import {colorVars} from '../theme/tokens.stylex';
import {defineTheme} from '../theme/defineTheme';
import {generateThemeRules} from '../theme/generateThemeRules';

type FruitValue = {
fruit: 'Apple' | 'Banana';
Expand Down Expand Up @@ -153,4 +157,180 @@ describe('ComplexSelector', () => {
await user.click(screen.getByRole('button', {name: 'Done', ...h}));
expect(trigger).toHaveAttribute('aria-expanded', 'false');
});

// ===========================================================================
// Popup theme target (#4804)
// ===========================================================================

describe('popup theme target', () => {
// The trigger container publishes `astryx-complex-selector`, but the popup
// rendered only anonymous hashed classes: no stable class on the surface,
// so neither defineTheme components nor a plain stylesheet could reach its
// background, border, radius, or width.

const fruitValue: FruitValue = {fruit: 'Apple', ripeness: 'Ripe'};

async function openPopup(
user: ReturnType<typeof userEvent.setup>,
contentXstyle?: stylex.StyleXStyles,
) {
render(
<ComplexSelector
label="Fruit blend"
value={fruitValue}
onChange={() => {}}
contentXstyle={contentXstyle}
triggerLabel="Apple Ripe">
{value => <FruitGrid value={value} onChange={() => {}} />}
</ComplexSelector>,
);
await user.click(screen.getByRole('button', {name: 'Fruit blend'}));
return document.querySelector('.astryx-complex-selector-popup');
}

// Collect every injected CSS rule (StyleX runtime injection is enabled in
// vitest), so assertions read the real declarations behind the popup's
// atomic classes instead of hashed class names.
function injectedCss(): string {
let out = '';
for (const sheet of Array.from(document.styleSheets)) {
try {
for (const rule of Array.from(sheet.cssRules)) {
out += rule.cssText + '\n';
}
} catch {
// ignore cross-origin sheets
}
}
out += Array.from(document.querySelectorAll('style'))
.map(s => s.textContent || '')
.join('\n');
return out;
}

// True when one of el's StyleX atomic classes declares `property`
// (optionally with a specific value fragment).
function declares(
css: string,
el: Element,
property: string,
value?: string,
): boolean {
return Array.from(el.classList)
.filter(c => c.startsWith('x'))
.some(c => {
// The lookahead stops a class from matching a longer class that
// shares its prefix (`.x14o` must not match `.x14odbl{…}`).
const rules = css.match(
new RegExp(`\\.${c}(?![a-zA-Z0-9_-])[^{]*\\{[^}]*\\}`, 'g'),
);
return (rules ?? []).some(
rule =>
rule.includes(property) &&
(value == null || rule.includes(value)),
);
});
}

it('stamps the stable popup class on the popup content container', async () => {
const user = userEvent.setup();
const popup = await openPopup(user);

expect(popup).not.toBeNull();
// The classed element is the container the trigger controls…
expect(popup).toHaveAttribute(
'id',
screen
.getByRole('button', {name: 'Fruit blend'})
.getAttribute('aria-controls'),
);
// …and the custom content renders inside it.
expect(
popup!.contains(
screen.getByRole('grid', {name: 'Fruit blend choices', ...h}),
),
).toBe(true);
});

it('paints the popup surface on the classed element', async () => {
const user = userEvent.setup();
const popup = await openPopup(user);
expect(popup).not.toBeNull();
const css = injectedCss();

// The stable-classed element owns the surface paint, with the same
// tokens every popover surface uses…
expect(
declares(css, popup!, 'background-color', '--color-background-popover'),
).toBe(true);
expect(declares(css, popup!, 'border-radius', '--radius-container')).toBe(
true,
);
expect(declares(css, popup!, 'box-shadow', '--shadow-low')).toBe(true);

// …and the dialog wrapper above it paints no second surface behind, so
// a theme override genuinely replaces the surface instead of floating
// over a differently-shaped default.
const dialog = screen.getByRole('dialog', {name: 'Fruit blend', ...h});
expect(popup!.parentElement).toBe(dialog);
expect(declares(css, dialog, 'background-color')).toBe(false);
expect(declares(css, dialog, 'box-shadow')).toBe(false);
});

it('lets contentXstyle override the surface paint', async () => {
// The StyleX escape hatch gains the same reach: with the surface on the
// popup element itself, contentXstyle merges after the surface styles
// and can replace them.
const overrides = stylex.create({
surface: {backgroundColor: colorVars['--color-background-surface']},
});
const user = userEvent.setup();
const popup = await openPopup(user, overrides.surface);
expect(popup).not.toBeNull();
const css = injectedCss();

expect(
declares(css, popup!, 'background-color', '--color-background-surface'),
).toBe(true);
// StyleX merge dedupes by property, so the default surface background
// is gone rather than merely covered.
expect(
declares(css, popup!, 'background-color', '--color-background-popover'),
).toBe(false);
});

it('keeps the existing trigger and indicator targets intact', async () => {
// Guard, not red proof: both targets exist before this change too.
const user = userEvent.setup();
await openPopup(user);

expect(document.querySelector('.astryx-complex-selector')).not.toBeNull();
expect(
document.querySelector('.astryx-complex-selector-indicator-icon'),
).not.toBeNull();
});

it('emits theme CSS for the popup target via defineTheme', () => {
// Guard for the documented route (defineTheme emits for any target
// class): the issue's exact use case — a bordered, fixed-width panel.
const theme = defineTheme({
name: 'complex-selector-popup-test',
components: {
'complex-selector-popup': {
base: {
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'var(--color-border)',
inlineSize: '288px',
},
},
},
});
const css = generateThemeRules(theme).join('\n');

expect(css).toContain('.astryx-complex-selector-popup');
expect(css).toContain('border-width: 1px');
expect(css).toContain('inline-size: 288px');
});
});
});
26 changes: 23 additions & 3 deletions packages/core/src/ComplexSelector/ComplexSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {Icon} from '../Icon';
import {Spinner} from '../Spinner';
import {useTranslator} from '../i18n';
import {layerAnimations} from '../Layer/layerAnimations.stylex';
import {usePopover} from '../Popover/usePopover';
import {popoverSurfaceStyles, usePopover} from '../Popover/usePopover';
import {
colorVars,
durationVars,
Expand Down Expand Up @@ -208,7 +208,14 @@ export interface ComplexSelectorProps<Value> extends Omit<
width?: SizeValue;
/** Popup placement. */
placement?: 'above' | 'below' | 'start' | 'end';
/** StyleX styles for the popup content container. */
/**
* StyleX styles for the popup content container.
*
* The container is the popup surface itself (it carries the
* `astryx-complex-selector-popup` theme target and paints the surface),
* so these styles can override background, border, radius, and shadow
* as well as the content padding.
*/
contentXstyle?: StyleXStyles;
/** Test ID for the trigger container. */
'data-testid'?: string;
Expand Down Expand Up @@ -292,6 +299,10 @@ export function ComplexSelector<Value>({
dialogLabel: label,
hasCloseButton: false,
hasAutoFocus: true,
// The content container paints the surface itself so that the
// `astryx-complex-selector-popup` theme target reaches the element
// owning background, border, radius, and width (#4804).
hasSurface: false,
onHide: () => {
document.getElementById(triggerId)?.focus();
},
Expand All @@ -313,7 +324,16 @@ export function ComplexSelector<Value>({
const triggerContent = triggerLabel ?? placeholder;

const content = (
<div id={contentId} {...stylex.props(styles.content, contentXstyle)}>
<div
id={contentId}
{...mergeProps(
themeProps('complex-selector-popup'),
stylex.props(
popoverSurfaceStyles.surface,
styles.content,
contentXstyle,
),
)}>
{children(optimisticValue, commitValue, popover.hide, {
isOpen: popover.isOpen,
isBusy,
Expand Down
17 changes: 12 additions & 5 deletions packages/core/src/Popover/usePopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,22 @@ import {rtlStyles} from '../utils';
import {useTranslator} from '../i18n';
import {useDevWarning} from '../hooks/useDevWarning';

const styles = stylex.create({
// Default popover surface — background, radius, shadow.
// Applied automatically unless hasSurface is false.
// Consumers that need a raw positioned layer should use useLayer instead.
// Default popover surface — background, radius, shadow.
// Applied automatically unless hasSurface is false.
// Consumers that need a raw positioned layer should use useLayer instead.
// Exported so popover-based components that opt out via `hasSurface: false`
// can paint the identical surface on their own stable-classed element
// (e.g. ComplexSelector's `astryx-complex-selector-popup` theme target)
// without duplicating these declarations.
export const popoverSurfaceStyles = stylex.create({
surface: {
backgroundColor: colorVars['--color-background-popover'],
borderRadius: radiusVars['--radius-container'],
boxShadow: shadowVars['--shadow-low'],
},
});

const styles = stylex.create({
// Focus trap container
contentWrapper: {
position: 'relative',
Expand Down Expand Up @@ -414,7 +421,7 @@ export function usePopover(options: UsePopoverOptions = {}): UsePopoverReturn {
aria-label={role === 'dialog' ? dialogLabel : undefined}
{...stylex.props(
styles.contentWrapper,
hasSurface && styles.surface,
hasSurface && popoverSurfaceStyles.surface,
xstyle,
)}>
{children}
Expand Down
Loading