refactor(ui): extract shared ColorGridPicker for the accent picker
Per review on #7009: move the accent swatch grid out of GeneralSection into a reusable core/ui/ColorGridPicker (swatch trigger + popover + radiogroup grid with an optional default/unset icon cell + a footer slot). GeneralSection now renders <ColorGridPicker> and passes the presets, default option and the custom-colour ColorInput as the footer. Removes the three raw-<button> eslint-disables from GeneralSection; the raw elements now live in the DS layer (core/ui/), which the lint rule exempts. Markup/styles preserved verbatim, so the control is unchanged visually.
This commit is contained in:
+23
-158
@@ -11,11 +11,11 @@ import {
|
||||
Group,
|
||||
Anchor,
|
||||
Badge,
|
||||
Popover,
|
||||
} from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { ColorInput } from "@app/ui/ColorInput";
|
||||
import { ColorGridPicker } from "@app/ui/ColorGridPicker";
|
||||
import { SegmentedControl } from "@app/ui/SegmentedControl";
|
||||
import { clampAccentChoice } from "@app/utils/customPrimary";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -84,7 +84,7 @@ interface GeneralSectionProps {
|
||||
desktopUpdateMode?: DesktopUpdateModeControl;
|
||||
}
|
||||
|
||||
/** Accent-colour picker: a swatch trigger opening a 3×5 grid — a "Default" icon cell + 14 accents — plus a "Custom" picker below. */
|
||||
/** Accent-colour picker: a swatch grid (a "Default" icon cell + 14 accents) with a "Custom" picker below, via the shared ColorGridPicker. */
|
||||
function AccentSwatchDropdown({
|
||||
value,
|
||||
onChange,
|
||||
@@ -95,8 +95,6 @@ function AccentSwatchDropdown({
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const isDefault = value === DEFAULT_ACCENT;
|
||||
// Resolve the "default" sentinel to the blue it maps to so the ColorInput shows a real colour.
|
||||
const asHex = (v: string) =>
|
||||
v === DEFAULT_ACCENT ? DEFAULT_ACCENT_COLOR : v;
|
||||
@@ -104,158 +102,25 @@ function AccentSwatchDropdown({
|
||||
const [draft, setDraft] = useState(() => asHex(value));
|
||||
useEffect(() => setDraft(asHex(value)), [value]);
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
<ColorGridPicker
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
colors={THEME_ACCENT_PRESETS}
|
||||
ariaLabel={ariaLabel}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
shadow="md"
|
||||
radius="md"
|
||||
withArrow
|
||||
>
|
||||
<Popover.Target>
|
||||
{/* eslint-disable-next-line no-restricted-syntax -- swatch-trigger control, not a text button; the shared Button's variants/padding don't fit */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.25rem 0.5rem",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid var(--c-border)",
|
||||
background: "var(--c-input-bg)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{isDefault ? (
|
||||
// Default is not a colour — show the icon chip so the trigger reads as unset, not a hue.
|
||||
<span
|
||||
style={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
width: "1.25rem",
|
||||
height: "1.25rem",
|
||||
borderRadius: "5px",
|
||||
background: "var(--c-surface-raised)",
|
||||
boxShadow: "inset 0 0 0 1px var(--c-border)",
|
||||
}}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="star-rounded"
|
||||
width="0.875rem"
|
||||
height="0.875rem"
|
||||
style={{ color: "var(--c-text-muted)" }}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
width: "1.25rem",
|
||||
height: "1.25rem",
|
||||
borderRadius: "5px",
|
||||
background: value,
|
||||
boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.15)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<LocalIcon
|
||||
icon="expand-more-rounded"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
style={{ color: "var(--c-text-subtle)" }}
|
||||
/>
|
||||
</button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(5, 1.75rem)",
|
||||
gap: "0.6875rem",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{/* Default — an icon chip for the neutral theme (surfaces untinted, blue buttons); stores the sentinel, a separate state from the colour swatches. */}
|
||||
{/* eslint-disable-next-line no-restricted-syntax -- an icon chip, not a text button; the shared Button's variants don't fit */}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isDefault}
|
||||
aria-label={t("settings.general.themeAccentDefault", "Default")}
|
||||
title={t(
|
||||
"settings.general.themeAccentDefaultHint",
|
||||
"Default theme (recommended)",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(DEFAULT_ACCENT);
|
||||
setOpened(false);
|
||||
}}
|
||||
style={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
width: "1.75rem",
|
||||
height: "1.75rem",
|
||||
padding: 0,
|
||||
borderRadius: "8px",
|
||||
cursor: "pointer",
|
||||
background: "var(--c-surface-raised)",
|
||||
border: "1px solid var(--c-border)",
|
||||
outline: isDefault ? "2px solid var(--c-text)" : "none",
|
||||
outlineOffset: "2px",
|
||||
}}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="star-rounded"
|
||||
width="1.125rem"
|
||||
height="1.125rem"
|
||||
style={{ color: "var(--c-text-muted)" }}
|
||||
/>
|
||||
</button>
|
||||
{THEME_ACCENT_PRESETS.map((color) => {
|
||||
const selected = value.toLowerCase() === color.toLowerCase();
|
||||
return (
|
||||
// eslint-disable-next-line no-restricted-syntax -- a colour swatch, not a text button; the shared Button's variants don't fit
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={color}
|
||||
onClick={() => {
|
||||
onChange(color);
|
||||
setOpened(false);
|
||||
}}
|
||||
style={{
|
||||
width: "1.75rem",
|
||||
height: "1.75rem",
|
||||
padding: 0,
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
background: color,
|
||||
cursor: "pointer",
|
||||
outline: selected ? "2px solid var(--c-text)" : "none",
|
||||
outlineOffset: "2px",
|
||||
boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.12)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Custom colour — below the presets; our ColorInput, clamped off white/grey/black. */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
paddingTop: "0.5rem",
|
||||
borderTop: "1px solid var(--c-border-subtle)",
|
||||
}}
|
||||
>
|
||||
defaultOption={{
|
||||
// Default is not a colour — an icon chip signalling the neutral theme
|
||||
// (surfaces untinted, blue buttons); stores the sentinel.
|
||||
value: DEFAULT_ACCENT,
|
||||
icon: "star-rounded",
|
||||
label: t("settings.general.themeAccentDefault", "Default"),
|
||||
hint: t(
|
||||
"settings.general.themeAccentDefaultHint",
|
||||
"Default theme (recommended)",
|
||||
),
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Text size="xs" c="dimmed" mb={6}>
|
||||
{t(
|
||||
"settings.general.themeAccentCustomHint",
|
||||
@@ -276,9 +141,9 @@ function AccentSwatchDropdown({
|
||||
)}
|
||||
popoverProps={{ withinPortal: false }}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Popover } from "@mantine/core";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
/**
|
||||
* A compact swatch trigger that opens a grid of selectable colours (a
|
||||
* radiogroup), with an optional leading "default/unset" cell shown as an icon
|
||||
* chip rather than a hue, and an optional footer slot (e.g. a custom-colour
|
||||
* input). Generic DS control — the caller supplies the colours and semantics.
|
||||
*/
|
||||
export interface ColorGridDefaultOption {
|
||||
/** The value stored when this cell is chosen (a sentinel, not a colour). */
|
||||
value: string;
|
||||
/** LocalIcon name shown in the chip. */
|
||||
icon: string;
|
||||
label: string;
|
||||
/** Native title tooltip. */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface ColorGridPickerProps {
|
||||
/** Current value: a colour from `colors`, or `defaultOption.value`. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Preset colours (any CSS colour string). */
|
||||
colors: string[];
|
||||
/** Accessible name for the trigger and the radiogroup. */
|
||||
ariaLabel: string;
|
||||
/** Grid columns; defaults to 5. */
|
||||
columns?: number;
|
||||
/** Optional leading "default/unset" cell. */
|
||||
defaultOption?: ColorGridDefaultOption;
|
||||
/** Optional content below the grid (e.g. a custom-colour picker). */
|
||||
footer?: ReactNode;
|
||||
/** Popover z-index, for use inside modals. */
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
const SWATCH = "1.75rem";
|
||||
|
||||
export function ColorGridPicker({
|
||||
value,
|
||||
onChange,
|
||||
colors,
|
||||
ariaLabel,
|
||||
columns = 5,
|
||||
defaultOption,
|
||||
footer,
|
||||
zIndex,
|
||||
}: ColorGridPickerProps) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const isDefault = !!defaultOption && value === defaultOption.value;
|
||||
const select = (v: string) => {
|
||||
onChange(v);
|
||||
setOpened(false);
|
||||
};
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
zIndex={zIndex}
|
||||
shadow="md"
|
||||
radius="md"
|
||||
withArrow
|
||||
>
|
||||
<Popover.Target>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.25rem 0.5rem",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid var(--c-border)",
|
||||
background: "var(--c-input-bg)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{defaultOption && value === defaultOption.value ? (
|
||||
<span
|
||||
style={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
width: "1.25rem",
|
||||
height: "1.25rem",
|
||||
borderRadius: "5px",
|
||||
background: "var(--c-surface-raised)",
|
||||
boxShadow: "inset 0 0 0 1px var(--c-border)",
|
||||
}}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={defaultOption.icon}
|
||||
width="0.875rem"
|
||||
height="0.875rem"
|
||||
style={{ color: "var(--c-text-muted)" }}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
width: "1.25rem",
|
||||
height: "1.25rem",
|
||||
borderRadius: "5px",
|
||||
background: value,
|
||||
boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.15)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<LocalIcon
|
||||
icon="expand-more-rounded"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
style={{ color: "var(--c-text-subtle)" }}
|
||||
/>
|
||||
</button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${columns}, ${SWATCH})`,
|
||||
gap: "0.6875rem",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{defaultOption && (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isDefault}
|
||||
aria-label={defaultOption.label}
|
||||
title={defaultOption.hint}
|
||||
onClick={() => select(defaultOption.value)}
|
||||
style={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
width: SWATCH,
|
||||
height: SWATCH,
|
||||
padding: 0,
|
||||
borderRadius: "8px",
|
||||
cursor: "pointer",
|
||||
background: "var(--c-surface-raised)",
|
||||
border: "1px solid var(--c-border)",
|
||||
outline: isDefault ? "2px solid var(--c-text)" : "none",
|
||||
outlineOffset: "2px",
|
||||
}}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={defaultOption.icon}
|
||||
width="1.125rem"
|
||||
height="1.125rem"
|
||||
style={{ color: "var(--c-text-muted)" }}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{colors.map((color) => {
|
||||
const selected = value.toLowerCase() === color.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={color}
|
||||
onClick={() => select(color)}
|
||||
style={{
|
||||
width: SWATCH,
|
||||
height: SWATCH,
|
||||
padding: 0,
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
background: color,
|
||||
cursor: "pointer",
|
||||
outline: selected ? "2px solid var(--c-text)" : "none",
|
||||
outlineOffset: "2px",
|
||||
boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.12)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{footer && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
paddingTop: "0.5rem",
|
||||
borderTop: "1px solid var(--c-border-subtle)",
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user