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..21acb7f --- /dev/null +++ b/src/components/color-picker/color-picker.stories.tsx @@ -0,0 +1,27 @@ +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', + opacity: 1, + saveText: 'Save', + cancelText: 'Cancel' + }, + render: ({ color: defaultColor, opacity: defaultOpacity, ...args }) => { + const [color, setColor] = useState(defaultColor); + const [opacity, setOpacity] = useState(defaultOpacity); + + return ; + } +}; 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..9b93fdc --- /dev/null +++ b/src/components/color-picker/color-picker.test.tsx @@ -0,0 +1,95 @@ +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'); + 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 new file mode 100644 index 0000000..15a7448 --- /dev/null +++ b/src/components/color-picker/color-picker.tsx @@ -0,0 +1,122 @@ +import * as React from 'react'; + +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { HexColorPicker } from 'react-colorful'; +import { Button } from '../button'; +import { THEME_COLORS } from './constants'; +import { opacityToHex } from './utils'; + +interface ColorPickerProps { + color: string; + onChange: (color: string) => void; + opacity: number; + onChangeOpacity: (opacity: number) => void; + onSave?: () => void; + onCancel?: () => void; + cancelText?: string; + saveText?: string; +} + +export const ColorPicker = ({ + color = '#000000', + onChange, + opacity = 1, + onChangeOpacity, + onSave, + onCancel, + cancelText = 'Cancel', + saveText = 'Save' +}: ColorPickerProps) => { + const [isOpen, setIsOpen] = React.useState(false); + + 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 = () => { + setIsOpen(false); + onSave?.(); + }; + + const handleCancel = () => { + setIsOpen(false); + onCancel?.(); + }; + + return ( + + + + + + + + + ); +}; 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..eeca29d --- /dev/null +++ b/src/components/color-picker/utils.ts @@ -0,0 +1,5 @@ +export const opacityToHex = (opacity: number): string => { + return Math.round(opacity * 255) + .toString(16) + .padStart(2, '0'); +}; 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/src/components/index.ts b/src/components/index.ts index d8881c2..a7884d0 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -26,3 +26,4 @@ export * from './variable-input'; export * from './radio-group'; export * from './switch'; export * from './split-button'; +export * from './color-picker'; 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 c3940ac..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' @@ -378,7 +381,42 @@ 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' + } + }; + 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 }); } ] }; 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==