Skip to content

Commit

Permalink
Merge pull request #62 from getpingback/feat/colorpicker
Browse files Browse the repository at this point in the history
Feat/colorpicker
  • Loading branch information
roger067 authored Feb 5, 2025
2 parents 88338c2 + cb65753 commit facad55
Show file tree
Hide file tree
Showing 12 changed files with 321 additions and 5 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 27 additions & 0 deletions src/components/color-picker/color-picker.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React, { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { ColorPicker } from './color-picker';

const meta: Meta<typeof ColorPicker> = {
title: 'Components/ColorPicker',
component: ColorPicker,
tags: ['autodocs']
};

export default meta;
type Story = StoryObj<typeof ColorPicker>;

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 <ColorPicker {...args} color={color} onChange={setColor} opacity={opacity} onChangeOpacity={setOpacity} />;
}
};
95 changes: 95 additions & 0 deletions src/components/color-picker/color-picker.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ColorPicker {...defaultProps} />);

const trigger = screen.getByRole('button');
expect(trigger).toHaveStyle({ backgroundColor: '#000000' });
});

it('opens color picker dropdown when clicked', () => {
render(<ColorPicker {...defaultProps} />);

const trigger = screen.getByRole('button');
fireEvent.click(trigger);

expect(screen.getByTestId('color-picker-dialog')).toBeInTheDocument();
});

it('updates color value when hex input changes', () => {
render(<ColorPicker {...defaultProps} />);

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(<ColorPicker {...defaultProps} />);

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(<ColorPicker {...defaultProps} onSave={onSave} />);

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(<ColorPicker {...defaultProps} onCancel={onCancel} />);

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(<ColorPicker {...defaultProps} />);

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);
});
});
122 changes: 122 additions & 0 deletions src/components/color-picker/color-picker.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<DropdownMenuPrimitive.Root open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuPrimitive.Trigger asChild>
<button
style={{ backgroundColor: color }}
className="w-6 h-6 rounded-md shadow-[0px_0px_0px_1.33px_#00000014_inset] cursor-pointer"
onClick={() => setIsOpen(true)}
/>
</DropdownMenuPrimitive.Trigger>
<DropdownMenuPrimitive.Content
side="bottom"
align="start"
className="w-[252px] p-4 flex flex-col z-50 rounded-lg shadow-modal"
data-testid="color-picker-dialog"
>
<div className="custom-color-picker">
<HexColorPicker color={color} onChange={handleChangeHexColor} />
</div>
<div className="flex flex-col gap-4 mt-4">
<div className="flex">
<input
type="text"
className="w-full border border-gray-500/10 rounded-l-lg rounded-r-none text-gray-600 text-sm py-2 px-3"
value={color.toUpperCase()}
onChange={(e) => onChange(e.target.value)}
/>
<input
type="text"
className="w-full max-w-[60px] border border-gray-500/10 rounded-r-lg rounded-l-none border-l-0 text-gray-600 text-sm py-2 px-[11px]"
value={`${Math.round(opacity * 100)}%`}
onChange={handleChangeOpacity}
onClick={handleOpacityInputClick}
/>
</div>
<div className="flex flex-wrap gap-[9px]">
{THEME_COLORS.map((themeColor) => (
<div
key={themeColor}
data-testid="theme-color"
style={{ backgroundColor: themeColor }}
className="w-[13px] h-[13px] rounded-sm cursor-pointer"
onClick={() => handleChangeHexColor(themeColor)}
/>
))}
</div>
<div className="flex gap-3">
<Button variant="ghost" className="w-full" onClick={handleCancel}>
{cancelText}
</Button>
<Button variant="outline" className="w-full" onClick={handleSave}>
{saveText}
</Button>
</div>
</div>
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Root>
);
};
22 changes: 22 additions & 0 deletions src/components/color-picker/constants.ts
Original file line number Diff line number Diff line change
@@ -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'
];
1 change: 1 addition & 0 deletions src/components/color-picker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './color-picker';
5 changes: 5 additions & 0 deletions src/components/color-picker/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const opacityToHex = (opacity: number): string => {
return Math.round(opacity * 255)
.toString(16)
.padStart(2, '0');
};
2 changes: 1 addition & 1 deletion src/components/drawer/drawer.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ export * from './variable-input';
export * from './radio-group';
export * from './switch';
export * from './split-button';
export * from './color-picker';
4 changes: 4 additions & 0 deletions src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
44 changes: 41 additions & 3 deletions tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,10 @@ const config = {
outline: {
DEFAULT: 'rgb(from var(--text-tertiary) r g b / <alpha-value>)',
hover: 'var(--list-hover)'
}
},
hover: 'var(--input-hover-border)',
focus: 'var(--input-focus-border)',
'focus-shadow': 'var(--input-focus-shadow)'
},
badge: {
gray: {
Expand Down Expand Up @@ -368,7 +371,7 @@ const config = {
}
},
plugins: [
function ({ addUtilities }) {
function ({ addUtilities, theme }) {
const newUtilities = {
'.no-scrollbar::-webkit-scrollbar': {
display: 'none'
Expand All @@ -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 });
}
]
};
Expand Down
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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==
Expand Down

0 comments on commit facad55

Please sign in to comment.