diff --git a/.changeset/rare-pumas-repeat.md b/.changeset/rare-pumas-repeat.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/rare-pumas-repeat.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/swingset/src/stories/button.mdx b/packages/swingset/src/stories/button.mdx
index 5c447778fc8..e0ce70678e9 100644
--- a/packages/swingset/src/stories/button.mdx
+++ b/packages/swingset/src/stories/button.mdx
@@ -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
+
+```
diff --git a/packages/swingset/src/stories/button.stories.tsx b/packages/swingset/src/stories/button.stories.tsx
index 03fc14af232..af32b82d31b 100644
--- a/packages/swingset/src/stories/button.stories.tsx
+++ b/packages/swingset/src/stories/button.stories.tsx
@@ -22,6 +22,7 @@ export const meta: StoryMeta = {
size: { sm: {}, md: {}, lg: {} },
shape: { default: {}, square: {}, circle: {} },
fullWidth: { true: {}, false: {} },
+ touchTarget: { true: {}, false: {} },
},
_defaultVariants: {
color: 'primary',
@@ -29,6 +30,7 @@ export const meta: StoryMeta = {
size: 'md',
shape: 'default',
fullWidth: false,
+ touchTarget: true,
},
},
};
diff --git a/packages/ui/src/mosaic/components/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts
index 25e6c7f99f3..f09d9d62b6e 100644
--- a/packages/ui/src/mosaic/components/button/button.styles.ts
+++ b/packages/ui/src/mosaic/components/button/button.styles.ts
@@ -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',
@@ -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
diff --git a/packages/ui/src/mosaic/components/button/button.test.tsx b/packages/ui/src/mosaic/components/button/button.test.tsx
index 163b799b8bb..6fd7f0162b6 100644
--- a/packages/ui/src/mosaic/components/button/button.test.tsx
+++ b/packages/ui/src/mosaic/components/button/button.test.tsx
@@ -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();
+ const on = screen.getByRole('button').className.split(' ');
+ rerender();
+ 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();
+ const on = screen.getByRole('button').className;
+ rerender(
+ ,
+ );
+ expect(screen.getByRole('button').className).toBe(on);
+ });
+
+ it('keeps the touch-target prop off the element', () => {
+ render();
+ expect(screen.getByRole('button')).not.toHaveAttribute('touchtarget');
+ });
+
it('wires variant props and consumer className/style through to the element', () => {
render(