ui: an icon button is the same height as the button beside it

The Details/Open pair on every archive card came out 36px beside 28px.

Mantine gives ActionIcon a size scale unrelated to Button's, and defaults
the two differently:

    size        xs    sm    md    lg    xl   default
    Button      30    36    42    50    60   sm (36)
    ActionIcon  18    22    28    34    44   md (28)

So the two never matched, at any size, and every toolbar mixing them was
ragged -- the archive card is just where it shows most.

Mantine already ships the aligned scale as `--ai-size-input-*`, for an
ActionIcon standing beside an Input; those five values are identical to
`--button-height-*`.  ui.css points one at the other, which fixes every
size at once including ones no call site uses yet, and IconButton now
defaults to `sm` like Button.

Both halves are needed and both are proven so: dropping just the `sm`
remap reddens the default and `sm` cases while xs/md/lg/xl stay green;
reverting just the default reddens the default case alone, at 42 vs 36 --
the mismatch inverted rather than fixed.

A further test pins the direction.  The pair could also be squared up by
cutting the labelled button down to 28px, which would be the wrong fix.

Gallery gains a row of Button/IconButton pairs at all five sizes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roland 2026-08-02 11:16:33 -06:00
parent 687b41bf20
commit bfb2e71d02
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
4 changed files with 101 additions and 4 deletions

View file

@ -244,10 +244,25 @@ export function ThemeSamplePage() {
})}>Show a toast</Button>
</Row>
</div>
<div style={{marginTop: 12}}>
<Row>
{['xs', 'sm', 'md', 'lg', 'xl'].map(size => <div key={size}
style={{display: 'flex', gap: '0.4em', alignItems: 'flex-start'}}>
<Button size={size}>{size}</Button>
<IconButton size={size} icon='external' label={`Open (${size})`}/>
</div>)}
</Row>
</div>
<p style={{fontSize: 12, color: 'var(--muted)', marginBottom: 0}}>
In night mode Delete drops its fill for a dashed red outline; dashed means
destructive or failed, and nothing else uses it.
</p>
<p style={{fontSize: 12, color: 'var(--muted)', marginBottom: 0}}>
An icon button is exactly as tall as a labelled one at every size, so a
group of them lines up. Mantine's own scale for icon buttons is unrelated
to its scale for buttons — <code>sm</code> is 22px against a button's 36px
— and the two components start from different defaults.
</p>
</Panel>
</Section>

View file

@ -101,9 +101,16 @@ export interface IconButtonProps extends Omit<ActionIconProps, 'children'> {
component?: any;
}
/** A square, icon-only button. */
/**
* A square, icon-only button, exactly as tall as a labelled `Button` beside it.
*
* The height needs BOTH halves of the fix. ui.css aligns ActionIcon's size scale with
* Button's, and this default aligns the starting point: Mantine defaults ActionIcon to `md`
* where it defaults Button to `sm`, so leaving it alone would trade a 28-vs-36 mismatch for
* a 42-vs-36 one.
*/
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>((
{role, icon, label, className, ...props}, ref
{role, icon, label, className, size = 'sm', ...props}, ref
) => {
const fromRole = role ? roleProps[role] : undefined;
return <ActionIcon
@ -116,7 +123,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>((
variant={props.variant ?? fromRole?.variant ?? 'default'}
className={[fromRole?.className, className].filter(Boolean).join(' ') || undefined}
{...props}
size={resolveSize(props.size)}
size={resolveSize(size)}
>
{renderIcon(icon)}
</ActionIcon>

View file

@ -1,6 +1,6 @@
import React from 'react';
import {
ActionInput, Button, Card, Header, Icon, IconStack, Loading, MultiSelect, Panel, PathInput,
ActionInput, Button, Card, Header, Icon, IconButton, IconStack, Loading, MultiSelect, Panel, PathInput,
Message, Placeholder, Progress, Statistic, StatisticGroup, Status, TabBar, Table,
tabClassName, TextInput,
} from './index';
@ -1863,3 +1863,56 @@ describe('a card keeps its meta with the title and its actions at the foot', ()
});
});
});
describe('a button and an icon button are the same height', () => {
/*
* Mantine gives ActionIcon a size scale of its own that is nothing like Button's --
* `sm` is 22px against a button's 36px -- and defaults it to `md` where Button defaults
* to `sm`. So the Details/Open pair on an archive card came out 36px beside 28px, and
* every other toolbar that mixes the two was ragged in the same way.
*
* Mantine already ships the aligned scale as `--ai-size-input-*`, for an ActionIcon
* sitting beside an Input; those five values are identical to `--button-height-*`. The
* fix points one at the other, so this holds for a size we have never used as well as
* the ones we have.
*
* Heights, so it belongs here: jsdom returns zero for both and the comparison would
* pass on any pair of numbers at all.
*/
const sizes = [undefined, 'xs', 'sm', 'md', 'lg', 'xl'];
sizes.forEach((size) => {
it(`matches at size ${size || '(default)'}`, () => {
cy.mountUI(<div style={{display: 'flex', gap: '0.5em', alignItems: 'flex-start'}}>
<Button size={size} icon='file alternate'>Details</Button>
<IconButton size={size} icon='external' label='Open original URL'/>
</div>);
cy.get('button').should(($buttons) => {
expect($buttons.length).to.equal(2);
const [button, iconButton] = [...$buttons].map(el => el.getBoundingClientRect());
// A zero-height pair would satisfy the equality below without meaning anything.
expect(button.height, 'the button has a height at all').to.be.greaterThan(10);
expect(iconButton.height, `icon button at size ${size || '(default)'}`)
.to.be.closeTo(button.height, 0.5);
// Square, which is the whole point of an icon button.
expect(iconButton.width, 'the icon button stays square')
.to.be.closeTo(iconButton.height, 0.5);
});
});
});
it('grows the icon button rather than shrinking the button', () => {
/*
* Both could be made equal by cutting the labelled button down to 28px, which would
* be the wrong fix -- a 28px target is below the 44px-ish guidance already stretched
* by having buttons this small, and every OTHER button on the page would still be 36.
* Pinned so a future "make them match" cannot resolve the other way.
*/
cy.mountUI(<IconButton icon='external' label='Open original URL'/>);
cy.get('button').should(($el) =>
expect($el[0].getBoundingClientRect().height, 'default icon button height')
.to.be.at.least(34));
});
});

View file

@ -102,6 +102,28 @@ html[data-theme="night"] .wrolpi-button-danger:hover:not(:disabled) {
/* The icon inside inherits currentColor, so it follows without another rule. */
/*
* An icon button is the same height as a labelled one, so a group of them lines up.
*
* Mantine gives ActionIcon a size scale unrelated to Button's -- `sm` is 22px against a
* button's 36px -- which left the Details/Open pair on every archive card ragged, and any
* other toolbar that mixes the two. It already ships the aligned scale as
* `--ai-size-input-*`, for an ActionIcon standing beside an Input; those five values are
* identical to `--button-height-*`. Pointing one at the other fixes every size at once,
* including ones no call site uses yet.
*
* The `html` prefix is load-bearing. Mantine declares this scale on ActionIcon's own
* hashed class, which has the same specificity as `.mantine-ActionIcon-root` and would win
* on source order.
*/
html .mantine-ActionIcon-root {
--ai-size-xs: var(--ai-size-input-xs);
--ai-size-sm: var(--ai-size-input-sm);
--ai-size-md: var(--ai-size-input-md);
--ai-size-lg: var(--ai-size-input-lg);
--ai-size-xl: var(--ai-size-input-xl);
}
/* ----------------------------------------------------------- Theme picker */
.wrolpi-theme-picker {