From 954cb0e7469360294f7b8e6a5cfd95d58ee68ff4 Mon Sep 17 00:00:00 2001 From: rogerio Date: Tue, 4 Feb 2025 17:06:06 -0300 Subject: [PATCH 1/4] feat: create color picker component --- package.json | 1 + .../color-picker/color-picker.stories.tsx | 31 +++++++ src/components/color-picker/color-picker.tsx | 89 +++++++++++++++++++ src/components/color-picker/constants.ts | 22 +++++ src/components/color-picker/index.ts | 1 + src/components/color-picker/utils.ts | 10 +++ src/components/drawer/drawer.tsx | 2 +- tailwind.config.js | 20 ++++- yarn.lock | 2 +- 9 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 src/components/color-picker/color-picker.stories.tsx create mode 100644 src/components/color-picker/color-picker.tsx create mode 100644 src/components/color-picker/constants.ts create mode 100644 src/components/color-picker/index.ts create mode 100644 src/components/color-picker/utils.ts diff --git a/package.json b/package.json index f6a073f..d7383a2 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "cmdk": "^0.2.1", "date-fns": "^3.6.0", "framer-motion": "^11.2.10", + "react-colorful": "^5.6.1", "react-day-picker": "^8.10.1", "react-dropzone": "^14.2.3", "tailwind-merge": "^1.14.0" diff --git a/src/components/color-picker/color-picker.stories.tsx b/src/components/color-picker/color-picker.stories.tsx new file mode 100644 index 0000000..a01569c --- /dev/null +++ b/src/components/color-picker/color-picker.stories.tsx @@ -0,0 +1,31 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { ColorPicker } from './color-picker'; + +const meta: Meta = { + title: 'Components/ColorPicker', + component: ColorPicker, + tags: ['autodocs'] +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + color: '#000000', + onChange: (color: string) => console.log('Color changed:', color) + }, + render: ({ color: defaultColor }) => { + const [color, setColor] = useState(defaultColor); + + return ; + } +}; + +export const WithInitialColor: Story = { + args: { + color: '#FF0000', + onChange: (color: string) => console.log('Color changed:', color) + } +}; diff --git a/src/components/color-picker/color-picker.tsx b/src/components/color-picker/color-picker.tsx new file mode 100644 index 0000000..5f2892e --- /dev/null +++ b/src/components/color-picker/color-picker.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; + +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { RgbaColorPicker } from 'react-colorful'; +import { Button } from '../button'; +import { THEME_COLORS } from './constants'; +import { convertToHex, convertToRgba } from './utils'; + +interface ColorPickerProps { + color: string; + onChange: (color: string) => void; + onSave?: () => void; + onCancel?: () => void; + cancelText?: string; + saveText?: string; +} + +export const ColorPicker = ({ + color = '#000000', + onChange, + onSave, + onCancel, + cancelText = 'Cancel', + saveText = 'Save' +}: ColorPickerProps) => { + const [isOpen, setIsOpen] = React.useState(false); + const transparencyColor = convertToRgba(color).a * 100; + + const handleTransparencyChange = (e: React.ChangeEvent) => { + console.log(e.target.value); + }; + + const handleSave = () => { + setIsOpen(false); + onSave?.(); + }; + + const handleCancel = () => { + setIsOpen(false); + onCancel?.(); + }; + + return ( + + +
+ + +
+ onChange(convertToHex(color))} /> +
+
+
+ onChange(e.target.value)} + /> + +
+
+ {THEME_COLORS.map((themeColor) => ( +
onChange(themeColor)} + /> + ))} +
+
+ + +
+
+ + + ); +}; diff --git a/src/components/color-picker/constants.ts b/src/components/color-picker/constants.ts new file mode 100644 index 0000000..9fd4258 --- /dev/null +++ b/src/components/color-picker/constants.ts @@ -0,0 +1,22 @@ +export const THEME_COLORS = [ + '#5521B5', + '#9061F9', + '#D946EF', + '#E74694', + '#FB3855', + '#FF1111', + '#F05252', + '#FF8B8B', + '#FF8A4C', + '#FACA15', + '#84CC16', + '#2DD4BF', + '#03543F', + '#31C48D', + '#0694A2', + '#00E1E2', + '#76A9FA', + '#1E429F', + '#A07553', + '#27272A' +]; diff --git a/src/components/color-picker/index.ts b/src/components/color-picker/index.ts new file mode 100644 index 0000000..4ac0233 --- /dev/null +++ b/src/components/color-picker/index.ts @@ -0,0 +1 @@ +export * from './color-picker'; diff --git a/src/components/color-picker/utils.ts b/src/components/color-picker/utils.ts new file mode 100644 index 0000000..eda2203 --- /dev/null +++ b/src/components/color-picker/utils.ts @@ -0,0 +1,10 @@ +import { RgbaColor } from 'react-colorful'; + +export const convertToHex = (color: RgbaColor) => { + return `#${color.r.toString(16).padStart(2, '0')}${color.g.toString(16).padStart(2, '0')}${color.b.toString(16).padStart(2, '0')}`; +}; + +export const convertToRgba = (hex: string) => { + const [r, g, b] = hex.match(/\w\w/g)!.map((x) => parseInt(x, 16)); + return { r, g, b, a: 1 }; +}; diff --git a/src/components/drawer/drawer.tsx b/src/components/drawer/drawer.tsx index 22dbcf6..9792878 100644 --- a/src/components/drawer/drawer.tsx +++ b/src/components/drawer/drawer.tsx @@ -1,7 +1,7 @@ +import * as React from 'react'; import { cn } from '@/lib/utils'; import * as Dialog from '@radix-ui/react-dialog'; import { TimesIcon } from '@stash-ui/light-icons'; -import * as React from 'react'; interface DrawerRootProps extends Dialog.DialogProps { children: React.ReactNode; diff --git a/tailwind.config.js b/tailwind.config.js index c3940ac..bb9eec0 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -378,7 +378,25 @@ const config = { 'scrollbar-width': 'none' } }; - addUtilities(newUtilities); + const colorPickerComponent = { + '.custom-color-picker .react-colorful': { + width: '100%', + height: '152px' + }, + '.custom-color-picker .react-colorful__saturation': { + borderTopLeftRadius: '4px', + borderTopRightRadius: '4px' + }, + '.custom-color-picker .react-colorful__last-control': { + borderBottomLeftRadius: '4px', + borderBottomRightRadius: '4px' + }, + '.custom-color-picker .react-colorful__pointer': { + width: '0.5rem', + height: '0.5rem' + } + }; + addUtilities({ ...newUtilities, ...colorPickerComponent }); } ] }; diff --git a/yarn.lock b/yarn.lock index d532c47..ccc4352 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10903,7 +10903,7 @@ rc@^1.0.1, rc@^1.1.6: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-colorful@^5.1.2: +react-colorful@^5.1.2, react-colorful@^5.6.1: version "5.6.1" resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b" integrity sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw== From 3d043f3b85405041659a77a0f10bd4cd37bab4d0 Mon Sep 17 00:00:00 2001 From: rogerio Date: Wed, 5 Feb 2025 10:02:33 -0300 Subject: [PATCH 2/4] feat: concat hex color with opacity --- .../color-picker/color-picker.stories.tsx | 16 +++---- src/components/color-picker/color-picker.tsx | 45 ++++++++++++++----- src/components/color-picker/utils.ts | 13 ++---- src/styles/globals.css | 4 ++ tailwind.config.js | 26 +++++++++-- 5 files changed, 71 insertions(+), 33 deletions(-) diff --git a/src/components/color-picker/color-picker.stories.tsx b/src/components/color-picker/color-picker.stories.tsx index a01569c..21acb7f 100644 --- a/src/components/color-picker/color-picker.stories.tsx +++ b/src/components/color-picker/color-picker.stories.tsx @@ -14,18 +14,14 @@ type Story = StoryObj; export const Default: Story = { args: { color: '#000000', - onChange: (color: string) => console.log('Color changed:', color) + opacity: 1, + saveText: 'Save', + cancelText: 'Cancel' }, - render: ({ color: defaultColor }) => { + render: ({ color: defaultColor, opacity: defaultOpacity, ...args }) => { const [color, setColor] = useState(defaultColor); + const [opacity, setOpacity] = useState(defaultOpacity); - return ; - } -}; - -export const WithInitialColor: Story = { - args: { - color: '#FF0000', - onChange: (color: string) => console.log('Color changed:', color) + return ; } }; diff --git a/src/components/color-picker/color-picker.tsx b/src/components/color-picker/color-picker.tsx index 5f2892e..e16e24c 100644 --- a/src/components/color-picker/color-picker.tsx +++ b/src/components/color-picker/color-picker.tsx @@ -1,14 +1,16 @@ import * as React from 'react'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; -import { RgbaColorPicker } from 'react-colorful'; +import { HexColorPicker } from 'react-colorful'; import { Button } from '../button'; import { THEME_COLORS } from './constants'; -import { convertToHex, convertToRgba } from './utils'; +import { opacityToHex } from './utils'; interface ColorPickerProps { color: string; onChange: (color: string) => void; + opacity: number; + onChangeOpacity: (opacity: number) => void; onSave?: () => void; onCancel?: () => void; cancelText?: string; @@ -18,16 +20,36 @@ interface ColorPickerProps { export const ColorPicker = ({ color = '#000000', onChange, + opacity = 1, + onChangeOpacity, onSave, onCancel, cancelText = 'Cancel', saveText = 'Save' }: ColorPickerProps) => { const [isOpen, setIsOpen] = React.useState(false); - const transparencyColor = convertToRgba(color).a * 100; - const handleTransparencyChange = (e: React.ChangeEvent) => { - console.log(e.target.value); + const handleChangeOpacity = (e: React.ChangeEvent) => { + const rawValue = e.target.value.replace('%', ''); + const value = Math.min(100, Math.max(0, Number(rawValue) || 0)); + const newOpacity = value / 100; + onChangeOpacity(newOpacity); + + if (newOpacity < 1) { + const baseColor = color.slice(0, 7); + onChange(baseColor + opacityToHex(newOpacity)); + } + }; + + const handleOpacityInputClick = (e: React.MouseEvent) => { + const input = e.target as HTMLInputElement; + const valueLength = Math.round(opacity * 100).toString().length; + input.setSelectionRange(0, valueLength); + }; + + const handleChangeHexColor = (color: string) => { + onChange(color); + onChangeOpacity(1); }; const handleSave = () => { @@ -47,21 +69,22 @@ export const ColorPicker = ({
- onChange(convertToHex(color))} /> +
onChange(e.target.value)} />
@@ -70,7 +93,7 @@ export const ColorPicker = ({ key={themeColor} style={{ backgroundColor: themeColor }} className="w-[13px] h-[13px] rounded-sm cursor-pointer" - onClick={() => onChange(themeColor)} + onClick={() => handleChangeHexColor(themeColor)} /> ))}
diff --git a/src/components/color-picker/utils.ts b/src/components/color-picker/utils.ts index eda2203..eeca29d 100644 --- a/src/components/color-picker/utils.ts +++ b/src/components/color-picker/utils.ts @@ -1,10 +1,5 @@ -import { RgbaColor } from 'react-colorful'; - -export const convertToHex = (color: RgbaColor) => { - return `#${color.r.toString(16).padStart(2, '0')}${color.g.toString(16).padStart(2, '0')}${color.b.toString(16).padStart(2, '0')}`; -}; - -export const convertToRgba = (hex: string) => { - const [r, g, b] = hex.match(/\w\w/g)!.map((x) => parseInt(x, 16)); - return { r, g, b, a: 1 }; +export const opacityToHex = (opacity: number): string => { + return Math.round(opacity * 255) + .toString(16) + .padStart(2, '0'); }; diff --git a/src/styles/globals.css b/src/styles/globals.css index eafc866..af819a9 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -89,6 +89,10 @@ --switch-thumb-bg: #fafafa; --switch-thumb-disabled: #e5e5e5; + --input-hover-border: #a1a1aa; + --input-focus-border: #9061f9; + --input-focus-shadow: rgba(144, 97, 249, 0.12); + --switch-checked-bg: #31c48d; --switch-checked-hover: #0e9f6e; --switch-checked-ring: #31c48d1f; diff --git a/tailwind.config.js b/tailwind.config.js index bb9eec0..5952304 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -270,7 +270,10 @@ const config = { outline: { DEFAULT: 'rgb(from var(--text-tertiary) r g b / )', hover: 'var(--list-hover)' - } + }, + hover: 'var(--input-hover-border)', + focus: 'var(--input-focus-border)', + 'focus-shadow': 'var(--input-focus-shadow)' }, badge: { gray: { @@ -368,7 +371,7 @@ const config = { } }, plugins: [ - function ({ addUtilities }) { + function ({ addUtilities, theme }) { const newUtilities = { '.no-scrollbar::-webkit-scrollbar': { display: 'none' @@ -396,7 +399,24 @@ const config = { height: '0.5rem' } }; - addUtilities({ ...newUtilities, ...colorPickerComponent }); + const inputFocusStyles = { + '.input-focus': { + '&:hover': { + 'border-color': theme('colors.input.hover') + }, + '&:focus': { + 'border-color': theme('colors.input.focus'), + 'box-shadow': `0px 0px 0px 3px ${theme('colors.input.focus-shadow')}` + }, + outline: '2px solid transparent', + outlineOffset: '2px', + 'transition-property': 'all', + 'transition-duration': '200ms', + 'transition-timing-function': 'ease-in-out' + } + }; + + addUtilities({ ...newUtilities, ...colorPickerComponent, ...inputFocusStyles }); } ] }; From d33d84f8cf869893a38c2ea0ad89a974176ad715 Mon Sep 17 00:00:00 2001 From: rogerio Date: Wed, 5 Feb 2025 10:31:20 -0300 Subject: [PATCH 3/4] test: add color picker tests --- .../color-picker/color-picker.test.tsx | 96 +++++++++++++++++++ src/components/color-picker/color-picker.tsx | 14 ++- src/components/index.ts | 1 + 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 src/components/color-picker/color-picker.test.tsx diff --git a/src/components/color-picker/color-picker.test.tsx b/src/components/color-picker/color-picker.test.tsx new file mode 100644 index 0000000..d2feb1d --- /dev/null +++ b/src/components/color-picker/color-picker.test.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ColorPicker } from './color-picker'; + +describe('ColorPicker', () => { + const defaultProps = { + color: '#000000', + onChange: jest.fn(), + opacity: 1, + onChangeOpacity: jest.fn() + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly with default props', () => { + render(); + + const trigger = screen.getByRole('button'); + expect(trigger).toHaveStyle({ backgroundColor: '#000000' }); + }); + + it('opens color picker dropdown when clicked', () => { + render(); + + const trigger = screen.getByRole('button'); + console.log(trigger); + fireEvent.click(trigger); + + expect(screen.getByTestId('color-picker-dialog')).toBeInTheDocument(); + }); + + it('updates color value when hex input changes', () => { + render(); + + const trigger = screen.getByRole('button'); + fireEvent.click(trigger); + + const hexInput = screen.getByDisplayValue('#000000'); + fireEvent.change(hexInput, { target: { value: '#FF0000' } }); + + expect(defaultProps.onChange).toHaveBeenCalledWith('#FF0000'); + }); + + it('updates opacity when opacity input changes', () => { + render(); + + const trigger = screen.getByRole('button'); + fireEvent.click(trigger); + + const opacityInput = screen.getByDisplayValue('100%'); + fireEvent.change(opacityInput, { target: { value: '50' } }); + + expect(defaultProps.onChangeOpacity).toHaveBeenCalledWith(0.5); + }); + + it('calls onSave when save button is clicked', () => { + const onSave = jest.fn(); + render(); + + const trigger = screen.getByRole('button'); + fireEvent.click(trigger); + + const saveButton = screen.getByText('Save'); + fireEvent.click(saveButton); + + expect(onSave).toHaveBeenCalled(); + }); + + it('calls onCancel when cancel button is clicked', () => { + const onCancel = jest.fn(); + render(); + + const trigger = screen.getByRole('button'); + fireEvent.click(trigger); + + const cancelButton = screen.getByText('Cancel'); + fireEvent.click(cancelButton); + + expect(onCancel).toHaveBeenCalled(); + }); + + it('updates color when theme color is clicked', () => { + render(); + + const trigger = screen.getByRole('button'); + fireEvent.click(trigger); + + const themeColors = screen.getAllByTestId('theme-color'); + fireEvent.click(themeColors[0]); + + expect(defaultProps.onChange).toHaveBeenCalled(); + expect(defaultProps.onChangeOpacity).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/components/color-picker/color-picker.tsx b/src/components/color-picker/color-picker.tsx index e16e24c..15a7448 100644 --- a/src/components/color-picker/color-picker.tsx +++ b/src/components/color-picker/color-picker.tsx @@ -65,9 +65,18 @@ export const ColorPicker = ({ return ( -
+