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/rare-pumas-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
20 changes: 20 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,23 @@ which color and variant it is stays legible while it's unavailable. Hover and pr
by the styles, not by `pointer-events`: the button stays hit-testable, which is what makes
`cursor: not-allowed` render at all and what lets a wrapping tooltip explain _why_ it's disabled.
That tooltip is worth adding β€” a disabled control with no explanation is a dead end.

### Touch target

Every size is shorter than the 44px a fingertip needs, so under `pointer: coarse` the button grows
its hit area with an invisible overlay β€” the rendered size doesn't change, and nothing shifts on a
mouse. Default shape grows on the block axis only; square and circle grow on both, being as narrow
as they are short. `link` is text, not a control, and never takes one.

Opt out with `touchTarget={false}` where buttons sit close enough for the overlays to overlap β€” a
dense icon toolbar, a tight stack. Overlap isn't symmetrical: the later sibling's overlay covers
the edge of the one before it, so a tap there activates the wrong button.

```tsx
<Button
shape='square'
touchTarget={false}
>
Γ—
</Button>
```
2 changes: 2 additions & 0 deletions packages/swingset/src/stories/button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ export const meta: StoryMeta = {
size: { sm: {}, md: {}, lg: {} },
shape: { default: {}, square: {}, circle: {} },
fullWidth: { true: {}, false: {} },
touchTarget: { true: {}, false: {} },
},
_defaultVariants: {
color: 'primary',
variant: 'filled',
size: 'md',
shape: 'default',
fullWidth: false,
touchTarget: true,
},
},
};
Expand Down
32 changes: 24 additions & 8 deletions packages/ui/src/mosaic/components/button/button.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,6 @@ export const styles = stylex.create({
default: 'none',
':focus-visible': `2px solid ${colorVars['--cl-color-primary']}`,
},
// The size axis fixes the height, so overflowing content clips instead of spilling out.
overflow: 'hidden',
alignItems: 'center',
// Strips UA control styling so what's below is the whole appearance, not an override.
appearance: 'none',
Expand Down Expand Up @@ -113,16 +111,34 @@ export const styles = stylex.create({
paddingInlineStart: 0,
},

// Under a coarse pointer the control floors at the target size. Its own atom rather than a
// per-size override: the floor is one physical constant, and `link` β€” text, not a control β€”
// opts out by not receiving it. `minHeight` leaves the fixed height in charge otherwise.
// Under a coarse pointer the hit area floors at the target size β€” grown by an overlay
// rather than by `min-height`, so the button keeps the size its axis gives it and only the
// region answering a tap gets bigger. Its own atom rather than a per-size override: the
// floor is one physical constant, and `link` β€” text, not a control β€” opts out by not
// receiving it.
//
// The insets resolve against the button's own box, so one expression covers every size:
// the overlay lands at exactly the target height whatever the shortfall, and clamps to the
// button's bounds once the control is already past the floor. `position` is scoped to the
// media query too β€” a fine pointer generates no overlay, so it shouldn't take on the
// stacking change either.
touchTarget: {
minHeight: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] },
position: { default: null, '@media (pointer: coarse)': 'relative' },
'::after': {
insetBlock: `min(0px, (100% - ${targetVars['--cl-target-coarse']}) / 2)`,
// Text buttons are already past the floor inline β€” growing that axis too would only
// reach into a neighbor's space for nothing.
insetInline: 0,
content: { default: null, '@media (pointer: coarse)': '""' },
position: 'absolute',
},
},
// Icon buttons are square, so the floor has to reach the inline axis too or the target
// Icon buttons are square, so the floor has to reach the inline axis as well or the region
// ends up tall and narrow.
touchTargetIcon: {
minWidth: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] },
'::after': {
insetInline: `min(0px, (100% - ${targetVars['--cl-target-coarse']}) / 2)`,
},
},

// state / modifiers
Expand Down
30 changes: 30 additions & 0 deletions packages/ui/src/mosaic/components/button/button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,36 @@ describe('Mosaic Button', () => {
expect(button).toBeEnabled();
});

// The touch target is an overlay with no attribute of its own, so these compare the atoms
// the element ends up with rather than a reflected variant.
it('drops the touch-target atoms when the prop is off', () => {
const { rerender } = render(<Button>Hi</Button>);
const on = screen.getByRole('button').className.split(' ');
rerender(<Button touchTarget={false}>Hi</Button>);
const off = screen.getByRole('button').className.split(' ');
expect(off.length).toBeLessThan(on.length);
expect(off.every(atom => on.includes(atom))).toBe(true);
});

it('leaves the touch target off a link, which is text rather than a control', () => {
const { rerender } = render(<Button variant='link'>Hi</Button>);
const on = screen.getByRole('button').className;
rerender(
<Button
variant='link'
touchTarget={false}
>
Hi
</Button>,
);
expect(screen.getByRole('button').className).toBe(on);
});

it('keeps the touch-target prop off the element', () => {
render(<Button touchTarget={false}>Hi</Button>);
expect(screen.getByRole('button')).not.toHaveAttribute('touchtarget');
});

it('wires variant props and consumer className/style through to the element', () => {
render(
<Button
Expand Down
14 changes: 12 additions & 2 deletions packages/ui/src/mosaic/components/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ export interface ButtonProps extends Omit<MosaicComponentProps<'button'>, 'rende
size?: 'sm' | 'md' | 'lg';
shape?: 'default' | 'square' | 'circle';
fullWidth?: boolean;
/**
* Floors the hit area at the recommended touch target under a coarse pointer, without
* changing the rendered size. Defaults to `true`. Pass `false` where buttons sit close
* enough that the grown areas would overlap β€” a dense icon toolbar, a tight stack β€” since
* the later sibling's area would otherwise cover the edge of the one before it. Has no
* effect on `variant='link'`, which is text rather than a control.
*/
touchTarget?: boolean;
}

/**
Expand Down Expand Up @@ -81,6 +89,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function
size = 'md',
shape = 'default',
fullWidth = false,
touchTarget = true,
disabled = false,
className,
style,
Expand All @@ -90,6 +99,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function
ref,
) {
const isIconShape = shape === 'square' || shape === 'circle';
const hasTouchTarget = touchTarget && variant !== 'link';
return (
<button
ref={ref}
Expand All @@ -104,8 +114,8 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function
shape === 'square' && styles.shapeSquare,
shape === 'circle' && styles.shapeCircle,
isIconShape && iconSizes[size],
variant !== 'link' && styles.touchTarget,
variant !== 'link' && isIconShape && styles.touchTargetIcon,
hasTouchTarget && styles.touchTarget,
hasTouchTarget && isIconShape && styles.touchTargetIcon,
fullWidth && styles.fullWidth,
disabled && styles.disabled,
),
Expand Down
Loading