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:
2026-01-20 16:29:55 +00:00
parent e65538bc8d
commit 640ff11189
12 changed files with 738 additions and 333 deletions

View File

@@ -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>

View File

@@ -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;
}

View 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);
}

View File

@@ -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'

View 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);
}

View File

@@ -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'

View 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);
}

View File

@@ -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'

View 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);
}
}

View File

@@ -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

View 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);
}
}

View File

@@ -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'