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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
20 changes: 20 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
20 changes: 20 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
20 changes: 20 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
20 changes: 20 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
20 changes: 20 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
20 changes: 20 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
20 changes: 20 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
20 changes: 20 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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