mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 22:04:56 +00:00
refactor: modularize css class builder
This commit is contained in:
@@ -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 "{searchQuery}".
|
||||
</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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user