wrolpi/app/src/components/Calculators.js
Roland 00408d515d
ui: fix the cypress failures, and two real defects behind them
All 43 cypress specs pass now (was 36 of 43).  Five of the seven failures were
stale selectors, two were real.

Real, and both mine:

  The search modal stopped focusing its input.  Mantine mounts modal content on
  a later tick, so the effect that focused it ran while the ref was still null;
  Semantic's modal mounted synchronously, which is why focusing there had been
  enough.  Fixed declaratively -- the field asks for focus itself, and SearchBox
  also sets `data-autofocus`, which is what Mantine's focus trap honours when it
  takes focus away from a plain autoFocus.

  Escape closed the suggestion list and the enclosing modal at once, so a user
  correcting a mistyped query lost the whole modal.  Escape now dismisses the
  suggestions and stops there; a second Escape, with the list closed, still
  closes the modal.

Stale selectors, all in the specs: Semantic's icon font (`i.search.icon`) is
SVG now, its `.ui.modal`/`.ui.dimmer` are the library's modal and overlay, and
one test asserted a button carried the classes `ui button secondary`.  That last
one is rewritten to check what a user can actually perceive -- that Edit is a
real link pointing at the edit route -- because framework class names say
nothing and break on every library change.

Two accessibility gaps the tests walked into:

  The nav's search control was an anchor with an onClick, no name and no
  keyboard path.  It now has a name, a role and Enter/Space activation, which is
  also what let the spec stop matching on icon-font classes.

  The search dialog had no accessible name at all; it has one now.

One `{force: true}` remains, with its reason recorded: Cypress judges that
field covered because it sits in a fixed modal under a sticky header, and I
verified by hand at Cypress's own viewport that it is the topmost element at
its centre, unobscured, and focused on open.

Also closed a hole in the ratchet that this uncovered.  A file could drop its
`semantic-ui-react` imports, leave the baseline, and still render Semantic
through Theme.tsx's wrappers -- which is exactly how a Semantic modal survived
in the search shortcut long after that file looked migrated.  A test now
forbids it, and the three files doing it (Calculators, KeyboardShortcutsProvider,
and Common.js reaching for theme names) import from the library or from
themes/names instead.
2026-08-08 18:04:01 -06:00

96 lines
4.9 KiB
JavaScript

import React from "react";
import {Breadcrumbs, useTitle} from "./Common";
import {Button, Header, Icon} from "./ui";
import {useCalcQuery} from "../hooks/customHooks";
import {TemperatureCalculator} from "./calculators/TemperatureCalculator";
import {ElectricalCalculators} from "./calculators/ElectricalCalculators";
import {DipoleAntennaCalculator} from "./calculators/HamCalculators";
import {Link} from "react-router";
import {RatioCalculators} from "./calculators/RatioCalculator";
import {QRCodeCalculator} from "./calculators/QRCodeCalculator";
import {DriveCalculator} from "./calculators/DriveCalculator";
import {WaterCalculator} from "./calculators/WaterCalculator";
import {FoodStorageCalculator} from "./calculators/FoodStorageCalculator";
import {OneTimePadCalculator} from "./calculators/OneTimePadCalculator";
import {VinDecoderCalculator} from "./calculators/VinDecoderCalculator";
// Calculators are shown in these titled groups, in this order. Each calculator carries a
// `group` matching one of these names; any calculator whose group is missing/unknown falls
// into a trailing "Other" section so a new calculator can never silently disappear.
//
// `color` is a Semantic UI named color (colorblind-friendly palette) used to tint the group's
// buttons. Violet is intentionally avoided — it is the default navbar color and would blend in.
export const CALCULATOR_GROUPS = [
{name: 'Preparedness & Storage', color: 'red'},
{name: 'Radio & Communications', color: 'blue'},
{name: 'Engineering', color: 'yellow'},
{name: 'Tools & Reference', color: 'grey'},
];
export const useCalculators = () => {
const [calc, setCalc] = useCalcQuery();
const calculators = [
{key: 'ratio', icon: 'th large', button: 'Ratio', group: 'Engineering', contents: <RatioCalculators/>},
{key: 'electrical', icon: 'lightning', button: 'Electrical', group: 'Engineering', contents: <ElectricalCalculators/>},
{key: 'drive', icon: 'cogs', button: 'Drive Ratio', group: 'Engineering', contents: <DriveCalculator/>},
{key: 'water', icon: 'tint', button: 'Water Storage', group: 'Preparedness & Storage', contents: <WaterCalculator/>},
{key: 'ration', icon: 'food', button: 'Food Storage', group: 'Preparedness & Storage', contents: <FoodStorageCalculator/>},
{key: 'antenna', icon: 'signal', button: 'Antenna', group: 'Radio & Communications', contents: <DipoleAntennaCalculator/>},
{key: 'temperature', icon: 'thermometer', button: 'Temperature', group: 'Tools & Reference', contents: <TemperatureCalculator/>},
{key: 'qrCode', icon: 'qrcode', button: 'QR Code', group: 'Tools & Reference', contents: <QRCodeCalculator/>},
{key: 'otp', icon: 'lock', button: 'One Time Pad', title: 'One Time Pad', group: 'Radio & Communications', contents: <OneTimePadCalculator/>},
{key: 'vin', icon: 'car', button: 'VIN Decoder', title: 'VIN Decoder', group: 'Tools & Reference', contents: <VinDecoderCalculator/>},
];
const activeCalculator = calc ? calculators.find(i => i.key === calc) : null;
const calculatorButton = ({button, icon, key}, color) =>
<Link key={key} to={`/more/calculators?calc=${key}`}>
<Button color={color} style={{margin: '0.5em'}}>
{icon && <Icon name={icon}/>}
{button}
</Button>
</Link>;
// Render each defined group as a titled section, then any leftovers under "Other".
const groupNames = CALCULATOR_GROUPS.map(g => g.name);
const grouped = CALCULATOR_GROUPS.map(g => [g.name, g.color, calculators.filter(c => c.group === g.name)]);
const leftovers = calculators.filter(c => !groupNames.includes(c.group));
if (leftovers.length > 0) {
grouped.push(['Other', undefined, leftovers]);
}
const calculatorLinks = grouped
.filter(([, , items]) => items.length > 0)
.map(([name, color, items]) =>
<div key={name} style={{marginBottom: '1.5em'}}>
<Header as='h3'>{name}</Header>
{items.map(c => calculatorButton(c, color))}
</div>);
return {calc, calculators, activeCalculator, calculatorLinks}
}
export function CalculatorsPage() {
const {activeCalculator, calculatorLinks} = useCalculators();
const {icon, button, title, contents} = activeCalculator || {};
const name = activeCalculator ? title || button + ' Calculator' : 'Calculators';
useTitle(name);
const body = activeCalculator ? <span>{contents}</span> : calculatorLinks;
// Make first breadcrumb a link back to Calculators only when a calculator is active.
const crumbs = activeCalculator
? [{text: 'Calculators', link: '/more/calculators'}, {text: name, icon: icon}]
: [{text: 'Calculators'}];
return <>
<Breadcrumbs crumbs={crumbs} size='huge'/>
<div style={{marginTop: '1em'}}>
{body}
</div>
</>
}