diff --git a/.changeset/complexselector-above-placement-gap.md b/.changeset/complexselector-above-placement-gap.md new file mode 100644 index 000000000000..19ba0973aa45 --- /dev/null +++ b/.changeset/complexselector-above-placement-gap.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] ComplexSelector: the popup keeps its clearance when opening upward (`placement="above"`). The popup layer only set the gap on its block-start edge, which spaces a downward-opening popup but left an upward-opening one flush against the trigger. Both block edges now carry the gap, matching Popover. + +@AKnassa diff --git a/packages/core/src/ComplexSelector/ComplexSelector.test.tsx b/packages/core/src/ComplexSelector/ComplexSelector.test.tsx index 7e0cd6200343..c9fbb889a1fa 100644 --- a/packages/core/src/ComplexSelector/ComplexSelector.test.tsx +++ b/packages/core/src/ComplexSelector/ComplexSelector.test.tsx @@ -12,8 +12,24 @@ 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 {spacingVars} from '../theme/tokens.stylex'; import {ComplexSelector} from './ComplexSelector'; +// StyleX emits one deterministic atomic class per property/value pair, so an +// element carries a probe's class exactly when it has the same declaration. +// The dev-mode debug class (contains "__") varies by source location and is +// excluded from the comparison. +const probe = stylex.create({ + blockStartGap: {marginBlockStart: spacingVars['--spacing-1']}, + blockEndGap: {marginBlockEnd: spacingVars['--spacing-1']}, +}); + +function atomicClasses(style: (typeof probe)[keyof typeof probe]): string[] { + const {className = ''} = stylex.props(style); + return className.split(' ').filter(c => c !== '' && !c.includes('__')); +} + type FruitValue = { fruit: 'Apple' | 'Banana'; ripeness: 'Crisp' | 'Ripe' | 'Juicy'; @@ -153,4 +169,33 @@ describe('ComplexSelector', () => { await user.click(screen.getByRole('button', {name: 'Done', ...h})); expect(trigger).toHaveAttribute('aria-expanded', 'false'); }); + + it('keeps the popup gap on both block edges so placement="above" clears the trigger', async () => { + // #4803: the popup layer only set marginBlockStart, which spaces a popup + // opening downward but leaves an upward-opening one flush against the + // trigger. Both block edges must carry the gap, mirroring Popover's `gap` + // style, so the clearance holds whichever way the layer opens. + const user = userEvent.setup(); + render( + + {() =>
Surface
} +
, + ); + await user.click(screen.getByRole('button', {name: 'Fruit blend'})); + + const layer = document.querySelector('[popover]'); + expect(layer).not.toBeNull(); + const startGapClasses = atomicClasses(probe.blockStartGap); + const endGapClasses = atomicClasses(probe.blockEndGap); + // Guard against a vacuous pass if the probe ever compiles to no classes. + expect(startGapClasses.length).toBeGreaterThan(0); + expect(endGapClasses.length).toBeGreaterThan(0); + for (const cls of [...startGapClasses, ...endGapClasses]) { + expect(layer!.classList.contains(cls)).toBe(true); + } + }); }); diff --git a/packages/core/src/ComplexSelector/ComplexSelector.tsx b/packages/core/src/ComplexSelector/ComplexSelector.tsx index d165b5feac6a..528e7f78619d 100644 --- a/packages/core/src/ComplexSelector/ComplexSelector.tsx +++ b/packages/core/src/ComplexSelector/ComplexSelector.tsx @@ -115,7 +115,10 @@ const styles = stylex.create({ }, popover: { minWidth: 'anchor-size(width)', + // Both block edges carry the gap so the popup keeps its clearance + // whichever way it opens (placement="above" spaces via the end edge). marginBlockStart: spacingVars['--spacing-1'], + marginBlockEnd: spacingVars['--spacing-1'], }, content: { boxSizing: 'border-box',