mirror of
https://github.com/johndoe6345789/snippet-pastebin.git
synced 2026-04-24 13:34:55 +00:00
refactor: migrate core UI components to M3 SCSS without Radix/Tailwind
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4.5) <aider@aider.chat>
This commit is contained in:
@@ -3,9 +3,9 @@ import { MonacoEditor } from '@/components/features/snippet-editor/MonacoEditor'
|
||||
import { ReactPreview } from '@/components/features/snippet-editor/ReactPreview'
|
||||
import { PythonOutput } from '@/components/features/python-runner/PythonOutput'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
|
||||
import { Code, Eye, SplitHorizontal } from '@phosphor-icons/react'
|
||||
import { InputParameter } from '@/lib/types'
|
||||
import styles from './split-screen-editor.module.scss'
|
||||
|
||||
interface SplitScreenEditorProps {
|
||||
value: string
|
||||
@@ -43,43 +43,40 @@ export function SplitScreenEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-md">
|
||||
<div className={styles.container}>
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.buttonGroup}>
|
||||
<Button
|
||||
variant={viewMode === 'code' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('code')}
|
||||
className="gap-2 h-8"
|
||||
className={styles.button}
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Code</span>
|
||||
<Code className={styles.buttonIcon} />
|
||||
<span className={styles.buttonLabel}>Code</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'split' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('split')}
|
||||
className="gap-2 h-8"
|
||||
className={styles.button}
|
||||
>
|
||||
<SplitHorizontal className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Split</span>
|
||||
<SplitHorizontal className={styles.buttonIcon} />
|
||||
<span className={styles.buttonLabel}>Split</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'preview' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('preview')}
|
||||
className="gap-2 h-8"
|
||||
className={styles.button}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{isPython ? 'Output' : 'Preview'}</span>
|
||||
<Eye className={styles.buttonIcon} />
|
||||
<span className={styles.buttonLabel}>{isPython ? 'Output' : 'Preview'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-md border border-border overflow-hidden bg-card"
|
||||
style={{ height }}
|
||||
>
|
||||
<div className={styles.viewport} style={{ height }}>
|
||||
{viewMode === 'code' && (
|
||||
<MonacoEditor
|
||||
value={value}
|
||||
@@ -103,17 +100,16 @@ export function SplitScreenEditor({
|
||||
)}
|
||||
|
||||
{viewMode === 'split' && (
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full">
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className={styles.splitView}>
|
||||
<div className={styles.editorPanel}>
|
||||
<MonacoEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
language={language}
|
||||
height="100%"
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
</div>
|
||||
<div className={styles.previewPanel}>
|
||||
{isPython ? (
|
||||
<PythonOutput code={value} />
|
||||
) : (
|
||||
@@ -124,8 +120,8 @@ export function SplitScreenEditor({
|
||||
inputParameters={inputParameters}
|
||||
/>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background-color: var(--mat-sys-surface-variant);
|
||||
border-radius: var(--mat-sys-corner-small);
|
||||
}
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.buttonIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.buttonLabel {
|
||||
@media (max-width: 640px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.viewport {
|
||||
border-radius: var(--mat-sys-corner-small);
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
overflow: hidden;
|
||||
background-color: var(--mat-sys-surface);
|
||||
}
|
||||
|
||||
.splitView {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
height: 100%;
|
||||
background-color: var(--mat-sys-outline-variant);
|
||||
}
|
||||
|
||||
.editorPanel,
|
||||
.previewPanel {
|
||||
background-color: var(--mat-sys-surface);
|
||||
overflow: auto;
|
||||
}
|
||||
70
src/components/ui/accordion.module.scss
Normal file
70
src/components/ui/accordion.module.scss
Normal file
@@ -0,0 +1,70 @@
|
||||
.accordion {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item {
|
||||
border-bottom: 1px solid var(--mat-sys-outline-variant);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 16px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--mat-sys-body-large-font);
|
||||
font-size: var(--mat-sys-body-large-size);
|
||||
font-weight: var(--mat-sys-body-large-weight);
|
||||
color: var(--mat-sys-on-surface);
|
||||
text-align: left;
|
||||
transition: color 200ms;
|
||||
|
||||
&:hover {
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mat-sys-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.38;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
transition: transform 200ms;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.iconOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.content {
|
||||
overflow: hidden;
|
||||
transition: max-height 300ms ease-in-out;
|
||||
max-height: 1000px;
|
||||
}
|
||||
|
||||
.contentClosed {
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.contentInner {
|
||||
padding: 0 0 16px;
|
||||
font-family: var(--mat-sys-body-medium-font);
|
||||
font-size: var(--mat-sys-body-medium-size);
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
@@ -1,64 +1,108 @@
|
||||
import { ComponentProps } from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import ChevronDownIcon from "lucide-react/dist/esm/icons/chevron-down"
|
||||
import { ComponentProps, forwardRef, useState, createContext, useContext } from 'react'
|
||||
import styles from './accordion.module.scss'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CaretDown } from '@phosphor-icons/react'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
interface AccordionContextValue {
|
||||
openItems: Set<string>
|
||||
toggleItem: (value: string) => void
|
||||
type: 'single' | 'multiple'
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
const AccordionContext = createContext<AccordionContextValue | null>(null)
|
||||
|
||||
interface AccordionProps extends ComponentProps<'div'> {
|
||||
type?: 'single' | 'multiple'
|
||||
defaultValue?: string | string[]
|
||||
}
|
||||
|
||||
export function Accordion({
|
||||
type = 'single',
|
||||
defaultValue,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
...props
|
||||
}: AccordionProps) {
|
||||
const [openItems, setOpenItems] = useState<Set<string>>(() => {
|
||||
if (!defaultValue) return new Set()
|
||||
return new Set(Array.isArray(defaultValue) ? defaultValue : [defaultValue])
|
||||
})
|
||||
|
||||
const toggleItem = (value: string) => {
|
||||
setOpenItems(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(value)) {
|
||||
next.delete(value)
|
||||
} else {
|
||||
if (type === 'single') {
|
||||
next.clear()
|
||||
}
|
||||
next.add(value)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
<AccordionContext.Provider value={{ openItems, toggleItem, type }}>
|
||||
<div className={cn(styles.accordion, className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
export const AccordionItem = forwardRef<HTMLDivElement, ComponentProps<'div'> & { value: string }>(
|
||||
({ className, value, ...props }, ref) => (
|
||||
<div ref={ref} className={cn(styles.item, className)} data-value={value} {...props} />
|
||||
)
|
||||
)
|
||||
AccordionItem.displayName = 'AccordionItem'
|
||||
|
||||
interface AccordionTriggerProps extends ComponentProps<'button'> {
|
||||
value?: string
|
||||
}
|
||||
|
||||
export const AccordionTrigger = forwardRef<HTMLButtonElement, AccordionTriggerProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const context = useContext(AccordionContext)
|
||||
const item = (ref as any)?.current?.closest('[data-value]')
|
||||
const value = item?.getAttribute('data-value') || ''
|
||||
const isOpen = context?.openItems.has(value)
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(styles.trigger, className)}
|
||||
onClick={() => context?.toggleItem(value)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
<CaretDown className={cn(styles.icon, isOpen && styles.iconOpen)} weight="bold" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
AccordionTrigger.displayName = 'AccordionTrigger'
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
export const AccordionContent = forwardRef<HTMLDivElement, ComponentProps<'div'>>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const context = useContext(AccordionContext)
|
||||
const item = (ref as any)?.current?.closest('[data-value]')
|
||||
const value = item?.getAttribute('data-value') || ''
|
||||
const isOpen = context?.openItems.has(value)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(styles.content, !isOpen && styles.contentClosed, className)}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.contentInner}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
AccordionContent.displayName = 'AccordionContent'
|
||||
|
||||
51
src/components/ui/checkbox.module.scss
Normal file
51
src/components/ui/checkbox.module.scss
Normal file
@@ -0,0 +1,51 @@
|
||||
.container {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
&:focus-visible + .indicator {
|
||||
outline: 2px solid var(--mat-sys-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--mat-sys-on-surface-variant);
|
||||
border-radius: var(--mat-sys-corner-extra-small);
|
||||
background-color: transparent;
|
||||
transition: all 200ms;
|
||||
|
||||
.input:hover + & {
|
||||
border-color: var(--mat-sys-on-surface);
|
||||
background-color: color-mix(in srgb, var(--mat-sys-on-surface) 8%, transparent);
|
||||
}
|
||||
|
||||
.input:checked + & {
|
||||
border-color: var(--mat-sys-primary);
|
||||
background-color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
.input:disabled + & {
|
||||
opacity: 0.38;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--mat-sys-on-primary);
|
||||
}
|
||||
@@ -1,30 +1,33 @@
|
||||
"use client"
|
||||
import { ComponentProps, forwardRef } from 'react'
|
||||
import styles from './checkbox.module.scss'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check, Minus } from '@phosphor-icons/react'
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import Check from "lucide-react/dist/esm/icons/check"
|
||||
interface CheckboxProps extends Omit<ComponentProps<'input'>, 'type'> {
|
||||
checked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
indeterminate?: boolean
|
||||
}
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ className, checked, onCheckedChange, indeterminate, ...props }, ref) => (
|
||||
<label className={cn(styles.container, className)}>
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
className={styles.input}
|
||||
checked={checked}
|
||||
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||
{...props}
|
||||
/>
|
||||
<span className={styles.indicator}>
|
||||
{indeterminate ? (
|
||||
<Minus className={styles.icon} weight="bold" />
|
||||
) : checked ? (
|
||||
<Check className={styles.icon} weight="bold" />
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
)
|
||||
Checkbox.displayName = 'Checkbox'
|
||||
|
||||
55
src/components/ui/radio-group.module.scss
Normal file
55
src/components/ui/radio-group.module.scss
Normal file
@@ -0,0 +1,55 @@
|
||||
.group {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
&:focus-visible + .indicator {
|
||||
outline: 2px solid var(--mat-sys-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--mat-sys-on-surface-variant);
|
||||
border-radius: 50%;
|
||||
background-color: transparent;
|
||||
transition: all 200ms;
|
||||
|
||||
.input:hover + & {
|
||||
border-color: var(--mat-sys-on-surface);
|
||||
background-color: color-mix(in srgb, var(--mat-sys-on-surface) 8%, transparent);
|
||||
}
|
||||
|
||||
.input:checked + & {
|
||||
border-color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
.input:disabled + & {
|
||||
opacity: 0.38;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
@@ -1,44 +1,49 @@
|
||||
"use client"
|
||||
import { ComponentProps, forwardRef, createContext, useContext } from 'react'
|
||||
import styles from './radio-group.module.scss'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Circle } from '@phosphor-icons/react'
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import Circle from "lucide-react/dist/esm/icons/circle"
|
||||
interface RadioGroupContextValue {
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
}
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null)
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
interface RadioGroupProps extends ComponentProps<'div'> {
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
|
||||
({ className, value, onValueChange, ...props }, ref) => (
|
||||
<RadioGroupContext.Provider value={{ value, onValueChange }}>
|
||||
<div ref={ref} className={cn(styles.group, className)} role="radiogroup" {...props} />
|
||||
</RadioGroupContext.Provider>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
)
|
||||
RadioGroup.displayName = 'RadioGroup'
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
export const RadioGroupItem = forwardRef<HTMLInputElement, ComponentProps<'input'> & { value: string }>(
|
||||
({ className, value, ...props }, ref) => {
|
||||
const context = useContext(RadioGroupContext)
|
||||
const isChecked = context?.value === value
|
||||
|
||||
return (
|
||||
<label className={cn(styles.item, className)}>
|
||||
<input
|
||||
ref={ref}
|
||||
type="radio"
|
||||
className={styles.input}
|
||||
checked={isChecked}
|
||||
onChange={() => context?.onValueChange?.(value)}
|
||||
{...props}
|
||||
/>
|
||||
<span className={styles.indicator}>
|
||||
{isChecked && <Circle className={styles.icon} weight="fill" />}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
)
|
||||
RadioGroupItem.displayName = 'RadioGroupItem'
|
||||
|
||||
127
src/components/ui/select.module.scss
Normal file
127
src/components/ui/select.module.scss
Normal file
@@ -0,0 +1,127 @@
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 120px;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--mat-sys-outline);
|
||||
border-radius: var(--mat-sys-corner-small);
|
||||
background-color: var(--mat-sys-surface);
|
||||
color: var(--mat-sys-on-surface);
|
||||
font-family: var(--mat-sys-body-large-font);
|
||||
font-size: var(--mat-sys-body-large-size);
|
||||
cursor: pointer;
|
||||
transition: all 200ms;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--mat-sys-on-surface);
|
||||
background-color: var(--mat-sys-surface-variant);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mat-sys-primary);
|
||||
border-color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.38;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.triggerSm {
|
||||
height: 32px;
|
||||
font-size: var(--mat-sys-body-medium-size);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: fixed;
|
||||
z-index: 1001;
|
||||
min-width: 180px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
background-color: var(--mat-sys-surface-container);
|
||||
border-radius: var(--mat-sys-corner-small);
|
||||
box-shadow: var(--mat-sys-level2);
|
||||
animation: slideIn 200ms;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 48px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--mat-sys-corner-extra-small);
|
||||
cursor: pointer;
|
||||
font-family: var(--mat-sys-body-large-font);
|
||||
font-size: var(--mat-sys-body-large-size);
|
||||
color: var(--mat-sys-on-surface);
|
||||
transition: background-color 200ms;
|
||||
|
||||
&:hover {
|
||||
background-color: color-mix(in srgb, var(--mat-sys-on-surface) 8%, transparent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
background-color: color-mix(in srgb, var(--mat-sys-on-surface) 12%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.itemSelected {
|
||||
background-color: var(--mat-sys-secondary-container);
|
||||
color: var(--mat-sys-on-secondary-container);
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
.group {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
padding: 8px 16px;
|
||||
font-family: var(--mat-sys-label-small-font);
|
||||
font-size: var(--mat-sys-label-small-size);
|
||||
font-weight: var(--mat-sys-label-small-weight);
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 1px;
|
||||
margin: 8px 0;
|
||||
background-color: var(--mat-sys-outline-variant);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -1,185 +1,124 @@
|
||||
import { ComponentProps } from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import CheckIcon from "lucide-react/dist/esm/icons/check"
|
||||
import ChevronDownIcon from "lucide-react/dist/esm/icons/chevron-down"
|
||||
import ChevronUpIcon from "lucide-react/dist/esm/icons/chevron-up"
|
||||
import { ComponentProps, forwardRef, useState, createContext, useContext } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from './select.module.scss'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CaretDown, Check } from '@phosphor-icons/react'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
interface SelectContextValue {
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
const SelectContext = createContext<SelectContextValue | null>(null)
|
||||
|
||||
interface SelectProps {
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
export function Select({ value, onValueChange, children }: SelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectContext.Provider value={{ value, onValueChange, open, setOpen }}>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
</SelectContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
export const SelectTrigger = forwardRef<HTMLButtonElement, ComponentProps<'button'> & { size?: 'sm' | 'default' }>(
|
||||
({ className, children, size = 'default', ...props }, ref) => {
|
||||
const context = useContext(SelectContext)
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(styles.trigger, size === 'sm' && styles.triggerSm, className)}
|
||||
onClick={() => context?.setOpen(!context.open)}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
{children}
|
||||
<CaretDown className={styles.icon} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
SelectTrigger.displayName = 'SelectTrigger'
|
||||
|
||||
export const SelectValue = ({ placeholder }: { placeholder?: string }) => {
|
||||
const context = useContext(SelectContext)
|
||||
return <span>{context?.value || placeholder}</span>
|
||||
}
|
||||
|
||||
export const SelectContent = forwardRef<HTMLDivElement, ComponentProps<'div'>>(
|
||||
({ className, children, position = 'popper', ...props }, ref) => {
|
||||
const context = useContext(SelectContext)
|
||||
|
||||
if (!context?.open) return null
|
||||
|
||||
return createPortal(
|
||||
<div className={styles.overlay} onClick={() => context.setOpen(false)}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(styles.content, className)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
)
|
||||
SelectContent.displayName = 'SelectContent'
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export const SelectItem = forwardRef<HTMLDivElement, ComponentProps<'div'> & { value: string }>(
|
||||
({ className, children, value, ...props }, ref) => {
|
||||
const context = useContext(SelectContext)
|
||||
const isSelected = context?.value === value
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(styles.item, isSelected && styles.itemSelected, className)}
|
||||
onClick={() => {
|
||||
context?.onValueChange?.(value)
|
||||
context?.setOpen(false)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{isSelected && <Check className={styles.checkmark} weight="bold" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
SelectItem.displayName = 'SelectItem'
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
export const SelectGroup = forwardRef<HTMLDivElement, ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn(styles.group, className)} {...props} />
|
||||
)
|
||||
}
|
||||
)
|
||||
SelectGroup.displayName = 'SelectGroup'
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
export const SelectLabel = forwardRef<HTMLDivElement, ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn(styles.label, className)} {...props} />
|
||||
)
|
||||
}
|
||||
)
|
||||
SelectLabel.displayName = 'SelectLabel'
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
export const SelectSeparator = forwardRef<HTMLDivElement, ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn(styles.separator, className)} {...props} />
|
||||
)
|
||||
}
|
||||
)
|
||||
SelectSeparator.displayName = 'SelectSeparator'
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
export const SelectScrollUpButton = () => null
|
||||
export const SelectScrollDownButton = () => null
|
||||
|
||||
58
src/components/ui/switch.module.scss
Normal file
58
src/components/ui/switch.module.scss
Normal file
@@ -0,0 +1,58 @@
|
||||
.container {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
&:focus-visible + .track {
|
||||
outline: 2px solid var(--mat-sys-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 52px;
|
||||
height: 32px;
|
||||
border-radius: var(--mat-sys-corner-full);
|
||||
background-color: var(--mat-sys-surface-variant);
|
||||
border: 2px solid var(--mat-sys-outline);
|
||||
transition: all 200ms;
|
||||
|
||||
.input:checked + & {
|
||||
background-color: var(--mat-sys-primary);
|
||||
border-color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
.input:disabled + & {
|
||||
opacity: 0.38;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 8px;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: var(--mat-sys-corner-full);
|
||||
background-color: var(--mat-sys-outline);
|
||||
transition: all 200ms;
|
||||
|
||||
.input:checked + .track & {
|
||||
left: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: var(--mat-sys-on-primary);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,27 @@
|
||||
"use client"
|
||||
import { ComponentProps, forwardRef } from 'react'
|
||||
import styles from './switch.module.scss'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
interface SwitchProps extends Omit<ComponentProps<'input'>, 'type'> {
|
||||
checked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
}
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
|
||||
({ className, checked, onCheckedChange, ...props }, ref) => (
|
||||
<label className={cn(styles.container, className)}>
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
className={styles.input}
|
||||
checked={checked}
|
||||
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||
{...props}
|
||||
/>
|
||||
<span className={styles.track}>
|
||||
<span className={styles.thumb} />
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
)
|
||||
Switch.displayName = 'Switch'
|
||||
|
||||
Reference in New Issue
Block a user