refactor(site): replace the banner color picker (#30308)

> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell.

Replace the announcement banner dialog's `react-color` palette and hue
slider with a local `BannerColorPicker`, and drop the `react-color`
dependency. The picker offers preset swatches and a hex field; the hue
slider and its toggle are removed.

Screenshots below predate the slider removal.

| Old | New |
| --- | --- |
| <img width="537" height="518" alt="image"
src="https://github.com/user-attachments/assets/6ffb6052-70b5-4972-ac63-0d6d25909e19"
/> | <img width="541" height="534" alt="image"
src="https://github.com/user-attachments/assets/082c70db-605f-479c-8fb6-8fab4a3eb142"
/> |
| <img width="562" height="513" alt="image"
src="https://github.com/user-attachments/assets/9f48e626-6183-45a4-8af0-2cfb75390bd8"
/> | `...gone...` |
This commit is contained in:
Jake Howell
2026-10-03 16:59:04 +10:00
committed by GitHub
parent 5c9d9db768
commit 70bc2e5540
6 changed files with 183 additions and 157 deletions
-2
View File
@@ -90,7 +90,6 @@
"pretty-bytes": "6.1.1",
"radix-ui": "1.6.7",
"react": "19.2.8",
"react-color": "2.19.3",
"react-confetti": "6.4.0",
"react-day-picker": "9.14.0",
"react-dom": "19.2.8",
@@ -146,7 +145,6 @@
"@types/node": "20.19.41",
"@types/novnc__novnc": "1.5.0",
"@types/react": "19.2.18",
"@types/react-color": "3.0.13",
"@types/react-dom": "19.2.5",
"@types/react-syntax-highlighter": "15.5.13",
"@types/react-virtualized-auto-sizer": "1.0.8",
-70
View File
@@ -166,9 +166,6 @@ importers:
react:
specifier: 19.2.8
version: 19.2.8
react-color:
specifier: 2.19.3
version: 2.19.3(react@19.2.8)
react-confetti:
specifier: 6.4.0
version: 6.4.0(react@19.2.8)
@@ -329,9 +326,6 @@ importers:
'@types/react':
specifier: 19.2.18
version: 19.2.18
'@types/react-color':
specifier: 3.0.13
version: 3.0.13(@types/react@19.2.18)
'@types/react-dom':
specifier: 19.2.5
version: 19.2.5(@types/react@19.2.18)
@@ -921,11 +915,6 @@ packages:
'@iconify/utils@3.1.7':
resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==, tarball: https://registry.npmjs.org/@iconify/utils/-/utils-3.1.7.tgz}
'@icons/material@0.2.4':
resolution: {integrity: sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==, tarball: https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz}
peerDependencies:
react: '*'
'@inquirer/confirm@3.2.0':
resolution: {integrity: sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw==, tarball: https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.2.0.tgz}
engines: {node: '>=18'}
@@ -3376,11 +3365,6 @@ packages:
'@types/range-parser@1.2.4':
resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==, tarball: https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz}
'@types/react-color@3.0.13':
resolution: {integrity: sha512-2c/9FZ4ixC5T3JzN0LP5Cke2Mf0MKOP2Eh0NPDPWmuVH3NjPyhEjqNMQpN1Phr5m74egAy+p2lYNAFrX1z9Yrg==, tarball: https://registry.npmjs.org/@types/react-color/-/react-color-3.0.13.tgz}
peerDependencies:
'@types/react': '*'
'@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==, tarball: https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz}
peerDependencies:
@@ -3404,11 +3388,6 @@ packages:
'@types/react@19.2.18':
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==, tarball: https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz}
'@types/reactcss@1.2.13':
resolution: {integrity: sha512-gi3S+aUi6kpkF5vdhUsnkwbiSEIU/BEJyD7kBy2SudWBUuKmJk8AQKE0OVcQQeEy40Azh0lV6uynxlikYIJuwg==, tarball: https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.13.tgz}
peerDependencies:
'@types/react': '*'
'@types/resolve@1.20.6':
resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==, tarball: https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz}
@@ -5151,9 +5130,6 @@ packages:
engines: {node: '>= 20'}
hasBin: true
material-colors@1.2.6:
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==, tarball: https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==, tarball: https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz}
engines: {node: '>= 0.4'}
@@ -5741,11 +5717,6 @@ packages:
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==, tarball: https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz}
engines: {node: '>= 0.8'}
react-color@2.19.3:
resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==, tarball: https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz}
peerDependencies:
react: '*'
react-confetti@6.4.0:
resolution: {integrity: sha512-5MdGUcqxrTU26I2EU7ltkWPwxvucQTuqMm8dUz72z2YMqTD6s9vMcDUysk7n9jnC+lXuCPeJJ7Knf98VEYE9Rg==, tarball: https://registry.npmjs.org/react-confetti/-/react-confetti-6.4.0.tgz}
engines: {node: '>=16'}
@@ -5890,11 +5861,6 @@ packages:
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==, tarball: https://registry.npmjs.org/react/-/react-19.2.8.tgz}
engines: {node: '>=0.10.0'}
reactcss@1.2.3:
resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==, tarball: https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz}
peerDependencies:
react: '*'
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==, tarball: https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz}
@@ -6313,9 +6279,6 @@ packages:
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==, tarball: https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz}
tinycolor2@1.6.0:
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==, tarball: https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz}
tinyexec@1.3.0:
resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==, tarball: https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz}
engines: {node: '>=18'}
@@ -7308,10 +7271,6 @@ snapshots:
'@iconify/types': 2.0.0
import-meta-resolve: 4.2.0
'@icons/material@0.2.4(react@19.2.8)':
dependencies:
react: 19.2.8
'@inquirer/confirm@3.2.0':
dependencies:
'@inquirer/core': 9.2.1
@@ -9591,11 +9550,6 @@ snapshots:
'@types/range-parser@1.2.4': {}
'@types/react-color@3.0.13(@types/react@19.2.18)':
dependencies:
'@types/react': 19.2.18
'@types/reactcss': 1.2.13(@types/react@19.2.18)
'@types/react-dom@18.3.7(@types/react@19.2.18)':
dependencies:
'@types/react': 19.2.18
@@ -9623,10 +9577,6 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/reactcss@1.2.13(@types/react@19.2.18)':
dependencies:
'@types/react': 19.2.18
'@types/resolve@1.20.6': {}
'@types/semver@7.8.0': {}
@@ -11438,8 +11388,6 @@ snapshots:
marked@17.0.5: {}
material-colors@1.2.6: {}
math-intrinsics@1.1.0: {}
mdast-util-find-and-replace@3.0.2:
@@ -12385,17 +12333,6 @@ snapshots:
iconv-lite: 0.4.24
unpipe: 1.0.0
react-color@2.19.3(react@19.2.8):
dependencies:
'@icons/material': 0.2.4(react@19.2.8)
lodash: 4.18.1
lodash-es: 4.18.1
material-colors: 1.2.6
prop-types: 15.8.1
react: 19.2.8
reactcss: 1.2.3(react@19.2.8)
tinycolor2: 1.6.0
react-confetti@6.4.0(react@19.2.8):
dependencies:
react: 19.2.8
@@ -12563,11 +12500,6 @@ snapshots:
react@19.2.8: {}
reactcss@1.2.3(react@19.2.8):
dependencies:
lodash: 4.18.1
react: 19.2.8
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
@@ -13085,8 +13017,6 @@ snapshots:
tinybench@2.9.0: {}
tinycolor2@1.6.0: {}
tinyexec@1.3.0: {}
tinyglobby@0.2.17:
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { fn, userEvent, within } from "storybook/test";
import { AnnouncementBannerDialog } from "./AnnouncementBannerDialog";
const meta: Meta<typeof AnnouncementBannerDialog> = {
@@ -24,26 +24,10 @@ const Example: Story = {};
export { Example as AnnouncementBannerDialog };
export const EditsMessage: Story = {
play: async ({ args }) => {
play: async () => {
const body = within(document.body);
const message = await body.findByLabelText("Message");
await expect(message).toHaveValue(
"The beep-bop will be boop-beeped on Saturday at 12AM PST.",
);
await userEvent.clear(message);
await userEvent.type(message, "Scheduled maintenance tonight.");
await userEvent.click(body.getByRole("button", { name: "Update" }));
await expect(args.onUpdate).toHaveBeenCalledWith(
expect.objectContaining({ message: "Scheduled maintenance tonight." }),
);
},
};
export const CancelClosesDialog: Story = {
play: async ({ args }) => {
const body = within(document.body);
await userEvent.click(await body.findByRole("button", { name: "Cancel" }));
await expect(args.onCancel).toHaveBeenCalled();
},
};
@@ -0,0 +1,85 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { BannerConfig } from "#/api/typesGenerated";
import { render } from "#/testHelpers/renderHelpers";
import { AnnouncementBannerDialog } from "./AnnouncementBannerDialog";
const banner: BannerConfig = {
enabled: true,
message: "Hello from the banner",
background_color: "#ffaff3",
};
describe("AnnouncementBannerDialog", () => {
it("submits an edited message", async () => {
const user = userEvent.setup();
const onUpdate = vi.fn(async () => undefined);
render(
<AnnouncementBannerDialog
banner={banner}
onCancel={() => undefined}
onUpdate={onUpdate}
/>,
);
const message = screen.getByRole("textbox", { name: "Message" });
await user.clear(message);
await user.type(message, "Scheduled maintenance tonight.");
await user.click(screen.getByRole("button", { name: "Update" }));
expect(onUpdate).toHaveBeenCalledWith({
message: "Scheduled maintenance tonight.",
background_color: "#ffaff3",
});
});
it("replaces an incomplete hex draft with a preset background color", async () => {
const user = userEvent.setup();
const onUpdate = vi.fn(async () => undefined);
render(
<AnnouncementBannerDialog
banner={banner}
onCancel={() => undefined}
onUpdate={onUpdate}
/>,
);
const hex = screen.getByRole("textbox", { name: "Hex" });
await user.clear(hex);
await user.type(hex, "abc");
await user.click(screen.getByRole("button", { name: "#8b5cf6" }));
expect(hex).toHaveValue("8b5cf6");
await user.click(screen.getByRole("button", { name: "Update" }));
expect(onUpdate).toHaveBeenCalledWith({
message: "Hello from the banner",
background_color: "#8b5cf6",
});
});
it("restores and submits the last valid typed color after an incomplete edit", async () => {
const user = userEvent.setup();
const onUpdate = vi.fn(async () => undefined);
render(
<AnnouncementBannerDialog
banner={banner}
onCancel={() => undefined}
onUpdate={onUpdate}
/>,
);
const hex = screen.getByRole("textbox", { name: "Hex" });
await user.clear(hex);
await user.type(hex, "112233");
await user.clear(hex);
await user.type(hex, "abc");
await user.tab();
expect(hex).toHaveValue("112233");
await user.click(screen.getByRole("button", { name: "Update" }));
expect(onUpdate).toHaveBeenCalledWith({
message: "Hello from the banner",
background_color: "#112233",
});
});
});
@@ -1,9 +1,6 @@
import { cn } from "cn";
import { useFormik } from "formik";
import { useState } from "react";
import { SliderPicker, TwitterPicker } from "react-color";
import type { BannerConfig } from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import {
Dialog,
DialogActions,
@@ -15,8 +12,8 @@ import {
import { Label } from "#/components/Label/Label";
import { Textarea } from "#/components/Textarea/Textarea";
import { AnnouncementBannerView } from "#/modules/dashboard/AnnouncementBanners/AnnouncementBannerView";
import { useTheme } from "#/theme/context";
import { getFormHelpers } from "#/utils/formUtils";
import { BannerColorPicker } from "./BannerColorPicker";
type AnnouncementBannerDialogProps = {
banner: BannerConfig;
@@ -27,7 +24,6 @@ type AnnouncementBannerDialogProps = {
export const AnnouncementBannerDialog: React.FC<
AnnouncementBannerDialogProps
> = ({ banner, onCancel, onUpdate }) => {
const theme = useTheme();
const isCreating = banner.message === "";
const bannerForm = useFormik<{
@@ -47,7 +43,6 @@ export const AnnouncementBannerDialog: React.FC<
const messageHelperId = `${messageField.id}-helper`;
const messageErrorId = `${messageField.id}-error`;
const [showHuePicker, setShowHuePicker] = useState(false);
const previewMessage = bannerForm.values.message.trim();
return (
@@ -121,67 +116,15 @@ export const AnnouncementBannerDialog: React.FC<
<h4 className="m-0 mb-2 text-base font-semibold text-content-primary">
Background color
</h4>
<div className="flex flex-col gap-4">
{showHuePicker ? (
<SliderPicker
color={bannerForm.values.background_color}
onChange={async (color) => {
await bannerForm.setFieldValue(
"background_color",
color.hex,
);
}}
/>
) : (
<TwitterPicker
color={bannerForm.values.background_color}
onChange={async (color) => {
await bannerForm.setFieldValue(
"background_color",
color.hex,
);
}}
triangle="hide"
colors={[
"#8b5cf6",
"#d94a5d",
"#f78da7",
"#d65d0f",
"#ff6900",
"#fcb900",
"#0693e3",
"#8ed1fc",
"#4cd473",
"#abb8c3",
]}
styles={{
default: {
input: {
color: "white",
backgroundColor: theme.palette.background.default,
},
body: {
backgroundColor: "transparent",
color: "white",
padding: 0,
},
card: {
backgroundColor: "transparent",
},
},
}}
/>
)}
<div>
<Button
variant="outline"
onClick={() => setShowHuePicker((it) => !it)}
>
Show {showHuePicker ? "palette" : "slider"}
</Button>
</div>
</div>
<BannerColorPicker
color={bannerForm.values.background_color}
onChange={(backgroundColor) => {
void bannerForm.setFieldValue(
"background_color",
backgroundColor,
);
}}
/>
</div>
</div>
@@ -0,0 +1,86 @@
import { cn } from "cn";
import { useId, useState } from "react";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "#/components/InputGroup/InputGroup";
import { Label } from "#/components/Label/Label";
import { isHexColor } from "#/utils/colors";
const PRESET_COLORS = [
"#8b5cf6",
"#d94a5d",
"#f78da7",
"#d65d0f",
"#ff6900",
"#fcb900",
"#0693e3",
"#8ed1fc",
"#4cd473",
"#abb8c3",
] as const;
type BannerColorPickerProps = {
color: string;
onChange: (color: string) => void;
};
export const BannerColorPicker: React.FC<BannerColorPickerProps> = ({
color,
onChange,
}) => {
const [hexDraft, setHexDraft] = useState<string | undefined>(undefined);
const hexId = useId();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-1.5">
{PRESET_COLORS.map((preset) => {
const selected = preset.toLowerCase() === color.toLowerCase();
return (
<button
key={preset}
type="button"
aria-label={preset}
aria-pressed={selected}
className={cn(
"size-8 rounded-md border border-solid border-border p-0",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-content-link",
selected &&
"ring-2 ring-content-link ring-offset-2 ring-offset-surface-primary",
)}
style={{ backgroundColor: preset }}
onClick={() => {
setHexDraft(undefined);
onChange(preset);
}}
/>
);
})}
</div>
<div className="flex items-center gap-2">
<Label htmlFor={hexId}>Hex</Label>
<InputGroup className="w-36">
<InputGroupAddon>#</InputGroupAddon>
<InputGroupInput
id={hexId}
value={hexDraft ?? color.replace(/^#/, "")}
onChange={(event) => {
const digits = event.target.value.replaceAll("#", "").slice(0, 6);
setHexDraft(digits);
const next = `#${digits}`;
if (isHexColor(next)) {
onChange(next);
}
}}
onBlur={() => setHexDraft(undefined)}
autoComplete="off"
spellCheck={false}
className="font-mono"
/>
</InputGroup>
</div>
</div>
);
};