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
16 changes: 16 additions & 0 deletions packages/design-system/src/components/Attribute/Attribute.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const attributeStory = createStoryHelper('data-display-attribute', [
'Horizontal With Actions Menu Only',
'With Inline Edit',
'Horizontal With Inline Edit',
'Mixed',
'Horizontal Mixed',
] as const);

test.describe('Component: Attribute', () => {
Expand Down Expand Up @@ -84,6 +86,20 @@ test.describe('Component: Attribute', () => {
await expect(page.getByTestId('attr--input')).toBeFocused();
await expect(page).toHaveScreenshot({ animations: 'disabled' });
});

test('Should render mixed hosts with aligned values correctly', async ({ page }) => {
// Plain value, AttributeActions target, and InlineEdit host stacked in
// one column — the value's left text edge must line up across all rows.
await attributeStory.goto(page, 'Mixed');
await expect(page).toHaveScreenshot({ animations: 'disabled' });
});

test('Should render horizontal mixed hosts with aligned values correctly', async ({ page }) => {
// Same alignment contract as the vertical Mixed story — the -ml-7 pull
// applies to InlineEdit and AttributeActionsTarget in both orientations.
await attributeStory.goto(page, 'Horizontal Mixed');
await expect(page).toHaveScreenshot({ animations: 'disabled' });
});
});

test.describe('Interactions', () => {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -894,3 +894,60 @@ export const WithInlineEdit: StoryFn = () => <AttributeInlineEditExample />;
export const HorizontalWithInlineEdit: StoryFn = () => (
<AttributeInlineEditExample orientation='horizontal' />
);

// ─── Mixed hosts ─────────────────────────────────────────────────────────────
// Plain value, AttributeActions target, and InlineEdit host stacked in one
// column — the value's left text edge must line up across all three rows in
// both orientations (AttributeValue's -ml-7 seam pulls the hover boxes left,
// not the text).

function AttributeMixedExample({ orientation }: { orientation?: AttributeProps['orientation'] }) {
const [name, setName] = useState('Checkout API');
return (
<div
className={
orientation === 'horizontal'
? 'w-[420px] flex flex-col gap-8'
: 'w-[420px] flex flex-col gap-16'
}
>
<Attribute orientation={orientation} data-testid='attr-plain'>
<AttributeLabel>Request ID</AttributeLabel>
<AttributeValue>
<Text size='sm'>abc-123-def-456</Text>
</AttributeValue>
</Attribute>

<Attribute orientation={orientation} data-testid='attr-actions'>
<AttributeLabel>Source IP</AttributeLabel>
<AttributeValue>
<AttributeActions>
<AttributeActionsTarget>
<Text size='sm'>142.198.167.52</Text>
</AttributeActionsTarget>
{renderActionsItems()}
</AttributeActions>
</AttributeValue>
</Attribute>

<Attribute orientation={orientation} data-testid='attr-inline-edit'>
<AttributeLabel>Name</AttributeLabel>
<AttributeValue>
<InlineEditRoot value={name} onValueCommit={v => setName(v as string)}>
<InlineEditPreview>{name}</InlineEditPreview>
<InlineEditControl>
<InlineEditInput />
</InlineEditControl>
<InlineEditError />
</InlineEditRoot>
</AttributeValue>
</Attribute>
</div>
);
}

export const Mixed: StoryFn = () => <AttributeMixedExample />;
Mixed.storyName = 'Mixed (plain + Actions + InlineEdit)';

export const HorizontalMixed: StoryFn = () => <AttributeMixedExample orientation='horizontal' />;
HorizontalMixed.storyName = 'Horizontal Mixed (plain + Actions + InlineEdit)';
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const Attribute: FC<AttributeProps> = ({
data-orientation={orientation}
data-empty={!loading && isEmpty ? '' : undefined}
className={cn(
isHorizontal ? 'flex flex-row items-start gap-4' : 'flex flex-col',
isHorizontal ? 'flex flex-row items-start gap-12' : 'flex flex-col',
className,
)}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,16 +78,16 @@ describe('InlineEdit integration', () => {
expect(value.className).toContain('has-[[data-slot=inline-edit]]:overflow-visible');
});

it('omits the -ml-7 hit-target offset in horizontal orientation', () => {
// In horizontal orientation AttributeValue sits right next to
// AttributeLabel with only a 4px gap — a 7px pull would eat into the
// label's space, so the offset only applies when stacked vertically.
it('keeps the -ml-7 hit-target pulls in horizontal orientation', () => {
// Both pulls apply in both orientations so the guest's text lines up
// where a plain value would sit (see AttributeValue.tsx).
render(<Example onCommit={() => {}} orientation='horizontal' />);
const value = screen.getByTestId('attr--value');
expect(value.className).not.toContain('has-[[data-slot=inline-edit]]:-ml-7');
expect(value.className).not.toContain('has-[[data-slot=attribute-actions-target]]:-ml-7');
// Nothing to cancel when there's no ambient -ml-7 pull.
expect(value.className).not.toContain('[&_[data-slot=inline-edit-error]]:ml-7');
expect(value.className).toContain('has-[[data-slot=inline-edit]]:-ml-7');
expect(value.className).toContain('has-[[data-slot=attribute-actions-target]]:-ml-7');
// The pull is active for the hosted InlineEdit, so its error cancel
// must follow in horizontal too.
expect(value.className).toContain('[&_[data-slot=inline-edit-error]]:ml-7');
// The vertical-axis cancellation still applies regardless of orientation.
expect(value.className).toContain('[&_[data-slot=inline-edit-preview]]:-my-4');
expect(value.className).toContain('[&_[data-slot=inline-edit-control]]:-my-4');
Expand Down
21 changes: 11 additions & 10 deletions packages/design-system/src/components/Attribute/AttributeValue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,21 @@ export const AttributeValue: FC<AttributeValueProps> = ({ ref, children, classNa
'[&_[data-slot=inline-edit-control]]:-my-4',
// Same seam, horizontal axis: pulls the hover/pressed background and
// hit target 7px further left than surrounding content, on the
// AttributeValue box itself rather than duplicated per guest.
// Applies whenever AttributeValue hosts either an InlineEdit or an
// AttributeActionsTarget — both render their own wide hover row and
// want it pulled flush with the label above. Vertical orientation
// only — in horizontal orientation AttributeValue sits right next to
// AttributeLabel with only a 4px gap, so a 7px pull would eat into
// the label's space.
!isHorizontal && 'has-[[data-slot=inline-edit]]:-ml-7',
!isHorizontal && 'has-[[data-slot=attribute-actions-target]]:-ml-7',
// AttributeValue box itself rather than duplicated per guest. Applies
// whenever AttributeValue hosts either an InlineEdit or an
// AttributeActionsTarget, in both orientations, so the guest's text
// lines up where a plain value would sit (in horizontal the 7px pull
// fits inside the 12px label gap set by Attribute).
'has-[[data-slot=inline-edit]]:-ml-7',
'has-[[data-slot=attribute-actions-target]]:-ml-7',
// The -ml-7 above pulls the whole box left, including InlineEditError
// — which isn't part of the wide hover row and has no reason to
// follow it. Cancel the pull for that one guest so the error message
// stays flush with the label instead of overhanging its left edge.
!isHorizontal && '[&_[data-slot=inline-edit-error]]:ml-7',
// (An error can only render inside an InlineEdit guest, so whenever
// this rule matches, the inline-edit pull above is active — in both
// orientations.)
'[&_[data-slot=inline-edit-error]]:ml-7',
// Un-clip non-portaled editor dropdowns (horizontal `truncate` sets
// overflow-hidden). :has() only matches while an InlineEdit is hosted.
'has-[[data-slot=inline-edit]]:overflow-visible',
Expand Down
96 changes: 96 additions & 0 deletions packages/design-system/src/components/Dialog/Dialog.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const dialogStory = createStoryHelper('overlay-dialog', [
'With Tabs',
'With Input At Edge',
'With Input At Edge Scrollable',
'With Nested Select',
'With Nested Drawer',
] as const);

const getDialogContent = (page: Page) => page.getByTestId('dialog--content');
Expand Down Expand Up @@ -91,9 +93,103 @@ test.describe('Component: Dialog', () => {
await focusViaKeyboard(page, input);
await expect(page).toHaveScreenshot({ animations: 'disabled' });
});

test('Should render the select dropdown above a nested dialog correctly', async ({ page }) => {
await dialogStory.goto(page, 'With Nested Select');
await page.getByRole('button', { name: 'Open dialog with nested select' }).click();
await page.getByRole('button', { name: 'Open nested dialog' }).click();
await page.getByTestId('nested-select--button').click();
await expect(page.locator('[data-scope="select"][data-part="content"]')).toBeVisible();
await expect(page).toHaveScreenshot({ animations: 'disabled' });
});
});

test.describe('Interactions', () => {
test('Should not push the dialog back when a drawer opens inside it', async ({ page }) => {
// Regression: Drawer and Dialog share one Ark dialog machine, so zag's
// data-has-nested cannot tell them apart — a Drawer opened from a
// Dialog wrongly triggered the dialog's pushed-back animation. The
// pushed-back CSS keys on the DS-owned same-kind attribute now.
await dialogStory.goto(page, 'With Nested Drawer');
await page.getByRole('button', { name: 'Open dialog with drawer inside' }).click();
const dialogContent = page.locator('[data-scope="dialog"][data-part="content"]').first();
await expect(dialogContent).toBeVisible();

await page.getByRole('button', { name: 'Open drawer' }).click();
await expect(page.locator('[data-scope="dialog"][data-part="content"]')).toHaveCount(2);

await expect(dialogContent).not.toHaveAttribute('data-has-nested-same');
const pushedBack = await dialogContent.evaluate(el => {
const cs = getComputedStyle(el);
return { scale: cs.scale, translate: cs.translate };
});
expect(pushedBack).toEqual({ scale: 'none', translate: 'none' });
});

test('Should stack the select dropdown above a nested dialog when opened inside it', async ({
page,
}) => {
// Regression: the dropdown content's static z-50 mirrored into its
// positioner sat below the nested dialog positioner (50 + layer * 20),
// hiding the open menu. The content z-index is layer-aware now.
await dialogStory.goto(page, 'With Nested Select');
await page.getByRole('button', { name: 'Open dialog with nested select' }).click();
await page.getByRole('button', { name: 'Open nested dialog' }).click();
await page.getByTestId('nested-select--button').click();

const content = page.locator('[data-scope="select"][data-part="content"]');
await expect(content).toBeVisible();

// The topmost element at the dropdown's location must belong to the
// dropdown itself — not to the nested dialog covering it.
// Let the open animation (zoom-in/slide-in) finish before hit-testing —
// mid-animation the content box is still transforming and the probe
// point can land outside it.
await content.evaluate(el => Promise.all(el.getAnimations().map(a => a.finished)));
const isOnTop = await content.evaluate(el => {
const rect = el.getBoundingClientRect();
const top = document.elementFromPoint(
rect.left + rect.width / 2,
rect.top + rect.height / 2,
);
return !!top && el.contains(top);
});
expect(isOnTop).toBe(true);

// The option is actually interactive: selecting closes the dropdown.
await page.getByRole('option', { name: 'Vue' }).click();
await expect(content).toBeHidden();
});

test('Should stack the dropdown menu above a nested dialog when opened inside it', async ({
page,
}) => {
await dialogStory.goto(page, 'With Nested Select');
await page.getByRole('button', { name: 'Open dialog with nested select' }).click();
await page.getByRole('button', { name: 'Open nested dialog' }).click();
await page.getByRole('button', { name: 'Open menu' }).click();

const content = page.locator('[data-scope="menu"][data-part="content"]');
await expect(content).toBeVisible();

// Let the open animation (zoom-in/slide-in) finish before hit-testing —
// mid-animation the content box is still transforming and the probe
// point can land outside it.
await content.evaluate(el => Promise.all(el.getAnimations().map(a => a.finished)));
const isOnTop = await content.evaluate(el => {
const rect = el.getBoundingClientRect();
const top = document.elementFromPoint(
rect.left + rect.width / 2,
rect.top + rect.height / 2,
);
return !!top && el.contains(top);
});
expect(isOnTop).toBe(true);

await page.getByRole('menuitem', { name: 'First action' }).click();
await expect(content).toBeHidden();
});

test('Should open dialog when trigger button is clicked', async ({ page }) => {
await dialogStory.goto(page, 'Basic');
await expect(getDialogContent(page)).toBeHidden();
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading