diff --git a/frontend/editor/src/portal/components/pipelines/PipelineIconPicker.css b/frontend/editor/src/core/ui/IconPicker.css similarity index 67% rename from frontend/editor/src/portal/components/pipelines/PipelineIconPicker.css rename to frontend/editor/src/core/ui/IconPicker.css index e0cc321f0e..2c4c5cb9ef 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineIconPicker.css +++ b/frontend/editor/src/core/ui/IconPicker.css @@ -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); } diff --git a/frontend/editor/src/core/ui/IconPicker.stories.tsx b/frontend/editor/src/core/ui/IconPicker.stories.tsx new file mode 100644 index 0000000000..8fe0534867 --- /dev/null +++ b/frontend/editor/src/core/ui/IconPicker.stories.tsx @@ -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: }, + { key: "lock", label: "Lock", node: }, + { key: "label", label: "Label", node: }, + { key: "layers", label: "Layers", node: }, + { key: "folder", label: "Folder", node: }, + { key: "bolt", label: "Bolt", node: }, + { + key: "schedule", + label: "Schedule", + node: , + }, + { + key: "sparkle", + label: "Sparkle", + node: , + }, +]; + +const meta: Meta = { + title: "Primitives/IconPicker", + component: IconPicker, + tags: ["autodocs"], + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +/** 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 ( + + ); + }, +}; diff --git a/frontend/editor/src/core/ui/IconPicker.tsx b/frontend/editor/src/core/ui/IconPicker.tsx new file mode 100644 index 0000000000..e3c7a98128 --- /dev/null +++ b/frontend/editor/src/core/ui/IconPicker.tsx @@ -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 ( + + + + {selected?.node} + + + +
+ {options.map((option) => { + const isSelected = option.key === value; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/frontend/editor/src/core/ui/index.ts b/frontend/editor/src/core/ui/index.ts index a0ed8e3118..94a14404da 100644 --- a/frontend/editor/src/core/ui/index.ts +++ b/frontend/editor/src/core/ui/index.ts @@ -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"; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.tsx b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.tsx index 8d56226a0a..d2629f976a 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.tsx @@ -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({ - + - + {renaming ? ( = { - title: "Portal/Pipelines/PipelineIconPicker", - component: PipelineIconPicker, - parameters: { layout: "centered" }, -}; -export default meta; -type Story = StoryObj; - -/** 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 ; - }, -}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineIconPicker.tsx b/frontend/editor/src/portal/components/pipelines/PipelineIconPicker.tsx deleted file mode 100644 index 4a636ec55c..0000000000 --- a/frontend/editor/src/portal/components/pipelines/PipelineIconPicker.tsx +++ /dev/null @@ -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 ( - - - - {pipelineIcon(value, "1.125rem")} - - - -
- {PIPELINE_ICON_KEYS.map((key) => { - const selected = key === value; - return ( - - ); - })} -
-
-
- ); -} diff --git a/frontend/editor/src/portal/components/pipelines/pipelineIcon.tsx b/frontend/editor/src/portal/components/pipelines/pipelineIcon.tsx index bc6a3c71a0..189a0deb64 100644 --- a/frontend/editor/src/portal/components/pipelines/pipelineIcon.tsx +++ b/frontend/editor/src/portal/components/pipelines/pipelineIcon.tsx @@ -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 = { fontSize }; return ; } + +/** 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") }), +);