refactor: modularize css class builder

This commit is contained in:
2025-12-27 18:49:26 +00:00
parent cadaa8c5fe
commit cb90ae91b5
4 changed files with 328 additions and 216 deletions

View File

@@ -1,14 +1,12 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui'
import { Badge } from '@/components/ui'
import { Button } from '@/components/ui'
import { Input } from '@/components/ui'
import { Label } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { Badge } from '@/components/ui'
import { Database } from '@/lib/database'
import { Plus, X, FloppyDisk } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { useClassBuilderState } from './class-builder/hooks'
import { Preview } from './class-builder/Preview'
import { RuleEditor } from './class-builder/RuleEditor'
import { X, FloppyDisk } from '@phosphor-icons/react'
interface CssClassBuilderProps {
open: boolean
@@ -17,119 +15,30 @@ interface CssClassBuilderProps {
onSave: (classes: string) => void
}
interface CssCategory {
name: string
classes: string[]
}
// eslint-disable-next-line no-useless-escape
const CLASS_TOKEN_PATTERN = /^[A-Za-z0-9:_/.\[\]()%#!,=+-]+$/
const parseClassList = (value: string) => Array.from(new Set(value.split(/\s+/).filter(Boolean)))
export function CssClassBuilder({ open, onClose, initialValue = '', onSave }: CssClassBuilderProps) {
const [selectedClasses, setSelectedClasses] = useState<string[]>([])
const [categories, setCategories] = useState<CssCategory[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [customClass, setCustomClass] = useState('')
const [activeTab, setActiveTab] = useState('custom')
const {
categories,
filteredCategories,
selectedClasses,
selectedClassSet,
searchQuery,
setSearchQuery,
activeTab,
setActiveTab,
customClass,
setCustomClass,
invalidCustomTokens,
duplicateCustomTokens,
unknownCustomTokens,
canAddCustom,
addCustomClass,
toggleClass,
clearSelectedClasses,
} = useClassBuilderState({ open, initialValue })
const knownClassSet = useMemo(
() => new Set(categories.flatMap((category) => category.classes)),
[categories]
)
const selectedClassSet = useMemo(() => new Set(selectedClasses), [selectedClasses])
const normalizedSearch = searchQuery.trim().toLowerCase()
const filteredCategories = useMemo(() => {
if (!normalizedSearch) {
return categories
}
return categories
.map((category) => ({
...category,
classes: category.classes.filter((cls) => cls.toLowerCase().includes(normalizedSearch)),
}))
.filter((category) => category.classes.length > 0)
}, [categories, normalizedSearch])
const customTokens = customClass.trim().split(/\s+/).filter(Boolean)
const uniqueCustomTokens = Array.from(new Set(customTokens))
const invalidCustomTokens = uniqueCustomTokens.filter((token) => !CLASS_TOKEN_PATTERN.test(token))
const duplicateCustomTokens = uniqueCustomTokens.filter((token) => selectedClassSet.has(token))
const unknownCustomTokens = uniqueCustomTokens.filter((token) => !knownClassSet.has(token))
const canAddCustom =
uniqueCustomTokens.length > 0 &&
invalidCustomTokens.length === 0 &&
uniqueCustomTokens.some((token) => !selectedClassSet.has(token))
const loadCssClasses = useCallback(async () => {
const classes = await Database.getCssClasses()
const sorted = classes.slice().sort((a, b) => a.name.localeCompare(b.name))
setCategories(sorted)
}, [])
useEffect(() => {
if (open) {
loadCssClasses()
setSelectedClasses(parseClassList(initialValue))
setSearchQuery('')
setCustomClass('')
}
}, [open, initialValue, loadCssClasses])
useEffect(() => {
if (!open) {
return
}
if (filteredCategories.length === 0) {
setActiveTab('custom')
return
}
if (activeTab === 'custom') {
return
}
const hasActiveTab = filteredCategories.some((category) => category.name === activeTab)
if (!hasActiveTab) {
setActiveTab(filteredCategories[0]?.name ?? 'custom')
}
}, [activeTab, filteredCategories, open])
const toggleClass = (cssClass: string) => {
setSelectedClasses(current => {
if (current.includes(cssClass)) {
return current.filter(c => c !== cssClass)
} else {
return [...current, cssClass]
}
})
}
const addCustomClass = () => {
if (uniqueCustomTokens.length === 0) {
return
}
if (invalidCustomTokens.length > 0) {
toast.error(`Invalid class name: ${invalidCustomTokens.join(', ')}`)
return
}
const newTokens = uniqueCustomTokens.filter((token) => !selectedClassSet.has(token))
if (newTokens.length === 0) {
toast.info('Those classes are already selected')
return
}
setSelectedClasses((current) => [...current, ...newTokens])
setCustomClass('')
}
const clearSelectedClasses = () => {
setSelectedClasses([])
}
const normalizedSearch = searchQuery.trim()
const hasNoCategories = filteredCategories.length === 0 && categories.length === 0
const hasNoSearchResults = filteredCategories.length === 0 && categories.length > 0 && normalizedSearch
const handleSave = () => {
onSave(selectedClasses.join(' '))
@@ -176,21 +85,16 @@ export function CssClassBuilder({ open, onClose, initialValue = '', onSave }: Cs
</div>
</div>
<div className="flex flex-wrap gap-2">
{selectedClasses.map(cls => (
{selectedClasses.map((cls) => (
<Badge key={cls} variant="secondary" className="gap-2">
{cls}
<button
onClick={() => toggleClass(cls)}
className="hover:text-destructive"
>
<button onClick={() => toggleClass(cls)} className="hover:text-destructive">
<X size={14} />
</button>
</Badge>
))}
</div>
<div className="rounded border bg-background p-2 font-mono text-sm">
{selectedClasses.join(' ')}
</div>
<div className="rounded border bg-background p-2 font-mono text-sm">{selectedClasses.join(' ')}</div>
</div>
) : (
<div className="p-4 border rounded-lg bg-muted/30 text-sm text-muted-foreground">
@@ -198,106 +102,34 @@ export function CssClassBuilder({ open, onClose, initialValue = '', onSave }: Cs
</div>
)}
<div className="p-4 border rounded-lg bg-muted/30 space-y-2">
<Label className="text-xs uppercase tracking-wider">Preview</Label>
<div className="rounded-md border border-dashed bg-background p-3">
<div className={`rounded-md p-4 transition-all ${selectedClasses.join(' ')}`}>
<div className="text-sm font-semibold">Preview element</div>
<div className="text-xs text-muted-foreground">
This sample updates as you add or remove classes.
</div>
<div className="mt-3 inline-flex items-center rounded-md border px-3 py-1 text-xs">
Sample button
</div>
</div>
</div>
</div>
<Preview selectedClasses={selectedClasses} />
{filteredCategories.length === 0 && categories.length === 0 && (
{hasNoCategories && (
<div className="rounded-lg border bg-muted/30 p-4 text-sm text-muted-foreground">
No CSS categories available yet. Add some in the CSS Classes tab.
</div>
)}
{filteredCategories.length === 0 && categories.length > 0 && normalizedSearch && (
{hasNoSearchResults && (
<div className="rounded-lg border bg-muted/30 p-4 text-sm text-muted-foreground">
No classes match &quot;{searchQuery}&quot;.
</div>
)}
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1">
<div className="overflow-x-auto">
<TabsList className="w-max">
{filteredCategories.map(category => (
<TabsTrigger key={category.name} value={category.name}>
{category.name}
</TabsTrigger>
))}
<TabsTrigger value="custom">Custom</TabsTrigger>
</TabsList>
</div>
{filteredCategories.map(category => (
<TabsContent key={category.name} value={category.name}>
<ScrollArea className="h-[300px] border rounded-lg p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{category.classes.map(cls => (
<button
key={cls}
onClick={() => toggleClass(cls)}
aria-pressed={selectedClassSet.has(cls)}
className={`
px-3 py-2 text-sm rounded border text-left font-mono transition-all duration-150 active:scale-95
${selectedClassSet.has(cls)
? 'bg-primary text-primary-foreground border-primary'
: 'bg-card hover:bg-accent hover:text-accent-foreground'
}
`}
>
{cls}
</button>
))}
</div>
</ScrollArea>
</TabsContent>
))}
<TabsContent value="custom">
<div className="border rounded-lg p-4 space-y-3">
<div className="flex gap-2">
<Input
placeholder="Enter custom class name..."
value={customClass}
onChange={(e) => setCustomClass(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && canAddCustom && addCustomClass()}
className={`font-mono ${invalidCustomTokens.length > 0 ? 'border-destructive focus-visible:ring-destructive' : ''}`}
/>
<Button onClick={addCustomClass} disabled={!canAddCustom}>
<Plus className="mr-2" />
Add
</Button>
</div>
{invalidCustomTokens.length > 0 && (
<p className="text-xs text-destructive">
Invalid class names: {invalidCustomTokens.join(', ')}
</p>
)}
{invalidCustomTokens.length === 0 && unknownCustomTokens.length > 0 && (
<p className="text-xs text-muted-foreground">
Not in library: {unknownCustomTokens.join(', ')}. They will still be added.
</p>
)}
{duplicateCustomTokens.length > 0 && (
<p className="text-xs text-muted-foreground">
Already selected: {duplicateCustomTokens.join(', ')}
</p>
)}
<p className="text-xs text-muted-foreground">
Add custom CSS classes that aren't in the predefined list.
</p>
</div>
</TabsContent>
</Tabs>
<RuleEditor
filteredCategories={filteredCategories}
activeTab={activeTab}
onTabChange={setActiveTab}
selectedClassSet={selectedClassSet}
toggleClass={toggleClass}
customClass={customClass}
setCustomClass={setCustomClass}
canAddCustom={canAddCustom}
addCustomClass={addCustomClass}
invalidCustomTokens={invalidCustomTokens}
duplicateCustomTokens={duplicateCustomTokens}
unknownCustomTokens={unknownCustomTokens}
/>
</div>
<DialogFooter className="gap-2">

View File

@@ -0,0 +1,24 @@
import { Label } from '@/components/ui'
interface PreviewProps {
selectedClasses: string[]
}
export function Preview({ selectedClasses }: PreviewProps) {
return (
<div className="p-4 border rounded-lg bg-muted/30 space-y-2">
<Label className="text-xs uppercase tracking-wider">Preview</Label>
<div className="rounded-md border border-dashed bg-background p-3">
<div className={`rounded-md p-4 transition-all ${selectedClasses.join(' ')}`}>
<div className="text-sm font-semibold">Preview element</div>
<div className="text-xs text-muted-foreground">
This sample updates as you add or remove classes.
</div>
<div className="mt-3 inline-flex items-center rounded-md border px-3 py-1 text-xs">
Sample button
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,109 @@
import { Button, Input, ScrollArea, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import type { CssCategory } from '@/lib/database'
import { Plus } from '@phosphor-icons/react'
interface RuleEditorProps {
filteredCategories: CssCategory[]
activeTab: string
onTabChange: (value: string) => void
selectedClassSet: Set<string>
toggleClass: (cssClass: string) => void
customClass: string
setCustomClass: (value: string) => void
canAddCustom: boolean
addCustomClass: () => void
invalidCustomTokens: string[]
duplicateCustomTokens: string[]
unknownCustomTokens: string[]
}
export function RuleEditor({
filteredCategories,
activeTab,
onTabChange,
selectedClassSet,
toggleClass,
customClass,
setCustomClass,
canAddCustom,
addCustomClass,
invalidCustomTokens,
duplicateCustomTokens,
unknownCustomTokens,
}: RuleEditorProps) {
return (
<Tabs value={activeTab} onValueChange={onTabChange} className="flex-1">
<div className="overflow-x-auto">
<TabsList className="w-max">
{filteredCategories.map((category) => (
<TabsTrigger key={category.name} value={category.name}>
{category.name}
</TabsTrigger>
))}
<TabsTrigger value="custom">Custom</TabsTrigger>
</TabsList>
</div>
{filteredCategories.map((category) => (
<TabsContent key={category.name} value={category.name}>
<ScrollArea className="h-[300px] border rounded-lg p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{category.classes.map((cls) => (
<button
key={cls}
onClick={() => toggleClass(cls)}
aria-pressed={selectedClassSet.has(cls)}
className={`
px-3 py-2 text-sm rounded border text-left font-mono transition-all duration-150 active:scale-95
${selectedClassSet.has(cls)
? 'bg-primary text-primary-foreground border-primary'
: 'bg-card hover:bg-accent hover:text-accent-foreground'
}
`}
>
{cls}
</button>
))}
</div>
</ScrollArea>
</TabsContent>
))}
<TabsContent value="custom">
<div className="border rounded-lg p-4 space-y-3">
<div className="flex gap-2">
<Input
placeholder="Enter custom class name..."
value={customClass}
onChange={(e) => setCustomClass(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && canAddCustom && addCustomClass()}
className={`font-mono ${invalidCustomTokens.length > 0 ? 'border-destructive focus-visible:ring-destructive' : ''}`}
/>
<Button onClick={addCustomClass} disabled={!canAddCustom}>
<Plus className="mr-2" />
Add
</Button>
</div>
{invalidCustomTokens.length > 0 && (
<p className="text-xs text-destructive">
Invalid class names: {invalidCustomTokens.join(', ')}
</p>
)}
{invalidCustomTokens.length === 0 && unknownCustomTokens.length > 0 && (
<p className="text-xs text-muted-foreground">
Not in library: {unknownCustomTokens.join(', ')}. They will still be added.
</p>
)}
{duplicateCustomTokens.length > 0 && (
<p className="text-xs text-muted-foreground">
Already selected: {duplicateCustomTokens.join(', ')}
</p>
)}
<p className="text-xs text-muted-foreground">
Add custom CSS classes that aren't in the predefined list.
</p>
</div>
</TabsContent>
</Tabs>
)
}

View File

@@ -0,0 +1,147 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { Database, CssCategory } from '@/lib/database'
import { toast } from 'sonner'
const CLASS_TOKEN_PATTERN = /^[A-Za-z0-9:_/.\[\]()%#!,=+-]+$/
const parseClassList = (value: string) => Array.from(new Set(value.split(/\s+/).filter(Boolean)))
interface UseClassBuilderStateProps {
open: boolean
initialValue: string
}
export function useClassBuilderState({ open, initialValue }: UseClassBuilderStateProps) {
const [selectedClasses, setSelectedClasses] = useState<string[]>([])
const [categories, setCategories] = useState<CssCategory[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [customClass, setCustomClass] = useState('')
const [activeTab, setActiveTab] = useState('custom')
const knownClassSet = useMemo(() => new Set(categories.flatMap((category) => category.classes)), [categories])
const selectedClassSet = useMemo(() => new Set(selectedClasses), [selectedClasses])
const normalizedSearch = searchQuery.trim().toLowerCase()
const filteredCategories = useMemo(() => {
if (!normalizedSearch) {
return categories
}
return categories
.map((category) => ({
...category,
classes: category.classes.filter((cls) => cls.toLowerCase().includes(normalizedSearch)),
}))
.filter((category) => category.classes.length > 0)
}, [categories, normalizedSearch])
const customTokens = useMemo(() => customClass.trim().split(/\s+/).filter(Boolean), [customClass])
const uniqueCustomTokens = useMemo(() => Array.from(new Set(customTokens)), [customTokens])
const invalidCustomTokens = useMemo(
() => uniqueCustomTokens.filter((token) => !CLASS_TOKEN_PATTERN.test(token)),
[uniqueCustomTokens]
)
const duplicateCustomTokens = useMemo(
() => uniqueCustomTokens.filter((token) => selectedClassSet.has(token)),
[uniqueCustomTokens, selectedClassSet]
)
const unknownCustomTokens = useMemo(
() => uniqueCustomTokens.filter((token) => !knownClassSet.has(token)),
[uniqueCustomTokens, knownClassSet]
)
const canAddCustom = useMemo(
() =>
uniqueCustomTokens.length > 0 &&
invalidCustomTokens.length === 0 &&
uniqueCustomTokens.some((token) => !selectedClassSet.has(token)),
[invalidCustomTokens.length, selectedClassSet, uniqueCustomTokens]
)
const loadCssClasses = useCallback(async () => {
const classes = await Database.getCssClasses()
const sorted = classes.slice().sort((a, b) => a.name.localeCompare(b.name))
setCategories(sorted)
}, [])
useEffect(() => {
if (open) {
loadCssClasses()
setSelectedClasses(parseClassList(initialValue))
setSearchQuery('')
setCustomClass('')
}
}, [open, initialValue, loadCssClasses])
useEffect(() => {
if (!open) {
return
}
if (filteredCategories.length === 0) {
setActiveTab('custom')
return
}
if (activeTab === 'custom') {
return
}
const hasActiveTab = filteredCategories.some((category) => category.name === activeTab)
if (!hasActiveTab) {
setActiveTab(filteredCategories[0]?.name ?? 'custom')
}
}, [activeTab, filteredCategories, open])
const toggleClass = (cssClass: string) => {
setSelectedClasses((current) => {
if (current.includes(cssClass)) {
return current.filter((c) => c !== cssClass)
}
return [...current, cssClass]
})
}
const addCustomClass = () => {
if (uniqueCustomTokens.length === 0) {
return
}
if (invalidCustomTokens.length > 0) {
toast.error(`Invalid class name: ${invalidCustomTokens.join(', ')}`)
return
}
const newTokens = uniqueCustomTokens.filter((token) => !selectedClassSet.has(token))
if (newTokens.length === 0) {
toast.info('Those classes are already selected')
return
}
setSelectedClasses((current) => [...current, ...newTokens])
setCustomClass('')
}
const clearSelectedClasses = () => {
setSelectedClasses([])
}
return {
categories,
filteredCategories,
selectedClasses,
selectedClassSet,
searchQuery,
setSearchQuery,
activeTab,
setActiveTab,
customClass,
setCustomClass,
invalidCustomTokens,
duplicateCustomTokens,
unknownCustomTokens,
canAddCustom,
addCustomClass,
toggleClass,
clearSelectedClasses,
}
}