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(