Create primitive for IconPicker

This commit is contained in:
James Brunton
2026-08-27 11:39:03 +01:00
parent 48ae15a06b
commit 61f86cfa50
9 changed files with 162 additions and 95 deletions
@@ -1,11 +1,11 @@
.portal-icon-picker__grid {
.sui-icon-picker__grid {
display: grid;
grid-template-columns: repeat(5, 2.25rem);
gap: 0.25rem;
padding: 0.375rem;
}
.portal-icon-picker__option {
.sui-icon-picker__option {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -18,13 +18,14 @@
cursor: pointer;
}
.portal-icon-picker__option:hover {
.sui-icon-picker__option:hover {
background: var(--c-hover);
color: var(--c-text);
}
.portal-icon-picker__option--selected {
.sui-icon-picker__option--selected {
border-color: var(--c-primary);
background: var(--c-primary-subtle);
color: var(--c-accent-fg, var(--c-primary));
/* Accent tuned to clear the contrast floor on the tinted chip (light + dark). */
color: var(--c-accent-text);
}
@@ -0,0 +1,55 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined";
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import BoltOutlinedIcon from "@mui/icons-material/BoltOutlined";
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined";
import { IconPicker, type IconPickerOption } from "@app/ui/IconPicker";
const sx = { fontSize: "1.25rem" } as const;
const OPTIONS: IconPickerOption[] = [
{ key: "shield", label: "Shield", node: <ShieldOutlinedIcon sx={sx} /> },
{ key: "lock", label: "Lock", node: <LockOutlinedIcon sx={sx} /> },
{ key: "label", label: "Label", node: <LabelOutlinedIcon sx={sx} /> },
{ key: "layers", label: "Layers", node: <LayersOutlinedIcon sx={sx} /> },
{ key: "folder", label: "Folder", node: <FolderOutlinedIcon sx={sx} /> },
{ key: "bolt", label: "Bolt", node: <BoltOutlinedIcon sx={sx} /> },
{
key: "schedule",
label: "Schedule",
node: <ScheduleOutlinedIcon sx={sx} />,
},
{
key: "sparkle",
label: "Sparkle",
node: <AutoAwesomeOutlinedIcon sx={sx} />,
},
];
const meta: Meta<typeof IconPicker> = {
title: "Primitives/IconPicker",
component: IconPicker,
tags: ["autodocs"],
parameters: { layout: "centered" },
};
export default meta;
type Story = StoryObj<typeof IconPicker>;
/** Click the glyph to open the grid and choose a new icon from the supplied set. */
export const Default: Story = {
render: () => {
const [value, setValue] = useState("shield");
return (
<IconPicker
value={value}
onChange={setValue}
options={OPTIONS}
ariaLabel="Icon"
/>
);
},
};
@@ -0,0 +1,78 @@
import { useState, type ReactNode } from "react";
import { ActionIcon } from "@app/ui/ActionIcon";
import { Dropdown } from "@app/ui/Dropdown";
import "@app/ui/IconPicker.css";
export interface IconPickerOption {
/** Stable identifier stored as the picked value. */
key: string;
/** The glyph to show, sized by the caller. */
node: ReactNode;
/** Accessible name for this option (falls back to the key). */
label?: string;
}
export interface IconPickerProps {
/** The picked option's key. */
value: string;
onChange: (key: string) => void;
/** The icons to choose from, in display order. The caller supplies the set. */
options: IconPickerOption[];
/** Accessible name for the trigger (e.g. "Icon"). */
ariaLabel: string;
size?: "sm" | "md" | "lg";
}
/**
* Pick an icon from a caller-supplied set. The chosen glyph is the trigger; the menu is a grid. The
* icon set is injected (via {@link options}) rather than baked in, so any surface - a pipeline, a
* watched folder, an automation - passes its own vocabulary and shares the one control.
*/
export function IconPicker({
value,
onChange,
options,
ariaLabel,
size = "sm",
}: IconPickerProps) {
// Controlled so a grid button (not a Dropdown.Item) can close the menu on pick.
const [open, setOpen] = useState(false);
const selected = options.find((option) => option.key === value) ?? options[0];
function pick(key: string) {
onChange(key);
setOpen(false);
}
return (
<Dropdown.Root open={open} onOpenChange={setOpen} align="start">
<Dropdown.Trigger>
<ActionIcon variant="secondary" size={size} aria-label={ariaLabel}>
{selected?.node}
</ActionIcon>
</Dropdown.Trigger>
<Dropdown.Menu>
<div className="sui-icon-picker__grid">
{options.map((option) => {
const isSelected = option.key === value;
return (
<button
key={option.key}
type="button"
className={
"sui-icon-picker__option" +
(isSelected ? " sui-icon-picker__option--selected" : "")
}
aria-label={option.label ?? option.key}
aria-pressed={isSelected}
onClick={() => pick(option.key)}
>
{option.node}
</button>
);
})}
</div>
</Dropdown.Menu>
</Dropdown.Root>
);
}
+1
View File
@@ -11,6 +11,7 @@ export * from "@app/ui/ProgressBar";
export * from "@app/ui/MetricCard";
export * from "@app/ui/NodeCard";
export * from "@app/ui/OptionCard";
export * from "@app/ui/IconPicker";
export * from "@app/ui/NavItem";
export * from "@app/ui/NavSurface";
export * from "@app/ui/Surface";
@@ -1,8 +1,8 @@
import { useTranslation } from "react-i18next";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import { ActionIcon, Button, Input } from "@app/ui";
import { ActionIcon, Button, IconPicker, Input } from "@app/ui";
import { PipelineBlockerTooltip } from "@portal/components/pipelines/PipelineBlockerTooltip";
import { PipelineIconPicker } from "@portal/components/pipelines/PipelineIconPicker";
import { PIPELINE_ICON_OPTIONS } from "@portal/components/pipelines/pipelineIcon";
import { PipelinePolicyControls } from "@portal/components/pipelines/PipelinePolicyControls";
import "@portal/components/pipelines/PipelineCreateHeader.css";
@@ -63,7 +63,12 @@ export function PipelineCreateHeader({
<ArrowBackRoundedIcon style={{ fontSize: "1.25rem" }} />
</ActionIcon>
<PipelineIconPicker value={icon} onChange={onIconChange} />
<IconPicker
value={icon}
onChange={onIconChange}
options={PIPELINE_ICON_OPTIONS}
ariaLabel={t("portal.pipelines.builder.icon.label")}
/>
<Input
className="portal-pipeline-create-header__name"
@@ -8,9 +8,9 @@ import PowerSettingsNewRoundedIcon from "@mui/icons-material/PowerSettingsNewRou
import ReplayRoundedIcon from "@mui/icons-material/ReplayRounded";
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
import MoreHorizRoundedIcon from "@mui/icons-material/MoreHorizRounded";
import { ActionIcon, Button, Dropdown, Input } from "@app/ui";
import { ActionIcon, Button, Dropdown, IconPicker, Input } from "@app/ui";
import { PipelineBlockerTooltip } from "@portal/components/pipelines/PipelineBlockerTooltip";
import { PipelineIconPicker } from "@portal/components/pipelines/PipelineIconPicker";
import { PIPELINE_ICON_OPTIONS } from "@portal/components/pipelines/pipelineIcon";
import { PipelinePolicyControls } from "@portal/components/pipelines/PipelinePolicyControls";
import "@portal/components/pipelines/PipelineEditHeader.css";
@@ -130,7 +130,12 @@ export function PipelineEditHeader({
<ArrowBackRoundedIcon style={{ fontSize: "1.25rem" }} />
</ActionIcon>
<PipelineIconPicker value={icon} onChange={onIconChange} />
<IconPicker
value={icon}
onChange={onIconChange}
options={PIPELINE_ICON_OPTIONS}
ariaLabel={t("portal.pipelines.builder.icon.label")}
/>
{renaming ? (
<Input
@@ -1,19 +0,0 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PipelineIconPicker } from "@portal/components/pipelines/PipelineIconPicker";
const meta: Meta<typeof PipelineIconPicker> = {
title: "Portal/Pipelines/PipelineIconPicker",
component: PipelineIconPicker,
parameters: { layout: "centered" },
};
export default meta;
type Story = StoryObj<typeof PipelineIconPicker>;
/** Live picker: click the glyph to open the grid and choose a new icon. */
export const Default: Story = {
render: () => {
const [icon, setIcon] = useState("route");
return <PipelineIconPicker value={icon} onChange={setIcon} />;
},
};
@@ -1,65 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Dropdown } from "@app/ui";
import {
PIPELINE_ICON_KEYS,
pipelineIcon,
} from "@portal/components/pipelines/pipelineIcon";
import "@portal/components/pipelines/PipelineIconPicker.css";
interface PipelineIconPickerProps {
/** Current icon key (may be a category id or empty; resolved by pipelineIcon). */
value: string;
onChange: (key: string) => void;
}
/** Picks the pipeline's icon from a small set. The chosen glyph is the trigger; the menu is a grid. */
export function PipelineIconPicker({
value,
onChange,
}: PipelineIconPickerProps) {
const { t } = useTranslation();
// Controlled so a grid button (not a Dropdown.Item) can close the menu on pick.
const [open, setOpen] = useState(false);
function pick(key: string) {
onChange(key);
setOpen(false);
}
return (
<Dropdown.Root open={open} onOpenChange={setOpen} align="start">
<Dropdown.Trigger>
<ActionIcon
variant="secondary"
size="sm"
aria-label={t("portal.pipelines.builder.icon.label")}
>
{pipelineIcon(value, "1.125rem")}
</ActionIcon>
</Dropdown.Trigger>
<Dropdown.Menu>
<div className="portal-icon-picker__grid">
{PIPELINE_ICON_KEYS.map((key) => {
const selected = key === value;
return (
<button
key={key}
type="button"
className={
"portal-icon-picker__option" +
(selected ? " portal-icon-picker__option--selected" : "")
}
aria-label={key}
aria-pressed={selected}
onClick={() => pick(key)}
>
{pipelineIcon(key, "1.25rem")}
</button>
);
})}
</div>
</Dropdown.Menu>
</Dropdown.Root>
);
}
@@ -4,6 +4,7 @@
// pipeline that only stores its categoryId still resolves to the matching glyph.
import type { ReactNode } from "react";
import type { IconPickerOption } from "@app/ui";
import type { SxProps, Theme } from "@mui/material";
import { PIPELINE_ROUTE_GLYPH } from "@portal/components/icons";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
@@ -100,3 +101,8 @@ export function pipelineIcon(
const sx: SxProps<Theme> = { fontSize };
return <Icon sx={sx} className={className} />;
}
/** The pipeline's icon vocabulary as options for the shared SUI `IconPicker`. */
export const PIPELINE_ICON_OPTIONS: IconPickerOption[] = PIPELINE_ICON_KEYS.map(
(key) => ({ key, label: key, node: pipelineIcon(key, "1.25rem") }),
);