diff --git a/.changeset/busy-lizards-dance.md b/.changeset/busy-lizards-dance.md new file mode 100644 index 00000000..84e42432 --- /dev/null +++ b/.changeset/busy-lizards-dance.md @@ -0,0 +1,13 @@ +--- +'@example/ui-playground': patch +'@genseki/react': patch +--- + +New components +- New `dropzone` component & examples +- New `file-preview` component & examples +- New `color-picker` component & examples +- New `slider` component & examples +- New `checkbox` component & examples +- New peer dependency color for managing internal color system +- New peer dependencu react-dropzone for managing file dropzone state \ No newline at end of file diff --git a/examples/ui-playground/package.json b/examples/ui-playground/package.json index 78f5e38c..7865216f 100644 --- a/examples/ui-playground/package.json +++ b/examples/ui-playground/package.json @@ -26,6 +26,7 @@ "@tiptap/extension-text-style": "^2.26.3", "@tiptap/extension-underline": "^2.26.3", "@tiptap/starter-kit": "^2.26.3", + "color": "^5.0.2", "date-fns": "^4.1.0", "next": "15.2.2", "next-themes": "^0.4.6", diff --git a/examples/ui-playground/src/app/playground/shadcn/checkbox-section.tsx b/examples/ui-playground/src/app/playground/shadcn/checkbox-section.tsx new file mode 100644 index 00000000..f66e41e9 --- /dev/null +++ b/examples/ui-playground/src/app/playground/shadcn/checkbox-section.tsx @@ -0,0 +1,344 @@ +import React from 'react' + +import { CheckIcon, MinusIcon, XIcon } from '@phosphor-icons/react' + +import { Checkbox, Label, Typography } from '@genseki/react/v2' + +import { PlaygroundCard } from '~/src/components/card' + +// Basic Checkbox +function BasicCheckbox() { + return ( +
+ + Square + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + Rounded + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ) +} + +// Checked State +function CheckedCheckbox() { + const [checked, setChecked] = React.useState(true) + + return ( +
+ { + setChecked(value === true || false) + }} + /> + +
+ ) +} + +// Disabled Checkbox +function DisabledCheckbox() { + return ( +
+
+ + +
+
+ + +
+
+ ) +} + +// Checkbox with Error State +function CheckboxWithError() { + return ( +
+
+ + +
+ + You must accept the terms to continue + +
+ ) +} + +// Checkbox Group +function CheckboxGroup() { + const [items, setItems] = React.useState({ + apples: false, + oranges: false, + bananas: false, + }) + + return ( +
+ +
+
+ setItems({ ...items, apples: checked as boolean })} + /> + +
+
+ setItems({ ...items, oranges: checked as boolean })} + /> + +
+
+ setItems({ ...items, bananas: checked as boolean })} + /> + +
+
+
+ ) +} + +// Controlled Checkbox +function ControlledCheckbox() { + const [agreed, setAgreed] = React.useState(false) + + return ( +
+
+ setAgreed(value === true || false)} + /> + +
+ + Status: {agreed ? 'Agreed' : 'Not agreed'} + +
+ ) +} + +// Checkbox with Description +function CheckboxWithDescription() { + return ( +
+
+ +
+ + + Get notified about new features and updates. + +
+
+
+ ) +} + +// Custom Styled Checkbox +function CustomStyledCheckbox() { + return ( +
+ + +
+ ) +} + +// All Checkbox States +function AllCheckboxStates() { + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ) +} + +export function CheckboxSection() { + return ( + <> +
+ + + A basic checkbox input with a label. + +
+ +
+
+ + + + A checkbox that is checked by default with controlled state. + +
+ +
+
+ + + + Checkbox in disabled state, both checked and unchecked. + +
+ +
+
+ + + + Checkbox in error state with validation feedback. + +
+ +
+
+ + + + Multiple checkboxes used together in a group. + +
+ +
+
+ + + + Checkbox with controlled state and real-time status feedback. + +
+ +
+
+ + + + Checkbox with additional descriptive text. + +
+ +
+
+ + + + Checkbox with custom styling for checked and unchecked states. + +
+ +
+
+ + + + Demonstration of all possible checkbox states. + +
+ +
+
+
+ + ) +} diff --git a/examples/ui-playground/src/app/playground/shadcn/color-picker-section.tsx b/examples/ui-playground/src/app/playground/shadcn/color-picker-section.tsx new file mode 100644 index 00000000..6fc8e423 --- /dev/null +++ b/examples/ui-playground/src/app/playground/shadcn/color-picker-section.tsx @@ -0,0 +1,362 @@ +import React, { startTransition, useState } from 'react' + +import { type ColorLike } from 'color' + +import { + Button, + Popover, + PopoverContent, + PopoverTrigger, + Typography, + useColorPicker, +} from '@genseki/react/v2' +import { + ColorPicker, + ColorPickerAlpha, + ColorPickerEyeDropper, + ColorPickerFormat, + ColorPickerHue, + ColorPickerOutput, + ColorPickerSelection, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@genseki/react/v2' + +import { InformationCard, PlaygroundCard } from '~/src/components/card' + +// Basic Color Picker +function BasicColorPicker() { + const [color, setColor] = useState([0, 0, 0, 1]) // RGBA - [r, g, b, alpha] + + return ( +
+ { + startTransition(() => setColor(v)) // This is a trick to optimize + }} + className="max-w-sm rounded-md border bg-background p-4 shadow-sm" + > + +
+ +
+ + +
+
+
+ + +
+
+ + Selected color: rgba({color.toString()}) + +
+ ) +} + +function CustomSelector() { + const { mode, setMode } = useColorPicker() // You can use `useColorPicker` under `ColorPicker` + const formats = ['hex', 'rgb', 'css', 'hsl'] + + return ( + + ) +} + +// Compact Color Picker +function CompactColorPicker() { + const [color, setColor] = React.useState([255, 0, 0, 1]) + + return ( +
+ { + startTransition(() => setColor(v)) + }} + > + +
+ +
+ + +
+
+
+ + +
+
+
+ ) +} + +// Large Color Picker +function LargeColorPicker() { + const [color, setColor] = React.useState([255, 255, 0, 1]) + + return ( +
+ { + startTransition(() => setColor(v)) + }} + > + +
+ +
+ + +
+
+
+ + +
+
+
+ ) +} + +// Color Picker with Preset Colors +function ColorPickerWithPresets() { + const [color, setColor] = React.useState([255, 0, 0, 1]) + + const presets = [ + [255, 0, 0, 1], // Red + [255, 255, 0, 1], // Yellow + [0, 255, 0, 1], // Green + [0, 255, 255, 1], // Cyan + [0, 0, 255, 1], // Blue + [255, 0, 255, 1], // Magenta + [128, 128, 128, 1], // Gray + [0, 0, 0, 1], // Black + ] + + return ( +
+ { + startTransition(() => setColor(v)) + }} + > + +
+ +
+ + +
+
+
+ + +
+
+
+ + Preset Colors: + +
+ {presets.map((preset, index) => ( +
+
+
+ ) +} + +// Color Picker Without EyeDropper +function ColorPickerWithoutEyeDropper() { + const [color, setColor] = React.useState([255, 140, 0, 1]) + + return ( +
+ { + startTransition(() => setColor(v)) + }} + > + +
+
+ + +
+
+
+ + +
+
+
+ ) +} + +// Color Picker with Popover +function ColorPickerWithPopover() { + const [color, setColor] = React.useState([255, 0, 150, 1]) + const [isOpen, setIsOpen] = React.useState(false) + + return ( +
+ + + + + + { + startTransition(() => setColor(v)) + }} + className="gap-6" + > + +
+ +
+ + +
+
+
+ + +
+
+
+
+
+
+ + Current: rgba({color.toString()}) + +
+
+ ) +} + +export function ColorPickerSection() { + return ( + <> +
+ + You can use the + + useColorPicker + + under the{' '} + + {''} + + component + + + + + A complete color picker with selection area, hue/alpha sliders, and output formats. + +
+ +
+
+ + + + A more compact version of the color picker for space-constrained layouts. + +
+ +
+
+ + + + A larger color picker with increased selection area for better precision. + +
+ +
+
+ + + + Color picker with preset color swatches for quick selection. + +
+ +
+
+ + + + Color picker without the eye dropper tool for simpler interfaces. + +
+ +
+
+ + + + Color picker inside a popover triggered by a button with live preview. + +
+ +
+
+
+ + ) +} diff --git a/examples/ui-playground/src/app/playground/shadcn/date-picker-section.tsx b/examples/ui-playground/src/app/playground/shadcn/date-picker-section.tsx index 1bb8bfb4..2f251000 100644 --- a/examples/ui-playground/src/app/playground/shadcn/date-picker-section.tsx +++ b/examples/ui-playground/src/app/playground/shadcn/date-picker-section.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react' import type { DateRange } from 'react-day-picker' -import { CalendarDotsIcon, ClockIcon, WarningIcon } from '@phosphor-icons/react' +import { CalendarDotsIcon, ClockIcon } from '@phosphor-icons/react' import { addDays, endOfMonth, @@ -27,7 +27,7 @@ import { Typography, } from '@genseki/react/v2' -import { PlaygroundCard } from '../../../components/card' +import { InformationCard, PlaygroundCard } from '../../../components/card' function BasicDatePicker() { const [date, setDate] = useState() @@ -534,12 +534,9 @@ function DisabledDates() { export function DatePickerSection() { return (
-
- + Our example use{' '} - - Popover - ,{' '} + Popover,{' '} PopoverContent {' '} @@ -559,7 +556,7 @@ export function DatePickerSection() { DatePickerTrigger -
+ A simple default datepicker diff --git a/examples/ui-playground/src/app/playground/shadcn/drop-zone-section.tsx b/examples/ui-playground/src/app/playground/shadcn/drop-zone-section.tsx new file mode 100644 index 00000000..beedcbf9 --- /dev/null +++ b/examples/ui-playground/src/app/playground/shadcn/drop-zone-section.tsx @@ -0,0 +1,450 @@ +import React, { Fragment, useState } from 'react' + +import { XIcon } from '@phosphor-icons/react' +import { useRouter } from 'next/navigation' + +import { + Button, + DropZoneArea, + DropZoneEmptyContent, + DropZoneEmptyUploadButton, + DropZoneEmptyUploadCaption, + DropZoneEmptyUploadDescription, + DropZoneNonemptyContent, + FilePreview, + FilePreviewAddons, + FilePreviewContent, + FilePreviewStatus, + FilePreviewThumbnail, + FilePreviewTitle, + Typography, + useDropZone, +} from '@genseki/react/v2' +import { DropzoneProvider } from '@genseki/react/v2' + +import { InformationCard, PlaygroundCard } from '~/src/components/card' + +function CustomDropzoneContent(props: { removePreview: () => void }) { + const fullPreview = false // Try change this flag + const dropzoneCtx = useDropZone() + const router = useRouter() + + const isValidPreview = dropzoneCtx.previews.every((preview) => { + return ( + preview.type.startsWith('image/') || + preview.type.startsWith('video/') || + preview.type.startsWith('audio/') + ) + }) + + if (!isValidPreview) { + return ( + +
+ + + + Unsupported file type +
+
+ ) + } + + if (isValidPreview && !fullPreview) { + return ( + // Handle more customization by yourselves here +
+ {dropzoneCtx.previews.map((preview) => ( + + + {/* + * You can pass a preview as a children of FilePreviewThumbnail + * + * ... + * + */} + + PDF 2024-my-portfolioss.pdf + + + + + + + + ))} +
+ ) + } + + // This is an another preview example + return ( +
+ + {dropzoneCtx.previews?.map(({ type, url }) => ( +
+ {type.startsWith('image/') ? ( + // eslint-disable-next-line @next/next/no-img-element + image URL.revokeObjectURL(url)} + className="rounded-md" + /> + ) : type.startsWith('video/') ? ( + + ) : type.startsWith('audio/') ? ( + + ) : null} +
+ ))} +
+ ) +} + +// Custom Dropzone +function CustomDropzone() { + const [files, setFiles] = useState([]) + const [previews, setPreviews] = useState<{ type: string; url: string }[]>([]) + + const handleDrop = (acceptedFiles: File[]) => { + setFiles(acceptedFiles) + const newPreviews = acceptedFiles.map((file) => ({ + type: file.type, + url: URL.createObjectURL(file), + })) + setPreviews(newPreviews) + } + + const handleError = (error: Error) => { + console.log(error) + } + + const removePreview = () => { + setFiles([]) + setPreviews([]) + } + + return ( + + + + {/* These content will be displayed if no previewable item */} +
+ + + +
+
+
+ + {/* These content will be displayed if there're available previewable item */} + + +
+ ) +} + +// Dropzone with Image Preview +function DropzoneWithImagePreview() { + const [files, setFiles] = useState([]) + const [previews, setPreviews] = useState<{ type: string; url: string }[]>([]) + + const handleDrop = (acceptedFiles: File[]) => { + setFiles(acceptedFiles) + const newPreviews = acceptedFiles.map((file) => ({ + type: file.type, + url: URL.createObjectURL(file), + })) + setPreviews(newPreviews) + } + + const handleError = (error: Error) => { + console.error(error) + } + + const removePreview = () => { + setFiles([]) + setPreviews([]) + } + + return ( + + + +
+ + + +
+
+
+ +
+ {previews.map((preview) => ( +
+ {preview.type.startsWith('image/') && ( + // eslint-disable-next-line @next/next/no-img-element + Uploaded URL.revokeObjectURL(preview.url)} + /> + )} + +
+ ))} +
+
+
+ ) +} + +// Multiple File Dropzone +function MultipleFileDropzone() { + const [files, setFiles] = useState([]) + const [previews, setPreviews] = useState<{ type: string; url: string }[]>([]) + + const handleDrop = (acceptedFiles: File[]) => { + setFiles(acceptedFiles) + const newPreviews = acceptedFiles.map((file) => ({ + type: file.type, + url: URL.createObjectURL(file), + })) + setPreviews(newPreviews) + } + + const handleError = (error: Error) => { + console.error(error) + } + + const removePreview = (index: number) => { + setFiles(files.filter((_, i) => i !== index)) + setPreviews(previews.filter((_, i) => i !== index)) + } + + return ( + + + +
+ + + +
+
+
+ +
+ {previews.map((preview, index) => ( +
+ + + + {files[index]?.name || `File ${index + 1}`} + + + + + + +
+ ))} +
+
+
+ ) +} + +// Dropzone with File Size Validation +function DropzoneWithValidation() { + const [files, setFiles] = useState([]) + const [previews, setPreviews] = useState<{ type: string; url: string }[]>([]) + const [error, setError] = useState(null) + + const handleDrop = (acceptedFiles: File[]) => { + setFiles(acceptedFiles) + setError(null) + const newPreviews = acceptedFiles.map((file) => ({ + type: file.type, + url: URL.createObjectURL(file), + })) + setPreviews(newPreviews) + } + + const handleError = (error: Error) => { + setError(error.message) + } + + const removePreview = () => { + setFiles([]) + setPreviews([]) + setError(null) + } + + return ( + + + +
+ + + + {error && {error}} +
+
+
+ +
+ {previews.map((preview, index) => ( + + + + {files[index]?.name || 'Uploaded file'} + + + + + + + ))} +
+
+
+ ) +} + +export function DropZoneSection() { + return ( + <> +
+ + You can use the + + useDropZone + {' '} + under the{' '} + + {''} + {' '} + component to manage the file + + + + A basic dropzone for file uploads with drag and drop functionality. + +
+ +
+
+ + + + Dropzone that accepts only image files and displays a preview with hover effects. + +
+ +
+
+ + + + Dropzone that allows uploading multiple files at once with individual file management. + +
+ +
+
+ + + + Dropzone with file size validation showing error messages for oversized files. + +
+ +
+
+
+ + ) +} diff --git a/examples/ui-playground/src/app/playground/shadcn/page-sidebar.tsx b/examples/ui-playground/src/app/playground/shadcn/page-sidebar.tsx index fd22c53c..29f2c4ad 100644 --- a/examples/ui-playground/src/app/playground/shadcn/page-sidebar.tsx +++ b/examples/ui-playground/src/app/playground/shadcn/page-sidebar.tsx @@ -17,7 +17,10 @@ import { const navigationItems = [ { href: '#button', label: 'Button' }, { href: '#combobox', label: 'Combobox' }, + { href: '#checkbox', label: 'Checkbox' }, + { href: '#color-picker', label: 'Color picker' }, { href: '#date-picker', label: 'Date picker' }, + { href: '#drop-zone', label: 'Drop zone' }, { href: '#dialog', label: 'Dialog' }, { href: '#input', label: 'Input' }, { href: '#input-otp', label: 'Input OTP' }, @@ -25,6 +28,7 @@ const navigationItems = [ { href: '#pagination', label: 'Pagination' }, { href: '#progress', label: 'Progress' }, { href: '#select', label: 'Select' }, + { href: '#slider', label: 'Slider' }, { href: '#switch', label: 'Switch' }, { href: '#tabs', label: 'Tabs' }, { href: '#tooltip', label: 'Tooltip' }, diff --git a/examples/ui-playground/src/app/playground/shadcn/page.tsx b/examples/ui-playground/src/app/playground/shadcn/page.tsx index 21362957..912b9bb4 100644 --- a/examples/ui-playground/src/app/playground/shadcn/page.tsx +++ b/examples/ui-playground/src/app/playground/shadcn/page.tsx @@ -4,10 +4,13 @@ import * as React from 'react' import { Typography } from '@genseki/react/v2' import { ButtonSection } from './button-section' +import { CheckboxSection } from './checkbox-section' import { CollapsibleSection } from './collapsible-section' +import { ColorPickerSection } from './color-picker-section' import { ComboboxSection } from './combobox-section' import { DatePickerSection } from './date-picker-section' import { DialogSection } from './dialog-section' +import { DropZoneSection } from './drop-zone-section' import { DropdownMenuSection } from './dropdown-menu-section' import { InputOtpSection } from './input-otp-section' import { InputSection } from './input-section' @@ -16,6 +19,7 @@ import PageSidebar from './page-sidebar' import { PaginationSection } from './pagination-section' import { ProgressSection } from './progress-section' import { SelectSection } from './select-section' +import { SliderSection } from './slider-section' import { SwitchSection } from './switch-section' import { TabsSection } from './tabs-section' import { TextareaSection } from './textarea-section' @@ -36,6 +40,14 @@ export default function ComboboxPage() { Button
+ + Checkbox + + + + Color Picker + + Combobox @@ -48,6 +60,10 @@ export default function ComboboxPage() { Dialog + + Drop Zone + + Input @@ -72,6 +88,10 @@ export default function ComboboxPage() { Select + + Slider + + Switch diff --git a/examples/ui-playground/src/app/playground/shadcn/slider-section.tsx b/examples/ui-playground/src/app/playground/shadcn/slider-section.tsx new file mode 100644 index 00000000..08e27362 --- /dev/null +++ b/examples/ui-playground/src/app/playground/shadcn/slider-section.tsx @@ -0,0 +1,285 @@ +import React from 'react' + +import { Label, Slider, Typography } from '@genseki/react/v2' + +import { PlaygroundCard } from '~/src/components/card' + +// Basic Slider +function BasicSlider() { + const [value, setValue] = React.useState([50]) + + return ( +
+
+ + + {value[0]}% + +
+ +
+ ) +} + +// Slider with Range +function SliderWithRange() { + const [value, setValue] = React.useState([20, 80]) + + return ( +
+
+ + + {value[0]}% - {value[1]}% + +
+ +
+ ) +} + +// Slider with Steps +function SliderWithSteps() { + const [value, setValue] = React.useState([50]) + + return ( +
+
+ + + {value[0]}% + +
+ +
+ ) +} + +// Slider with Min/Max +function SliderWithMinMax() { + const [value, setValue] = React.useState([25]) + + return ( +
+
+ + + {value[0]}°C + +
+ +
+ ) +} + +// Disabled Slider +function DisabledSlider() { + const [value] = React.useState([50]) + + return ( +
+
+ + + {value[0]}% + +
+ +
+ ) +} + +// Vertical Slider +function VerticalSlider() { + const [value, setValue] = React.useState([50]) + + return ( +
+
+ + + {value[0]}% + +
+
+ +
+
+ ) +} + +// Slider with Marks +function SliderWithMarks() { + const [value, setValue] = React.useState([50]) + + return ( +
+
+ + + {value[0]} + +
+ +
+ 0 + 1 + 2 + 3 + 4 + 5 +
+
+ ) +} + +// Slider with Custom Styling +function SliderWithCustomStyling() { + const [value, setValue] = React.useState([75]) + + return ( +
+
+ + + {value[0]}% + +
+ +
+ ) +} + +// Multiple Sliders +function MultipleSliders() { + const [volume, setVolume] = React.useState([50]) + const [brightness, setBrightness] = React.useState([75]) + const [contrast, setContrast] = React.useState([60]) + + return ( +
+
+
+ + + {volume[0]}% + +
+ +
+ +
+
+ + + {brightness[0]}% + +
+ +
+ +
+
+ + + {contrast[0]}% + +
+ +
+
+ ) +} + +export function SliderSection() { + return ( + <> +
+ + + A basic slider with a single value. + +
+ +
+
+ + + + A slider with two thumbs for selecting a range of values. + +
+ +
+
+ + + + A slider with discrete step increments. + +
+ +
+
+ + + + A slider with custom minimum and maximum values. + +
+ +
+
+ + + + A disabled slider that cannot be interacted with. + +
+ +
+
+ + + + A vertical orientation slider. + +
+ +
+
+ + + + A slider with step marks and labels below. + +
+ +
+
+ + + + A slider with custom colors and styling. + +
+ +
+
+ + + + Multiple independent sliders for different settings. + +
+ +
+
+
+ + ) +} diff --git a/examples/ui-playground/src/components/card.tsx b/examples/ui-playground/src/components/card.tsx index d0fca1bc..4d63c516 100644 --- a/examples/ui-playground/src/components/card.tsx +++ b/examples/ui-playground/src/components/card.tsx @@ -1,5 +1,6 @@ import type React from 'react' +import { type Icon, WarningIcon } from '@phosphor-icons/react' import { StarIcon } from '@phosphor-icons/react/dist/ssr' import { Typography } from '@genseki/react/v2' @@ -45,3 +46,18 @@ export const PlaygroundCard = ({
) } + +export function InformationCard({ + icon: Icon = WarningIcon, + ...props +}: { + icon?: Icon + children?: React.ReactNode +}) { + return ( +
+ + {props.children} +
+ ) +} diff --git a/internals/project-config/tsconfig/base.json b/internals/project-config/tsconfig/base.json index 6eecfeb5..f4b4a825 100644 --- a/internals/project-config/tsconfig/base.json +++ b/internals/project-config/tsconfig/base.json @@ -18,7 +18,7 @@ "skipLibCheck": true, "strict": true, "resolveJsonModule": true, - "verbatimModuleSyntax": true, + "verbatimModuleSyntax": true }, "exclude": ["node_modules"] } diff --git a/packages/react/package.json b/packages/react/package.json index 0ee153ae..4ef95a94 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -52,6 +52,7 @@ "@intentui/icons": "^1.10.31", "@internationalized/date": "^3.8.2", "@phosphor-icons/react": "^2.1.8", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -60,6 +61,7 @@ "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", @@ -115,16 +117,20 @@ "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.3.4", + "color": "^5.0.2", "react-day-picker": "^9.11.1", + "react-dropzone": "^14.3.8", "tsup": "^8.5.0", "type-fest": "^4.38.0", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.1.3" }, "peerDependencies": { + "color": "^5.0.2", "react": "^19.1.0", "react-day-picker": "^9.11.1", "react-dom": "^19.1.0", + "react-dropzone": "^14.3.8", "zod": "^4.0.11" } } diff --git a/packages/react/src/react/components/primitives/checkbox.tsx b/packages/react/src/react/components/primitives/checkbox.tsx index 45ce8bb0..3bc38b9e 100644 --- a/packages/react/src/react/components/primitives/checkbox.tsx +++ b/packages/react/src/react/components/primitives/checkbox.tsx @@ -21,12 +21,18 @@ import { composeTailwindRenderProps } from './primitive' import { cn } from '../../utils/cn' +/** + * @deprecated + */ interface CheckboxGroupProps extends CheckboxGroupPrimitiveProps { label?: string description?: string errorMessage?: string | ((validation: ValidationResult) => string) } +/** + * @deprecated + */ const CheckboxGroup = forwardRef(function CheckboxGroup( { className, ...props }: CheckboxGroupProps, ref: React.ForwardedRef @@ -45,6 +51,9 @@ const CheckboxGroup = forwardRef(function CheckboxGroup( ) }) +/** + * @deprecated + */ const checkboxStyles = tv({ base: 'group flex items-center gap-2 text-sm transition', variants: { @@ -54,6 +63,9 @@ const checkboxStyles = tv({ }, }) +/** + * @deprecated + */ const boxStyles = tv({ base: 'inset-ring inset-ring-fg/10 flex size-8 shrink-0 items-center justify-center rounded-sm text-bg transition *:data-[slot=icon]:size-6', variants: { @@ -76,6 +88,9 @@ const boxStyles = tv({ }, }) +/** + * @deprecated + */ interface CheckboxProps extends CheckboxPrimitiveProps { description?: string errorMessage?: string @@ -84,6 +99,9 @@ interface CheckboxProps extends CheckboxPrimitiveProps { isTextCenter?: boolean } +/** + * @deprecated + */ const Checkbox = forwardRef(function Checkbox( { className, size = 'md', isTextCenter = true, ...props }: CheckboxProps, ref: React.ForwardedRef diff --git a/packages/react/src/react/components/primitives/color-picker.tsx b/packages/react/src/react/components/primitives/color-picker.tsx index a96cf78f..49c664d7 100644 --- a/packages/react/src/react/components/primitives/color-picker.tsx +++ b/packages/react/src/react/components/primitives/color-picker.tsx @@ -22,6 +22,9 @@ import { Popover, PopoverContent, type PopoverContentProps } from './popover' import { BaseIcon } from '../../components/primitives/base-icon' import { cn } from '../../utils/cn' +/** + * @deprecated + */ interface ColorPickerProps extends ColorPickerPrimitiveProps, Pick { @@ -36,6 +39,9 @@ interface ColorPickerProps onPopupOpenChange?: (open: boolean) => void } +/** + * @deprecated + */ const ColorPicker = ({ showArrow = false, placement = 'bottom start', @@ -103,10 +109,16 @@ const ColorPicker = ({ declare global { interface Window { + /** + * @deprecated + */ EyeDropper?: new () => { open: () => Promise<{ sRGBHex: string }> } } } +/** + * @deprecated + */ const EyeDropper = () => { const state = use(ColorPickerStateContext)! diff --git a/packages/react/src/react/components/primitives/color-slider.tsx b/packages/react/src/react/components/primitives/color-slider.tsx index a661159f..c9d3d685 100644 --- a/packages/react/src/react/components/primitives/color-slider.tsx +++ b/packages/react/src/react/components/primitives/color-slider.tsx @@ -13,6 +13,9 @@ import { tv } from 'tailwind-variants' import { ColorThumb } from './color-thumb' import { Label } from './field' +/** + * @deprecated + */ const trackStyles = tv({ base: 'group col-span-2 rounded-lg', variants: { @@ -26,11 +29,17 @@ const trackStyles = tv({ }, }) +/** + * @deprecated + */ interface ColorSliderProps extends ColorSliderPrimitiveProps { label?: string showOutput?: boolean } +/** + * @deprecated + */ const colorSliderStyles = tv({ base: 'group relative py-2', variants: { @@ -43,6 +52,9 @@ const colorSliderStyles = tv({ }, }, }) +/** + * @deprecated + */ const ColorSlider = ({ showOutput = true, label, className, ...props }: ColorSliderProps) => { return ( ( diff --git a/packages/react/src/react/styles/tailwind.css b/packages/react/src/react/styles/tailwind.css index 2ea7c804..f959a101 100644 --- a/packages/react/src/react/styles/tailwind.css +++ b/packages/react/src/react/styles/tailwind.css @@ -161,7 +161,7 @@ --color-icon-secondary: var(--color-bluegray-600); --color-icon-tertiary: var(--color-bluegray-400); - --color-icon-disabled: var(--color-bluegray-100); + --color-icon-disabled: var(--color-bluegray-300); --color-icon-brand: var(--color-pumpkin-500); --color-icon-brand-bold: var(--color-pumpkin-600); diff --git a/packages/react/v2/components/primitives/checkbox.tsx b/packages/react/v2/components/primitives/checkbox.tsx new file mode 100644 index 00000000..656fa1c5 --- /dev/null +++ b/packages/react/v2/components/primitives/checkbox.tsx @@ -0,0 +1,78 @@ +'use client' + +import * as React from 'react' + +import { CheckIcon, type Icon } from '@phosphor-icons/react' +import * as CheckboxPrimitive from '@radix-ui/react-checkbox' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '../../../src/react/utils/cn' + +type Variants = { + variant: Record<'default' | 'incorrect' | 'correct', any> + shape: Record<'square' | 'rounded', any> +} + +const checkbox = cva( + [ + 'peer ring-offset-2 focus-visible:border-ring focus-visible:ring-ring size-8 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[2px]', + 'disabled:cursor-not-allowed data-[state=checked]:disabled:border-border disabled:bg-surface-tertiary data-[state=checked]:disabled:bg-surface-tertiary data-[state=checked]:disabled:text-icon-disabled', + 'aria-invalid:ring-destructive dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[state=checked]:aria-invalid:border-destructive', + ], + { + variants: { + variant: { + default: + 'border-input data-[state=checked]:border-none data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:data-[state=checked]:bg-primary', + incorrect: + 'border-input data-[state=checked]:border-none data-[state=checked]:bg-icon-incorrect data-[state=checked]:text-text-inverse', + correct: + 'border-input data-[state=checked]:border-none data-[state=checked]:bg-icon-correct data-[state=checked]:text-text-inverse', + }, + shape: { + square: '', + rounded: 'rounded-full', + }, + }, + defaultVariants: { + variant: 'default', + shape: 'square', + }, + } +) + +const checkboxIndicator = cva('', { + variants: { + variant: { default: null, incorrect: null, correct: null }, + shape: { square: 'size-7', rounded: 'size-6' }, + }, + defaultVariants: { + shape: 'square', + }, +}) + +function Checkbox({ + className, + variant, + shape, + icon: Icon = CheckIcon, + ...props +}: React.ComponentProps & + VariantProps & { icon?: Icon }) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/packages/react/v2/components/primitives/color-picker.tsx b/packages/react/v2/components/primitives/color-picker.tsx new file mode 100644 index 00000000..ef8af9a2 --- /dev/null +++ b/packages/react/v2/components/primitives/color-picker.tsx @@ -0,0 +1,413 @@ +'use client' +import { + type ComponentProps, + type HTMLAttributes, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' + +import { EyedropperIcon } from '@phosphor-icons/react' +import * as Slider from '@radix-ui/react-slider' +import Color from 'color' + +import { Button } from './button' +import { Input } from './input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select' +import { Typography } from './typography' + +import { createRequiredContext } from '../../../src/react/hooks/create-required-context' +import { cn } from '../../../src/react/utils/cn' + +interface ColorPickerContextValue { + hue: number + saturation: number + lightness: number + alpha: number + mode: string + setHue: (hue: number) => void + setSaturation: (saturation: number) => void + setLightness: (lightness: number) => void + setAlpha: (alpha: number) => void + setMode: (mode: string) => void +} + +export const [ColorPickerContextProvider, useColorPicker] = + createRequiredContext('Color picker context') + +export type ColorPickerProps = HTMLAttributes & { + value?: Parameters[0] + defaultValue?: Parameters[0] + onChange?: (value: Parameters[0]) => void +} +export const ColorPicker = ({ + value, + defaultValue = '#000000', + onChange, + className, + ...props +}: ColorPickerProps) => { + // Handle controlled vs uncontrolled + const currentValue = value !== undefined ? value : defaultValue + const initialColor = Color(currentValue) + + const [hue, setHue] = useState(() => { + try { + return initialColor.hue() || 0 + } catch { + return 0 + } + }) + const [saturation, setSaturation] = useState(() => { + try { + return initialColor.saturationl() || 100 + } catch { + return 100 + } + }) + const [lightness, setLightness] = useState(() => { + try { + return initialColor.lightness() || 50 + } catch { + return 50 + } + }) + const [alpha, setAlpha] = useState(() => { + try { + return initialColor.alpha() * 100 || 100 + } catch { + return 100 + } + }) + const [mode, setMode] = useState('hex') + const isInitialMount = useRef(true) + + // Update color when controlled value changes + useEffect(() => { + if (value !== undefined && !isInitialMount.current) { + try { + const color = Color(value) + setHue(color.hue() || 0) + setSaturation(color.saturationl() || 100) + setLightness(color.lightness() || 50) + setAlpha(color.alpha() * 100 || 100) + } catch (error) { + console.error('Failed to parse color value:', error) + } + } + }, [value]) + + // Notify parent of changes (skip initial mount) + useEffect(() => { + if (onChange && !isInitialMount.current) { + const color = Color.hsl(hue, saturation, lightness).alpha(alpha / 100) + const rgba = color.rgb().array() + onChange([rgba[0], rgba[1], rgba[2], alpha / 100]) + } + isInitialMount.current = false + }, [hue, saturation, lightness, alpha, onChange]) + return ( + +
+ + ) +} +export type ColorPickerSelectionProps = HTMLAttributes +export const ColorPickerSelection = memo(({ className, ...props }: ColorPickerSelectionProps) => { + const containerRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) + const [positionX, setPositionX] = useState(0) + const [positionY, setPositionY] = useState(0) + const { hue, setSaturation, setLightness } = useColorPicker() + const backgroundGradient = useMemo(() => { + return `linear-gradient(0deg, rgba(0,0,0,1), rgba(0,0,0,0)), + linear-gradient(90deg, rgba(255,255,255,1), rgba(255,255,255,0)), + hsl(${hue}, 100%, 50%)` + }, [hue]) + const handlePointerMove = useCallback( + (event: PointerEvent) => { + if (!(isDragging && containerRef.current)) { + return + } + const rect = containerRef.current.getBoundingClientRect() + const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) + const y = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)) + setPositionX(x) + setPositionY(y) + setSaturation(x * 100) + const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x) + const lightness = topLightness * (1 - y) + setLightness(lightness) + }, + [isDragging, setSaturation, setLightness] + ) + useEffect(() => { + const handlePointerUp = () => setIsDragging(false) + if (isDragging) { + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp) + } + return () => { + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerUp) + } + }, [isDragging, handlePointerMove]) + return ( +
{ + e.preventDefault() + setIsDragging(true) + handlePointerMove(e.nativeEvent) + }} + ref={containerRef} + style={{ + background: backgroundGradient, + }} + {...props} + > +
+
+ ) +}) +ColorPickerSelection.displayName = 'ColorPickerSelection' + +export type ColorPickerHueProps = ComponentProps + +export const ColorPickerHue = ({ className, ...props }: ColorPickerHueProps) => { + const { hue, setHue } = useColorPicker() + return ( + setHue(hue)} + step={1} + value={[hue]} + {...props} + > + + + + + + ) +} +export type ColorPickerAlphaProps = ComponentProps + +export const ColorPickerAlpha = ({ className, ...props }: ColorPickerAlphaProps) => { + const { alpha, setAlpha } = useColorPicker() + return ( + setAlpha(alpha)} + step={1} + value={[alpha]} + {...props} + > + +
+ + + + + ) +} +export type ColorPickerEyeDropperProps = ComponentProps +export const ColorPickerEyeDropper = ({ className, ...props }: ColorPickerEyeDropperProps) => { + const { setHue, setSaturation, setLightness, setAlpha } = useColorPicker() + const handleEyeDropper = async () => { + try { + // @ts-expect-error - EyeDropper API is experimental + const eyeDropper = new EyeDropper() + const result = await eyeDropper.open() + const color = Color(result.sRGBHex) + const [h, s, l] = color.hsl().array() + setHue(h) + setSaturation(s) + setLightness(l) + setAlpha(100) + } catch (error) { + console.error('EyeDropper failed:', error) + } + } + return ( + + ) +} + +const formats = ['hex', 'rgb', 'css', 'hsl'] + +export type ColorPickerOutputProps = ComponentProps + +export const ColorPickerOutput = ({ className, ...props }: ColorPickerOutputProps) => { + const { mode, setMode } = useColorPicker() + + return ( + + ) +} +type PercentageInputProps = ComponentProps +const PercentageInput = ({ className, ...props }: PercentageInputProps) => { + return ( +
+ + + % + +
+ ) +} +export type ColorPickerFormatProps = HTMLAttributes +export const ColorPickerFormat = ({ className, ...props }: ColorPickerFormatProps) => { + const { hue, saturation, lightness, alpha, mode } = useColorPicker() + const color = Color.hsl(hue, saturation, lightness, alpha / 100) + if (mode === 'hex') { + const hex = color.hex() + return ( +
+ + +
+ ) + } + if (mode === 'rgb') { + const rgb = color + .rgb() + .array() + .map((value) => Math.round(value)) + return ( +
+ {rgb.map((value, index) => ( + + ))} + +
+ ) + } + if (mode === 'css') { + const rgb = color + .rgb() + .array() + .map((value) => Math.round(value)) + return ( +
+ +
+ ) + } + if (mode === 'hsl') { + const hsl = color + .hsl() + .array() + .map((value) => Math.round(value)) + return ( +
+ {hsl.map((value, index) => ( + + ))} + +
+ ) + } + return null +} diff --git a/packages/react/v2/components/primitives/drop-zone.tsx b/packages/react/v2/components/primitives/drop-zone.tsx new file mode 100644 index 00000000..a9d01205 --- /dev/null +++ b/packages/react/v2/components/primitives/drop-zone.tsx @@ -0,0 +1,308 @@ +'use client' + +import type { ReactNode } from 'react' +import React from 'react' +import type { DropEvent, DropzoneOptions, DropzoneState, FileRejection } from 'react-dropzone' +import { useDropzone as useReactDropzone } from 'react-dropzone' + +import { PaperclipIcon } from '@phosphor-icons/react' + +import { Button } from './button' +import { Typography } from './typography' + +import { createRequiredContext } from '../../../src/react/hooks/create-required-context' +import { cn } from '../../../src/react/utils/cn' +declare namespace Intl { + type ListType = 'conjunction' | 'disjunction' + + interface ListFormatOptions { + localeMatcher?: 'lookup' | 'best fit' + type?: ListType + style?: 'long' | 'short' | 'narrow' + } + + interface ListFormatPart { + type: 'element' | 'literal' + value: string + } + + class ListFormat { + constructor(locales?: string | string[], options?: ListFormatOptions) + format(values: any[]): string + formatToParts(values: any[]): ListFormatPart[] + supportedLocalesOf(locales: string | string[], options?: ListFormatOptions): string[] + } +} + +interface DropzoneContextType extends DropzoneState { + src?: File[] + previews: { type: string; url: string }[] + accept?: DropzoneOptions['accept'] + maxSize?: DropzoneOptions['maxSize'] + minSize?: DropzoneOptions['minSize'] + maxFiles?: DropzoneOptions['maxFiles'] +} + +interface FinalDropzoneContextType extends DropzoneContextType { + caption: string +} + +const renderBytes = (bytes: number) => { + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] + let size = bytes + let unitIndex = 0 + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024 + unitIndex++ + } + + return `${size.toFixed(2)}${units[unitIndex]}` +} + +export const [_DropzoneProvider, useDropZone] = createRequiredContext< + DropzoneContextType, + FinalDropzoneContextType +>('Dropzone context', { + valueMapper(value) { + let caption = '' + + if (value.accept) { + caption += 'Accepts ' + caption += new Intl.ListFormat('en').format(Object.keys(value.accept)) + } + + if (value.minSize && value.maxSize) { + caption += ` between ${renderBytes(value.minSize)} and ${renderBytes(value.maxSize)}` + } else if (value.minSize) { + caption += ` at least ${renderBytes(value.minSize)}` + } else if (value.maxSize) { + caption += ` less than ${renderBytes(value.maxSize)}` + } + + return { ...value, caption } + }, +}) + +export interface DropzoneProps extends Omit { + id?: string + src: File[] + previews: { type: string; url: string }[] + className?: string + onDrop?: (acceptedFiles: File[], fileRejections: FileRejection[], event: DropEvent) => void + children?: ReactNode +} + +/** + * @description Dropzone provider, children under this component can use `useDropZone` + * @default maxSize is 20MB + */ +export const DropzoneProvider = ({ + src, + accept, + maxSize, + minSize, + maxFiles = 1, + previews = [], + onDrop, + onError, + disabled, + children, + ...props +}: DropzoneProps) => { + const dropzoneCtx = useReactDropzone({ + accept, + maxFiles, + maxSize: maxSize ?? 1024 * 1024 * 20, // 20MB + minSize, + onError, + disabled, + onDrop: (acceptedFiles, fileRejections, event) => { + if (fileRejections.length > 0) { + const message = fileRejections.at(0)?.errors.at(0)?.message + onError?.(new Error(message)) + return + } + + onDrop?.(acceptedFiles, fileRejections, event) + }, + ...props, + }) + + return ( + <_DropzoneProvider + src={src} + previews={previews} + accept={accept} + maxSize={maxSize} + minSize={minSize} + maxFiles={maxFiles} + {...dropzoneCtx} + > + {children} + + ) +} + +/** + * @description render if no previewable item + */ +export const DropZoneNonemptyContent = (props: { children?: React.ReactNode }) => { + const dropzoneCtx = useDropZone() + + if (dropzoneCtx.previews.length === 0) return null + + return props.children +} + +/** + * @description render if has a previewable item + */ +export const DropZoneEmptyContent = (props: { children?: React.ReactNode }) => { + const dropzoneCtx = useDropZone() + + if (dropzoneCtx.previews.length > 0) return null + + return props.children +} + +export const DropZoneArea = (props: React.ComponentPropsWithRef<'button'>) => { + const dropzoneCtx = useDropZone() + + return ( + + ) +} + +export type DropzoneContentProps = { + children?: ReactNode + className?: string +} + +// /** +// * @description `DropzoneContent` displays content after user upload media, +// * you can pass a children as a custom renderer and empower with `useDropZone` +// * hook to access underlying uploaded items data +// */ +// export const DropzoneContent = ({ children, className }: DropzoneContentProps) => { +// const { previews } = useDropZone() + +// if (previews.length === 0) return null + +// if (children) return children + +// return ( +//
+// {previews?.map(({ type, url }) => ( +// +// {type.startsWith('image/') ? ( +// URL.revokeObjectURL(url)} /> +// ) : type.startsWith('video/') ? ( +// +// ) : type.startsWith('audio/') ? ( +// +// ) : ( +// Unsupported file type +// )} +// +// ))} +//
+// ) +// } + +export type DropzoneEmptyStateProps = { + children?: ReactNode + className?: string +} + +export const DropZoneEmptyUploadButton = ({ + className, + ...props +}: React.ComponentPropsWithRef) => { + const { maxFiles } = useDropZone() + + return ( +