mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Acting on the review of #625. No bugs; four suggestions and three nits, of which two were already handled and one needed no code. The weld used three separate 8px literals -- the control's padding, its flex gap, and the clear button's negative margins -- which had to stay in lockstep or the button would drift past the border or back inside it. They are one `--searchbox-inset` now. Cancelling the gap on the clear button's LEFT was a real side effect: when a field is both `loading` and `clearable`, which SearchResultsInput allows, the DOM order is input, spinner, clear, and the spinner ended up welded to the divider. Only the right-hand padding is cancelled now; the 8px before the button stays, and a case covers the pair. `min-height` was still coming from `--ai-size`, so the rule only half-owned the height it claimed to set -- true today by 0.15px, the field being 36.15 against a 36 minimum. Unset now. Two nits needed no code and are recorded as findings rather than changes. The clear button's corners are already square: the theme zeroes every `--mantine-radius-*`, so there are no wedges to show against the field, and a case now depends on that explicitly. And a label-less button with BOTH `icon` and `iconAfter` would stay mis-centred, but no call site writes one, so the limit is documented instead of designed around. Blank children now count as no label. `toArray` drops null and booleans but keeps `''` and whitespace, which would have left the glyph off-centre; no call site does it today and the predicate should not depend on that. Two corrections to my own work, both caught by planting: The fragment case. I wrote that `toArray` flattens fragments away to nothing and tested `<></>` as unlabelled. It does not -- it returns the fragment as one child, so `<></>` is indistinguishable from real content. The test now records what actually happens, with `<>Delete</>` as the shape that occurs in practice. The min-height assertion compared `--ai-size` -- which reads back as `calc(2.25rem * var(--mantine-scale))` -- against a computed `36px`. Two strings that can never match, so it passed with the rule removed. It compares resolved lengths now, paired with an ordinary IconButton that DOES carry the minimum, so its absence is attributable to this rule rather than to a Mantine default. An earlier attempt fabricated a shorter field by overriding the input's font size; that was deleted, because a test that invents a layout the app never renders is not evidence about this app. 1128 jest / 63 suites, 231 in ui-layout.cy.js, clean npm run build. Confirmed live: inset 8px, clear button 1px from the field edge, 8px from the input, at the field's full inner height with no minimum of its own.
168 lines
7 KiB
TypeScript
168 lines
7 KiB
TypeScript
import React, {forwardRef} from 'react';
|
|
import {ActionIcon, ActionIconProps, Button as MButton, ButtonProps as MButtonProps} from '@mantine/core';
|
|
import {Icon} from './Icon';
|
|
|
|
/*
|
|
* Buttons.
|
|
*
|
|
* Roles carry meaning (Semantic UI heritage): blue = primary/download,
|
|
* green = save/confirm, red = destructive, amber = retry/warning, neutral
|
|
* outline = cancel. Prefer `role` over naming a color, so a theme that remaps
|
|
* a color keeps the meaning.
|
|
*/
|
|
|
|
export type ButtonRole = 'primary' | 'save' | 'retry' | 'danger' | 'cancel';
|
|
|
|
const roleProps: Record<ButtonRole, {color?: string; variant: string; className?: string}> = {
|
|
primary: {color: 'blue', variant: 'filled'},
|
|
save: {color: 'green', variant: 'filled'},
|
|
retry: {color: 'yellow', variant: 'filled'},
|
|
// Danger resolves differently per theme (filled in light/dark, dashed outline in
|
|
// night). The branching lives in ui.css, never in a call site.
|
|
danger: {color: 'red', variant: 'filled', className: 'wrolpi-button-danger'},
|
|
cancel: {variant: 'default'},
|
|
};
|
|
|
|
export interface ButtonProps extends Omit<MButtonProps, 'leftSection' | 'rightSection'> {
|
|
role?: ButtonRole;
|
|
/** Semantic icon name or a Tabler component, rendered before the label. */
|
|
icon?: string | React.ComponentType<any>;
|
|
iconAfter?: string | React.ComponentType<any>;
|
|
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
|
type?: 'button' | 'submit' | 'reset';
|
|
href?: string;
|
|
target?: string;
|
|
component?: any;
|
|
}
|
|
|
|
const renderIcon = (icon?: string | React.ComponentType<any>) => {
|
|
if (!icon) return undefined;
|
|
return typeof icon === 'string' ? <Icon name={icon}/> : <Icon component={icon}/>;
|
|
}
|
|
|
|
/*
|
|
* Semantic's size names, mapped onto Mantine's scale.
|
|
*
|
|
* Unmigrated call sites still pass `size='tiny'`, and Mantine silently ignores a
|
|
* size it does not know — the button renders at the default size and nobody sees
|
|
* a warning. Translating here keeps those call sites looking right until they
|
|
* migrate, and costs nothing once they have.
|
|
*/
|
|
const semanticSizes: Record<string, string> = {
|
|
mini: 'xs',
|
|
tiny: 'xs',
|
|
small: 'sm',
|
|
medium: 'md',
|
|
large: 'lg',
|
|
big: 'lg',
|
|
huge: 'xl',
|
|
massive: 'xl',
|
|
};
|
|
|
|
// Generic so it passes each component's own size type straight through; only a
|
|
// string that Semantic knew about is rewritten.
|
|
export const resolveSize = <T, >(size: T): T =>
|
|
typeof size === 'string' ? ((semanticSizes[size] ?? size) as T) : size;
|
|
|
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>((
|
|
{role, icon, iconAfter, className, children, ...props}, ref
|
|
) => {
|
|
const fromRole = role ? roleProps[role] : undefined;
|
|
|
|
/*
|
|
* An icon with no label is the button's CONTENT, not a leading icon.
|
|
*
|
|
* Mantine gives `leftSection` a `margin-inline-end` to hold it off the label. With no
|
|
* label that margin is pure offset: the glyph sits about 8px left of the button's centre,
|
|
* which is what the import and save buttons on the Settings config table looked like next
|
|
* to a correctly centred Restore -- Restore is an IconButton, which centres a lone glyph
|
|
* by construction.
|
|
*
|
|
* `React.Children.toArray` drops null, undefined and booleans, which matters because the
|
|
* file browser's footer passes `{label('Delete')}` and that is null whenever the bar is
|
|
* too narrow for words. Those buttons are icon-only at exactly the widths where the
|
|
* offset is most visible.
|
|
*/
|
|
/*
|
|
* Blank text is not a label. `toArray` drops null, undefined and booleans, but keeps an
|
|
* empty or whitespace-only string, which would leave the icon in `leftSection` with its
|
|
* margin and the glyph off-centre again. No call site does that today; the predicate
|
|
* should not depend on that staying true.
|
|
*
|
|
* Two limits, both deliberate. "Icon-only" means exactly one of `icon`/`iconAfter`: a
|
|
* label-less button with both would keep both section margins and stay mis-centred, and
|
|
* there are no such call sites to design for. And any ELEMENT child counts as a label,
|
|
* including an empty fragment -- `toArray` does not look inside one, so `<></>` is
|
|
* indistinguishable from real content without recursing into fragment props.
|
|
*/
|
|
const labelled = React.Children.toArray(children)
|
|
.some(child => typeof child !== 'string' || child.trim().length > 0);
|
|
const soleIcon = !labelled && (icon || iconAfter) && !(icon && iconAfter)
|
|
? renderIcon(icon || iconAfter)
|
|
: undefined;
|
|
|
|
return <MButton
|
|
ref={ref}
|
|
/*
|
|
* An `href` makes this an anchor. Mantine needs `component='a'` to render one, and
|
|
* without it the href is accepted and silently dropped — the control looks like a
|
|
* link and navigates nowhere. Semantic used `as='a'` for this, so every migrated
|
|
* call site that carried `href` alone was quietly broken.
|
|
*/
|
|
component={props.component ?? (props.href ? 'a' : undefined)}
|
|
// An explicit color/variant still wins, so a call site can deviate when it must.
|
|
color={props.color ?? fromRole?.color}
|
|
variant={props.variant ?? fromRole?.variant}
|
|
className={[fromRole?.className, className].filter(Boolean).join(' ') || undefined}
|
|
leftSection={soleIcon ? undefined : renderIcon(icon)}
|
|
rightSection={soleIcon ? undefined : renderIcon(iconAfter)}
|
|
{...props}
|
|
size={resolveSize(props.size)}
|
|
>
|
|
{soleIcon ?? children}
|
|
</MButton>
|
|
});
|
|
Button.displayName = 'Button';
|
|
|
|
export interface IconButtonProps extends Omit<ActionIconProps, 'children'> {
|
|
role?: ButtonRole;
|
|
icon: string | React.ComponentType<any>;
|
|
/** Required: an icon-only button has no visible name. */
|
|
label: string;
|
|
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
|
type?: 'button' | 'submit' | 'reset';
|
|
href?: string;
|
|
target?: string;
|
|
component?: any;
|
|
}
|
|
|
|
/**
|
|
* 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, size = 'sm', ...props}, ref
|
|
) => {
|
|
const fromRole = role ? roleProps[role] : undefined;
|
|
return <ActionIcon
|
|
ref={ref}
|
|
// See Button: an href without `component='a'` renders a non-navigating button.
|
|
component={props.component ?? (props.href ? 'a' : undefined)}
|
|
aria-label={label}
|
|
title={label}
|
|
color={props.color ?? fromRole?.color}
|
|
variant={props.variant ?? fromRole?.variant ?? 'default'}
|
|
className={[fromRole?.className, className].filter(Boolean).join(' ') || undefined}
|
|
{...props}
|
|
size={resolveSize(size)}
|
|
>
|
|
{renderIcon(icon)}
|
|
</ActionIcon>
|
|
});
|
|
IconButton.displayName = 'IconButton';
|
|
|
|
export {ButtonGroup} from '@mantine/core';
|