mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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:
@@ -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",
|
||||
|
||||
Generated
-70
@@ -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:
|
||||
|
||||
+2
-18
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
+85
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
+10
-67
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user