loopable / web /src /customer-grid /icons.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
0427303 verified
Raw
History Blame Contribute Delete
17.6 kB
// ---------------------------------------------------------------------------
// customer-grid / icons.tsx
// Wave-8 items I18 + I20 β€” the React half of the grid's icon vocabulary.
//
// The GEOMETRY lives in iconShapes.ts, which this file and the glide canvas
// sprites both read. Two painters, one source: glide's headerIcons API takes a
// function returning SVG *source* (a header icon can never be a React
// component), so the shapes are kept as data and each painter gets its own thin
// renderer. Hand-copying paths into a template literal instead would guarantee
// the header and the Fields panel eventually disagree about what a "date" is.
//
// Components only in this file β€” the constants/sprite builders sit next door so
// fast refresh keeps working (oxlint react/only-export-components).
// ---------------------------------------------------------------------------
import type { ReactNode } from "react";
import type { DisplayMode, FieldType, FolderIcon, FolderTone } from "./types";
import { DEFAULT_FOLDER_SHAPE, DEFAULT_FOLDER_TONE } from "./types";
import type { IconShape } from "./iconShapes";
import type { ChartKindKey } from "./iconShapes";
import {
CHART_KIND_SHAPES,
CHART_KIND_TONE,
FOLDER_SHAPE_PATHS,
folderTonePaint,
MODE_SHAPES,
TYPE_SHAPES,
} from "./iconShapes";
/**
* ⭐ WAVE 30 item 2 (R5) β€” `shapes` IS OPTIONAL, and the `?? []` is the whole point.
*
* Every caller reaches this through a TOTAL `Record<K, IconShape[]>`, so TypeScript promises the
* lookup always hits. That promise is only as good as the key: `type` arrives over the WIRE, and
* a field whose type is not in the union β€” a kind added server-side, a stored definition from
* before a rename β€” makes `TYPE_SHAPES[type]` `undefined` at runtime with tsc none the wiser.
* `undefined.map` is a TypeError, and until this wave a TypeError anywhere unmounted the entire
* React tree: a blank white page, which is exactly what a user calls "it crashes".
*
* ⚠ RANKED SECOND AND SHIPPED ANYWAY (R5). Every field in `odoo_relational.order_fields` carries
* a type present in `TYPE_SHAPES`, and the server's `UT_FIELD_TYPES` filters unknown types off
* the wire β€” so this is LATENT, not the proven cause of owner item 2. The reporter was a user and
* cannot be re-interviewed, so both candidate causes are fixed rather than one guessed at.
*
* An absent lookup now paints NO GLYPH, which is the honest degradation: the row still renders,
* still carries its label, and the missing mark says "this type has no icon" rather than taking
* the product down to say it.
*/
function Shapes({ shapes }: { shapes?: IconShape[] }) {
return (
<>
{(shapes ?? []).map((s, i) =>
s.fill ? (
<path key={i} d={s.d} fill="currentColor" />
) : (
<path
key={i}
d={s.d}
fill="none"
stroke="currentColor"
strokeWidth={1.35}
strokeLinecap="round"
strokeLinejoin="round"
/>
)
)}
</>
);
}
/** The field-type mark for panels/menus (I20). Decorative by default β€” the row
* it sits in already names the field; a screen reader reading "currency icon"
* before every label is noise. Pass `title` where the icon teaches the
* vocabulary (the Fields panel) so hovering it names the type.
*
* Wave-14 C-FLDSEL: `type` widened to carry the "measure" PSEUDO-KIND, which is not a
* `FieldType` (a measure column's real type is currency/int/pct β€” widening the union would
* let it leak into every switch that renders cells). It paints the CURRENCY mark, which is
* the decision the create-picker already shipped and its reason still holds: inventing a
* 17th glyph for a pseudo-kind would put a mark on screen that no column header ever wears.
* Every caller that used to spell that mapping out at the call site now stops. */
export function FieldTypeIcon({
type,
size = 14,
title,
}: {
type: FieldType | "measure";
size?: number;
title?: string;
}) {
return (
<svg
className="cg-type-icon"
width={size}
height={size}
viewBox="0 0 16 16"
aria-hidden={title ? undefined : true}
role={title ? "img" : undefined}
aria-label={title}
>
{title && <title>{title}</title>}
<Shapes shapes={TYPE_SHAPES[type === "measure" ? "currency" : type]} />
</svg>
);
}
/** The display-mode mark for the View switcher (I18). */
export function ModeIcon({ mode, size = 14 }: { mode: DisplayMode; size?: number }) {
return (
<svg className="cg-mode-icon" width={size} height={size} viewBox="0 0 16 16" aria-hidden>
<Shapes shapes={MODE_SHAPES[mode]} />
</svg>
);
}
/**
* Wave-9 I14 β€” the SAME mode mark, in a pastel tone (the flyout's "pastel-coloured icons").
*
* A separate component rather than a `tone` prop on ModeIcon: every existing ModeIcon call
* site inherits `currentColor` from the row it sits in, and quietly giving that a colour
* would repaint the toolbar switcher and every radio row along with the flyout. The geometry
* is still the one source β€” only the paint differs.
*/
export function ToneModeIcon({
mode,
tone,
size = 15,
}: {
mode: DisplayMode;
tone: FolderTone;
size?: number;
}) {
const paint = folderTonePaint(tone);
return (
<svg
className="cg-mode-icon cg-tone-icon"
width={size}
height={size}
viewBox="0 0 16 16"
aria-hidden
>
{/* R5: `?? []` for the same reason as `Shapes` β€” `mode` is a STORED display mode off
the wire, so a view saved under a kind this build no longer knows makes the lookup
undefined and `.map` a TypeError. */}
{(MODE_SHAPES[mode] ?? []).map((s, i) =>
s.fill ? (
<path key={i} d={s.d} fill={paint.stroke} />
) : (
<path
key={i}
d={s.d}
// OUTLINE ONLY β€” the Airtable register (owner, 2026-07-29: "it doesn't have a
// full color, only the linings"). This used to tint the first path with
// `paint.fill`, which made every mark read as a solid pastel BLOCK with the
// drawing hidden inside it: at 16px the grid's own lines were invisible against
// their own fill. The tone now lives entirely in the STROKE, which is the `-d`
// weight and is the only part that was ever carrying the shape.
fill="none"
stroke={paint.stroke}
strokeWidth={1.35}
strokeLinecap="round"
strokeLinejoin="round"
/>
)
)}
</svg>
);
}
/**
* Wave-9 I16 β€” the chart-kind mark, in its pastel tone. Same two-painter discipline as
* everything else here: the geometry is in iconShapes.ts, this is only a renderer.
*/
export function ChartKindIcon({
kind,
size = 15,
}: {
kind: ChartKindKey;
size?: number;
}) {
const paint = folderTonePaint(CHART_KIND_TONE[kind]);
return (
<svg
className="cg-mode-icon cg-tone-icon"
width={size}
height={size}
viewBox="0 0 16 16"
aria-hidden
>
{/* R5: same guard, same reason β€” `kind` comes from a saved chart definition. */}
{(CHART_KIND_SHAPES[kind] ?? []).map((s, i) => (
<path
key={i}
d={s.d}
// Same rule as ToneModeIcon: the FIRST path is the shape's body, so it takes the
// pastel fill and the rest stay strokes over it. Bar/line are pure strokes, so
// their first path has no interior to fill and this is a no-op for them.
fill={i === 0 &&
(kind === "area" || kind === "donut" || kind === "kpi" || kind === "table")
? paint.fill
: "none"}
stroke={paint.stroke}
strokeWidth={1.35}
strokeLinecap="round"
strokeLinejoin="round"
/>
))}
</svg>
);
}
// ---------------------------------------------------------------------------
// MENU ACTION ICONS (wave-7 item W4, moved here by wave-14 item 19).
//
// These lived privately in ColumnMenu.tsx, which was fine while the column menu was the only
// menu with icons. Item 19 puts icons on the VIEW menu too, and the alternative to one home
// was a second hand-drawn padlock that would drift from the first β€” the same argument
// iconShapes.ts records for the canvas/React split, one layer up.
//
// Exported as a COMPONENT taking a name, not as a record of nodes: a record would be a
// non-component export from a components file, which is the `only-export-components` warning
// the whole icons.tsx/iconShapes.ts split exists to avoid.
// ---------------------------------------------------------------------------
const mi = { width: 16, height: 16, viewBox: "0 0 16 16", fill: "none" } as const;
const miStroke = {
stroke: "currentColor",
strokeWidth: 1.3,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
const MENU_ICONS = {
duplicate: (
<>
<rect x="5.5" y="5.5" width="8" height="8" rx="1.5" {...miStroke} />
<path d="M10.5 2.5h-7a1 1 0 0 0-1 1v7" {...miStroke} />
</>
),
insertLeft: (
<>
<line x1="3" y1="2.5" x2="3" y2="13.5" {...miStroke} />
<path d="M13.5 8H6.5M9.2 5.3 6.5 8l2.7 2.7" {...miStroke} />
</>
),
insertRight: (
<>
<line x1="13" y1="2.5" x2="13" y2="13.5" {...miStroke} />
<path d="M2.5 8h7M6.8 5.3 9.5 8l-2.7 2.7" {...miStroke} />
</>
),
addEnd: <path d="M8 3v10M3 8h10" {...miStroke} />,
rename: <path d="m9.8 3.2 3 3L6.2 12.8l-3.6.6.6-3.6 6.6-6.6Z" {...miStroke} />,
description: <path d="M3 4.5h10M3 8h10M3 11.5h6" {...miStroke} />,
permissions: (
<>
<rect x="3.5" y="7" width="9" height="6" rx="1" {...miStroke} />
<path d="M5.5 7V5.2a2.5 2.5 0 0 1 5 0V7" {...miStroke} />
</>
),
/** The same padlock with an OPEN shackle β€” "unlock" as the picture of undoing the one above,
* rather than as a padlock with a slash, which every other `off` row already uses. */
unlock: (
<>
<rect x="3.5" y="7" width="9" height="6" rx="1" {...miStroke} />
<path d="M5.5 7V5.2a2.5 2.5 0 0 1 5 0" {...miStroke} />
</>
),
format: (
<>
<path d="M3 5h5.5M12.5 5H13M3 11h1M7 11h6" {...miStroke} />
<circle cx="10.5" cy="5" r="1.6" {...miStroke} />
<circle cx="4.9" cy="11" r="1.6" {...miStroke} />
</>
),
swap: (
<path
d="M3.5 6h9M10.2 3.7 12.5 6l-2.3 2.3M12.5 10.5h-9M5.8 8.2 3.5 10.5l2.3 2.3"
{...miStroke}
/>
),
sortAsc: (
<>
<path d="M4.5 3v10M4.5 13l-2-2M4.5 13l2-2" {...miStroke} />
<path d="M8.5 4.5h2M8.5 8h3.5M8.5 11.5h5" {...miStroke} />
</>
),
sortDesc: (
<>
<path d="M4.5 3v10M4.5 13l-2-2M4.5 13l2-2" {...miStroke} />
<path d="M8.5 4.5h5M8.5 8h3.5M8.5 11.5h2" {...miStroke} />
</>
),
off: (
<>
<circle cx="8" cy="8" r="5.5" {...miStroke} />
<line x1="4.4" y1="11.6" x2="11.6" y2="4.4" {...miStroke} />
</>
),
filter: <path d="M2.5 3.5h11L9.5 8.5v3.8L6.5 14V8.5L2.5 3.5Z" {...miStroke} />,
group: (
<>
<line x1="2.5" y1="4" x2="13.5" y2="4" {...miStroke} />
<line x1="5.5" y1="8" x2="13.5" y2="8" {...miStroke} />
<line x1="5.5" y1="12" x2="13.5" y2="12" {...miStroke} />
</>
),
pin: (
<>
<path d="M6 2.5h4l-.5 4L12 9v1H4V9l2.5-2.5L6 2.5Z" {...miStroke} />
<line x1="8" y1="10" x2="8" y2="13.5" {...miStroke} />
</>
),
unpin: (
<>
<path d="M6 2.5h4l-.5 4L12 9v1H4V9l2.5-2.5L6 2.5Z" {...miStroke} />
<line x1="8" y1="10" x2="8" y2="13.5" {...miStroke} />
<line x1="3" y1="13" x2="13" y2="3" {...miStroke} />
</>
),
hide: (
<>
<path d="M2.5 8s2-3.5 5.5-3.5S13.5 8 13.5 8s-2 3.5-5.5 3.5S2.5 8 2.5 8Z" {...miStroke} />
<circle cx="8" cy="8" r="1.5" {...miStroke} />
<line x1="3.5" y1="12.5" x2="12.5" y2="3.5" {...miStroke} />
</>
),
trash: <path d="M3.5 4.5h9M6.5 4.5V3h3v1.5M5 4.5l.6 8.5h4.8l.6-8.5" {...miStroke} />,
/** Item 19 β€” the view menu's own three. */
download: (
<>
<path d="M8 2.5v7M5.3 6.8 8 9.5l2.7-2.7" {...miStroke} />
<path d="M3 11v1.5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V11" {...miStroke} />
</>
),
cohortAdd: (
<>
<path d="M2.5 4h7M2.5 7.5h5M2.5 11h4" {...miStroke} />
<path d="M11.5 8.5v5M9 11h5" {...miStroke} />
</>
),
/**
* ⭐ W29 close-out β€” IMPORT's own mark, asked for by E and taken by the integrator while
* W29-T78 had this menu open anyway.
*
* `download` MIRRORED: the same tray, the same stem, the arrowhead at the top instead of the
* bottom β€” so Export and Import read as one gesture in two directions rather than as two
* unrelated glyphs. It replaces a borrowed `cohortAdd` (a LIST with a plus), which named
* "add to a cohort" on a row that imports a spreadsheet: a borrowed mark is a small lie that
* a menu repeats every time it opens.
*/
upload: (
<>
<path d="M8 9.5v-7M5.3 5.2 8 2.5l2.7 2.7" {...miStroke} />
<path d="M3 11v1.5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V11" {...miStroke} />
</>
),
} as const;
export type MenuIconName = keyof typeof MENU_ICONS;
/** One 16px menu glyph, by name. */
export function MenuIcon({ name }: { name: MenuIconName }) {
return (
<svg {...mi} aria-hidden>
{MENU_ICONS[name]}
</svg>
);
}
/**
* One menu action row: icon + label. The icon column is fixed-width so labels align.
*
* `icon` is a NAME by default; a node is accepted for the rows whose mark is not part of this
* vocabulary (the view menu's "Lock as Kanban" wears the KANBAN mark, because the picture of
* what gets frozen says more than a second padlock).
*/
export function MenuLabel({
icon,
text,
}: {
icon: MenuIconName | ReactNode;
text: string;
}) {
return (
<>
<span className="cg-mi-ic" aria-hidden>
{typeof icon === "string" ? <MenuIcon name={icon as MenuIconName} /> : icon}
</span>
<span className="cg-mi-text">{text}</span>
</>
);
}
/**
* Wave-14 item 12 β€” the HIDE-FIELDS mark: an eye with a slash through it.
*
* The toolbar button that opens the panel wore `IconFields` (three vertical column rules),
* which named the NOUN and left the verb to be guessed β€” the same complaint that renamed the
* panel "Hide fields" in the first place. A struck-through eye says what the control does.
*
* It lives here rather than beside the toolbar's private `Icon*` set because it has two
* consumers: the toolbar button, and the copy-configuration modal's "Hidden fields" row
* (item 13), which must wear the SAME mark for the same concept. Paths are inline for the
* same reason `LockMark`'s are: `iconShapes.ts` holds the geometry the glide CANVAS also
* paints, and no header ever draws this one.
*/
export function EyeOffIcon({ size = 14 }: { size?: number }) {
return (
<svg
className="cg-eyeoff-icon"
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
aria-hidden
>
<path
d="M2.5 8s2-3.5 5.5-3.5S13.5 8 13.5 8s-2 3.5-5.5 3.5S2.5 8 2.5 8Z"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.6 8a1.6 1.6 0 1 1-3.2 0 1.6 1.6 0 0 1 3.2 0Z"
stroke="currentColor"
strokeWidth={1.4}
strokeLinejoin="round"
/>
<path
d="M3.3 12.7 12.7 3.3"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
/>
</svg>
);
}
/**
* Wave-9 contract C3 (I12) β€” the "this view's mode is frozen" mark. Drawn from the same
* stroke vocabulary as everything else here; deliberately small and muted, because it
* annotates the row rather than competing with the mode icon that leads it.
*/
export function LockMark({ size = 11 }: { size?: number }) {
return (
<svg
className="cg-lock-mark"
width={size}
height={size}
viewBox="0 0 16 16"
aria-hidden
>
<path
d="M4.2 7.2h7.6v5.6H4.2z"
fill="none"
stroke="currentColor"
strokeWidth={1.35}
strokeLinejoin="round"
/>
<path
d="M5.9 7.2V5.5a2.1 2.1 0 0 1 4.2 0v1.7"
fill="none"
stroke="currentColor"
strokeWidth={1.35}
strokeLinecap="round"
/>
</svg>
);
}
/**
* Wave-9 contract C5 (I15) β€” a folder's chosen mark. `icon` absent renders the DEFAULT
* folder shape in grey, which is every pre-wave-9 folder and is exactly what I14 asks for
* ("existing folders get the folder icon").
*/
export function FolderMark({
icon,
size = 14,
}: {
icon?: FolderIcon;
size?: number;
}) {
const shape = icon?.shape ?? DEFAULT_FOLDER_SHAPE;
const paint = folderTonePaint(icon?.tone ?? DEFAULT_FOLDER_TONE);
return (
<svg
className="cg-folder-mark"
width={size}
height={size}
viewBox="0 0 16 16"
aria-hidden
>
{/* R5: same guard β€” a folder's shape is stored in nav prefs. */}
{(FOLDER_SHAPE_PATHS[shape] ?? []).map((s, i) => (
<path
key={i}
d={s.d}
// Outline only, same ruling as ToneModeIcon β€” a filled folder sitting directly
// above outlined view rows would make the rail read as two icon vocabularies.
fill="none"
stroke={paint.stroke}
strokeWidth={1.35}
strokeLinecap="round"
strokeLinejoin="round"
/>
))}
</svg>
);
}