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
2 changes: 2 additions & 0 deletions .changeset/mosaic-card-footer-button-width.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 0 additions & 2 deletions packages/mosaic/src/blocks/confirmation/confirmation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,13 @@ export function Confirmation<Payload = void>({ color = 'negative', ...props }: C
<Button
color='neutral'
variant='outline'
fullWidth
>
{cancelLabel}
</Button>
}
/>
<SubmitButton
type='button'
fullWidth
color={color}
isPending={isPending}
onClick={confirm}
Expand Down
2 changes: 0 additions & 2 deletions packages/mosaic/src/blocks/destructive/destructive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,13 @@ function DestructiveStep<Payload>({
<Button
color='neutral'
variant='outline'
fullWidth
>
{cancelLabel}
</Button>
}
/>
<SubmitButton
form={formId}
fullWidth
color='negative'
isPending={isPending}
disabled={!isConfirmed}
Expand Down
16 changes: 9 additions & 7 deletions packages/mosaic/src/components/button/button.context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import React from 'react';

import type { ButtonProps } from './button';

export const ButtonContext = React.createContext<
Pick<ButtonProps, 'size' | 'shape' | 'variant' | 'color' | 'disabled'> & {
styles?: StyleXStyles;
sizeStyles?: StyleXStyles;
iconStyles?: StyleXStyles;
}
>({});
type ButtonDefaults = Pick<ButtonProps, 'size' | 'shape' | 'variant' | 'color' | 'disabled' | 'fullWidth'> & {
styles?: StyleXStyles;
sizeStyles?: StyleXStyles;
iconStyles?: StyleXStyles;
};

export const NO_BUTTON_DEFAULTS: ButtonDefaults = {};

export const ButtonContext = React.createContext<ButtonDefaults>(NO_BUTTON_DEFAULTS);
3 changes: 2 additions & 1 deletion packages/mosaic/src/components/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function
variant: variantProp,
size: sizeProp,
shape: shapeProp,
fullWidth = false,
fullWidth: fullWidthProp,
touchTarget = true,
disabled: disabledProp,
focusableWhenDisabled = false,
Expand All @@ -76,6 +76,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function
const variant = variantProp ?? defaults.variant ?? 'filled';
const size = sizeProp ?? defaults.size ?? 'md';
const shape = shapeProp ?? defaults.shape ?? 'default';
const fullWidth = fullWidthProp ?? defaults.fullWidth ?? false;
const disabled = defaults.disabled || disabledProp || false;
const isIconShape = shape === 'square' || shape === 'circle';
const hasTouchTarget = touchTarget && variant !== 'link';
Expand Down
51 changes: 51 additions & 0 deletions packages/mosaic/src/components/card/card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { Button, SubmitButton } from '../button';
import { ButtonContext } from '../button/button.context';
import { Dialog } from '../dialog';
import { Flow } from '../flow';
import { Card } from './card';
Expand Down Expand Up @@ -753,6 +755,55 @@ describe('Mosaic Card', () => {
});
});

describe('Card.Footer buttons', () => {
it('makes buttons full width by default', () => {
render(
<Card.Footer>
<Button>Cancel</Button>
<SubmitButton>Save</SubmitButton>
</Card.Footer>,
);

expect(screen.getByRole('button', { name: 'Cancel' })).toHaveAttribute('data-full-width', '');
expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('data-full-width', '');
});

it('lets a button opt out', () => {
render(
<Card.Footer>
<Button fullWidth={false}>Sign in</Button>
</Card.Footer>,
);

expect(screen.getByRole('button', { name: 'Sign in' })).not.toHaveAttribute('data-full-width');
});

it('keeps button defaults from an enclosing provider', () => {
render(
<ButtonContext.Provider value={{ disabled: true }}>
<Card.Footer>
<Button>Save</Button>
</Card.Footer>
</ButtonContext.Provider>,
);

expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
});

it('leaves buttons outside the footer alone', () => {
render(
<Card.Root>
<Card.Content>
<Button>Inside content</Button>
</Card.Content>
<Card.Footer />
</Card.Root>,
);

expect(screen.getByRole('button', { name: 'Inside content' })).not.toHaveAttribute('data-full-width');
});
});

describe('Card surface', () => {
// Written out rather than imported, so a drift from `card.styles.ts` fails here instead of passing silently.
const probe = stylex.create({
Expand Down
7 changes: 6 additions & 1 deletion packages/mosaic/src/components/card/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { BannerRootProps } from '../banner';
import { Banner } from '../banner';
import { Branding } from '../branding';
import { Button } from '../button';
import { ButtonContext } from '../button/button.context';
import type { DialogContextValue } from '../dialog';
import { Dialog, DialogContext, isInDialog } from '../dialog';
import { Icon } from '../icon';
Expand Down Expand Up @@ -251,7 +252,9 @@ const Footer = React.forwardRef<HTMLDivElement, MosaicComponentProps<'div'>>(fun
ref,
) {
const elevation = React.useContext(CardContext)?.elevation ?? DEFAULT_ELEVATION;
return useRender({
const parentButtonDefaults = React.useContext(ButtonContext);
const buttonDefaults = React.useMemo(() => ({ ...parentButtonDefaults, fullWidth: true }), [parentButtonDefaults]);
const element = useRender({
defaultTagName: 'div',
render,
ref,
Expand All @@ -263,6 +266,8 @@ const Footer = React.forwardRef<HTMLDivElement, MosaicComponentProps<'div'>>(fun
),
},
});

return <ButtonContext.Provider value={buttonDefaults}>{element}</ButtonContext.Provider>;
});

/**
Expand Down
34 changes: 34 additions & 0 deletions packages/mosaic/src/components/dialog/__tests__/dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest';

import type { MosaicComponentProps } from '../../../props';
import { colorVars, radiusVars, space } from '../../../tokens.stylex';
import { Button } from '../../button';
import { ButtonContext } from '../../button/button.context';
import { Card } from '../../card';
import type { DialogVariant } from '../dialog';
import { Dialog } from '../dialog';
Expand Down Expand Up @@ -395,6 +397,38 @@ describe('stacked backdrops', () => {
});
});

describe('button defaults', () => {
it('does not inherit full width from a card footer it is opened from', () => {
render(
<Card.Footer>
<Dialog.Root defaultOpen>
<Dialog.Popup>
<Surface title='Nested' />
<Button>Inside popup</Button>
</Dialog.Popup>
</Dialog.Root>
</Card.Footer>,
);

expect(screen.getByRole('button', { name: 'Inside popup' })).not.toHaveAttribute('data-full-width');
});

it('does not inherit disabled from an enclosing provider', () => {
render(
<ButtonContext.Provider value={{ disabled: true }}>
<Dialog.Root defaultOpen>
<Dialog.Popup>
<Surface title='Nested' />
<Button>Inside popup</Button>
</Dialog.Popup>
</Dialog.Root>
</ButtonContext.Provider>,
);

expect(screen.getByRole('button', { name: 'Inside popup' })).toBeEnabled();
});
});

describe('Dialog.CloseButton', () => {
it('closes the dialog and carries a default accessible name', async () => {
const user = userEvent.setup();
Expand Down
7 changes: 6 additions & 1 deletion packages/mosaic/src/components/dialog/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { MosaicComponentProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
import { reset } from '../../styles/reset.styles';
import { Button } from '../button';
import { ButtonContext, NO_BUTTON_DEFAULTS } from '../button/button.context';
import { Icon } from '../icon';
import { ToastProvider } from '../toast/toast';
import {
Expand Down Expand Up @@ -475,7 +476,11 @@ const Popup = React.forwardRef<HTMLDivElement, DialogPopupProps>(function Dialog
);

return (
<Primitive.Portal>{holdsProfile(variant) ? <ToastProvider>{viewport}</ToastProvider> : viewport}</Primitive.Portal>
<Primitive.Portal>
<ButtonContext.Provider value={NO_BUTTON_DEFAULTS}>
{holdsProfile(variant) ? <ToastProvider>{viewport}</ToastProvider> : viewport}
</ButtonContext.Provider>
</Primitive.Portal>
);
});

Expand Down
33 changes: 33 additions & 0 deletions packages/mosaic/src/components/popover/popover.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { Button } from '../button';
import { ButtonContext } from '../button/button.context';
import { Card } from '../card';
import { Popover } from './popover';

afterEach(() => cleanup());
Expand All @@ -14,6 +17,36 @@ const settle = () =>
await new Promise(resolve => setTimeout(resolve, 0));
});

describe('button defaults', () => {
it('does not inherit full width from a card footer it is opened from', () => {
render(
<Card.Footer>
<Popover.Root defaultOpen>
<Popover.Popup aria-label='Panel'>
<Button>Inside popup</Button>
</Popover.Popup>
</Popover.Root>
</Card.Footer>,
);

expect(screen.getByRole('button', { name: 'Inside popup' })).not.toHaveAttribute('data-full-width');
});

it('does not inherit disabled from an enclosing provider', () => {
render(
<ButtonContext.Provider value={{ disabled: true }}>
<Popover.Root defaultOpen>
<Popover.Popup aria-label='Panel'>
<Button>Inside popup</Button>
</Popover.Popup>
</Popover.Root>
</ButtonContext.Provider>,
);

expect(screen.getByRole('button', { name: 'Inside popup' })).toBeEnabled();
});
});

describe('Mosaic Popover', () => {
it('uses a custom anchor and restores the trigger when it is removed', async () => {
const anchor = document.createElement('div');
Expand Down
19 changes: 11 additions & 8 deletions packages/mosaic/src/components/popover/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Popover as Primitive } from '../../primitives/popover';
import type { MosaicComponentProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
import { reset } from '../../styles/reset.styles';
import { ButtonContext, NO_BUTTON_DEFAULTS } from '../button/button.context';
import { sizes, styles } from './popover.styles';

export type PopoverSize = 'sm' | 'md' | 'lg' | 'anchor';
Expand Down Expand Up @@ -98,14 +99,16 @@ const Popup = React.forwardRef<HTMLDivElement, PopoverPopupProps>(function Popov
{...(ariaLabel == null ? {} : { 'aria-label': ariaLabel })}
{...(ariaLabelledby == null ? {} : { 'aria-labelledby': ariaLabelledby })}
>
<Primitive.Popup
ref={ref}
{...mergeStyleProps(
themeProps('popover-popup', { size }),
stylex.props(reset.base, styles.popup, sizes[size], xstyle),
rest,
)}
/>
<ButtonContext.Provider value={NO_BUTTON_DEFAULTS}>
<Primitive.Popup
ref={ref}
{...mergeStyleProps(
themeProps('popover-popup', { size }),
stylex.props(reset.base, styles.popup, sizes[size], xstyle),
rest,
)}
/>
</ButtonContext.Provider>
</Positioner>
</Primitive.Portal>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,6 @@ function CreateKeyStep(props: CreateAPIKeyDialogProps & { inputRef: Ref<HTMLInpu
<Card.Footer>
<SubmitButton
form={form.id}
fullWidth
isPending={form.isSubmitting}
disabled={!form.canSubmit}
focusableWhenDisabled
Expand Down Expand Up @@ -223,12 +222,7 @@ function CopyKeyStep(props: CreateAPIKeyDialogProps) {
</Field.Root>
</Card.Content>
<Card.Footer>
<SubmitButton
form={formId}
fullWidth
>
{m.copyAndClose}
</SubmitButton>
<SubmitButton form={formId}>{m.copyAndClose}</SubmitButton>
</Card.Footer>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,6 @@ function CreateStep({ form, slugEnabled, onNameChange, onCancel }: CreateOrganiz
<Button
variant='outline'
color='neutral'
fullWidth
disabled={form.isSubmitting}
onClick={onCancel}
>
Expand All @@ -156,7 +155,6 @@ function CreateStep({ form, slugEnabled, onNameChange, onCancel }: CreateOrganiz
) : null}
<SubmitButton
form={form.id}
fullWidth
isPending={form.isSubmitting}
disabled={!form.canSubmit}
focusableWhenDisabled
Expand Down Expand Up @@ -303,15 +301,13 @@ function InviteStep({ invite, onFinish }: CreateOrganizationStepsProps) {
<Button
variant='outline'
color='neutral'
fullWidth
disabled={invite.isPending}
onClick={onFinish}
>
{m.invite.skip}
</Button>
<SubmitButton
form={formId}
fullWidth
isPending={invite.isPending}
disabled={!canSubmit}
focusableWhenDisabled
Expand All @@ -336,7 +332,6 @@ function SentStep({ onFinish }: CreateOrganizationStepsProps) {
<Card.Footer>
<Button
ref={finishButton}
fullWidth
onClick={onFinish}
>
{m.sent.finish}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,6 @@ function ListStep({
ref={createButton}
variant='outline'
color='neutral'
fullWidth
disabled={Boolean(pendingKey)}
focusableWhenDisabled
onClick={onCreateOrganization}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,13 @@ export function OrganizationProfileInviteMembersDialog({
<Button
variant='outline'
color='neutral'
fullWidth
>
{m.cancel}
</Button>
}
/>
<SubmitButton
form={formId}
fullWidth
isPending={isPending}
disabled={!canSubmit}
focusableWhenDisabled
Expand Down
Loading
Loading