Generated by Spark: Too risky making changes without refactoring now. Create hook library, All components <150LOC. Consider orchestrating pages using json. JSON can describe actions, hooks, component tree, seed data you name it.

This commit is contained in:
2026-01-16 19:04:10 +00:00
committed by GitHub
parent e29e8b7361
commit c5e486859f
33 changed files with 4612 additions and 42 deletions

View File

@@ -0,0 +1,66 @@
import React, { useCallback } from 'react'
import { PageSchema, ComponentDef } from './schema'
import { getComponent } from './component-registry'
import { useDataSources } from './data-source-manager'
import { executeAction, ActionContext } from './action-executor'
interface PageRendererProps {
schema: PageSchema
onNavigate?: (path: string) => void
customHandlers?: Record<string, (payload?: any) => void | Promise<void>>
}
export function PageRenderer({ schema, onNavigate, customHandlers = {} }: PageRendererProps) {
const { dataMap, updateData, getData } = useDataSources(schema.dataSources || [])
const context: ActionContext = {
navigate: onNavigate || (() => {}),
updateData,
getData,
customHandlers,
}
const handleAction = useCallback(
(actionId: string, payload?: any) => {
const action = schema.actions?.find((a) => a.id === actionId)
if (action) {
executeAction({ ...action, payload: payload || action.payload }, context)
}
},
[schema.actions, context]
)
const renderComponent = (compDef: ComponentDef): React.ReactNode => {
const Component = getComponent(compDef.type)
if (!Component) {
console.warn(`Component ${compDef.type} not found in registry`)
return null
}
const props = { ...compDef.props }
if (compDef.dataBinding && dataMap[compDef.dataBinding]) {
props.data = dataMap[compDef.dataBinding]
}
if (compDef.eventHandlers) {
Object.entries(compDef.eventHandlers).forEach(([event, actionId]) => {
props[event] = (payload?: any) => handleAction(String(actionId), payload)
})
}
const children = compDef.children?.map((child) => renderComponent(child))
return (
<Component key={compDef.id} {...props}>
{children}
</Component>
)
}
return (
<div className="h-full w-full">
{schema.components.map((comp) => renderComponent(comp))}
</div>
)
}

View File

@@ -0,0 +1,78 @@
import { Action } from './schema'
import { toast } from 'sonner'
export type ActionContext = {
navigate: (path: string) => void
updateData: (key: string, value: any) => void
getData: (key: string) => any
customHandlers: Record<string, (payload?: any) => void | Promise<void>>
}
export async function executeAction(
action: Action,
context: ActionContext
): Promise<void> {
try {
switch (action.type) {
case 'navigate':
if (action.target) {
context.navigate(action.target)
}
break
case 'create':
case 'update':
if (action.target && action.payload) {
const currentData = context.getData(action.target) || []
const newData = action.type === 'create'
? [...currentData, action.payload]
: currentData.map((item: any) =>
item.id === action.payload?.id ? { ...item, ...action.payload } : item
)
context.updateData(action.target, newData)
}
break
case 'delete':
if (action.target && action.payload?.id) {
const currentData = context.getData(action.target) || []
const filtered = currentData.filter(
(item: any) => item.id !== action.payload?.id
)
context.updateData(action.target, filtered)
}
break
case 'api':
if (action.payload?.endpoint) {
const response = await fetch(action.payload.endpoint, {
method: action.payload.method || 'GET',
headers: action.payload.headers || {},
body: action.payload.body ? JSON.stringify(action.payload.body) : undefined,
})
const data = await response.json()
if (action.target) {
context.updateData(action.target, data)
}
}
break
case 'custom':
if (action.handler && context.customHandlers[action.handler]) {
await context.customHandlers[action.handler](action.payload)
}
break
case 'transform':
if (action.handler && action.target) {
const sourceData = context.getData(action.payload?.source || action.target)
const transformed = context.customHandlers[action.handler]?.(sourceData)
context.updateData(action.target, transformed)
}
break
}
} catch (error) {
console.error('Action execution failed:', error)
toast.error(`Failed to execute ${action.type} action`)
}
}

View File

@@ -0,0 +1,36 @@
import { ComponentType } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { ProjectDashboard } from '@/components/ProjectDashboard'
import { CodeEditor } from '@/components/CodeEditor'
import { ModelDesigner } from '@/components/ModelDesigner'
import { ComponentTreeBuilder } from '@/components/ComponentTreeBuilder'
import { StyleDesigner } from '@/components/StyleDesigner'
import { FeatureIdeaCloud } from '@/components/FeatureIdeaCloud'
export const ComponentRegistry: Record<string, ComponentType<any>> = {
Button,
Input,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Badge,
Textarea,
ProjectDashboard,
CodeEditor,
ModelDesigner,
ComponentTreeBuilder,
StyleDesigner,
FeatureIdeaCloud,
}
export function getComponent(name: string): ComponentType<any> | null {
return ComponentRegistry[name] || null
}

View File

@@ -0,0 +1,81 @@
import { useKV } from '@github/spark/hooks'
import { DataSource } from './schema'
import { useEffect, useState } from 'react'
export function useDataSource(source: DataSource) {
const [kvData, setKvData] = useKV(source.key || source.id, source.defaultValue)
const [apiData, setApiData] = useState<any>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
if (source.type === 'api' && source.endpoint) {
setLoading(true)
fetch(source.endpoint)
.then((res) => res.json())
.then((data) => {
setApiData(data)
setError(null)
})
.catch((err) => {
setError(err)
setApiData(null)
})
.finally(() => setLoading(false))
}
}, [source.type, source.endpoint])
switch (source.type) {
case 'kv':
return { data: kvData, setData: setKvData, loading: false, error: null }
case 'api':
return { data: apiData, setData: setApiData, loading, error }
case 'static':
return {
data: source.defaultValue,
setData: () => {},
loading: false,
error: null,
}
case 'computed':
return {
data: source.defaultValue,
setData: () => {},
loading: false,
error: null,
}
default:
return {
data: null,
setData: () => {},
loading: false,
error: null,
}
}
}
export function useDataSources(sources: DataSource[]) {
const [dataMap, setDataMapState] = useState<Record<string, any>>({})
const updateData = (key: string, value: any) => {
setDataMapState((prev) => ({ ...prev, [key]: value }))
}
const getData = (key: string) => {
return dataMap[key]
}
useEffect(() => {
sources.forEach((source) => {
if (source.type === 'static' || source.type === 'computed') {
updateData(source.id, source.defaultValue)
}
})
}, [sources])
return {
dataMap,
updateData,
getData,
}
}

View File

@@ -0,0 +1,5 @@
export * from './schema'
export * from './action-executor'
export * from './data-source-manager'
export * from './component-registry'
export { PageRenderer } from './PageRenderer'

View File

@@ -0,0 +1,68 @@
import { z } from 'zod'
export const ActionSchema = z.object({
id: z.string(),
type: z.enum(['create', 'update', 'delete', 'navigate', 'api', 'transform', 'custom']),
target: z.string().optional(),
payload: z.record(z.any()).optional(),
handler: z.string().optional(),
})
export const DataSourceSchema = z.object({
id: z.string(),
type: z.enum(['kv', 'api', 'computed', 'static']),
key: z.string().optional(),
endpoint: z.string().optional(),
transform: z.string().optional(),
defaultValue: z.any().optional(),
dependencies: z.array(z.string()).optional(),
})
export const HookConfigSchema = z.object({
name: z.string(),
params: z.record(z.any()).optional(),
bindings: z.record(z.string()).optional(),
})
export const ComponentPropsSchema = z.record(z.any())
export const ComponentSchema: any = z.object({
id: z.string(),
type: z.string(),
props: ComponentPropsSchema.optional(),
children: z.lazy(() => z.array(ComponentSchema)).optional(),
dataBinding: z.string().optional(),
eventHandlers: z.record(z.string()).optional(),
})
export const LayoutSchema = z.object({
type: z.enum(['single', 'split', 'grid', 'tabs', 'flex']),
direction: z.enum(['horizontal', 'vertical', 'row', 'column']).optional(),
panels: z.array(z.object({
id: z.string(),
minSize: z.number().optional(),
maxSize: z.number().optional(),
defaultSize: z.number().optional(),
})).optional(),
})
export const PageSchemaDefinition = z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
layout: LayoutSchema,
components: z.array(ComponentSchema),
dataSources: z.array(DataSourceSchema).optional(),
actions: z.array(ActionSchema).optional(),
hooks: z.array(HookConfigSchema).optional(),
seedData: z.record(z.any()).optional(),
permissions: z.array(z.string()).optional(),
})
export type Action = z.infer<typeof ActionSchema>
export type DataSource = z.infer<typeof DataSourceSchema>
export type HookConfig = z.infer<typeof HookConfigSchema>
export type ComponentDef = z.infer<typeof ComponentSchema>
export type Layout = z.infer<typeof LayoutSchema>
export type PageSchema = z.infer<typeof PageSchemaDefinition>

View File

@@ -0,0 +1,49 @@
{
"id": "dashboard-json",
"name": "Dashboard (JSON)",
"description": "JSON-driven dashboard page",
"icon": "ChartBar",
"layout": {
"type": "single"
},
"dataSources": [
{
"id": "files",
"type": "kv",
"key": "project-files",
"defaultValue": []
},
{
"id": "models",
"type": "kv",
"key": "project-models",
"defaultValue": []
},
{
"id": "components",
"type": "kv",
"key": "project-components",
"defaultValue": []
}
],
"components": [
{
"id": "dashboard-root",
"type": "ProjectDashboard",
"props": {},
"dataBinding": "files"
}
],
"actions": [
{
"id": "navigate-to-code",
"type": "navigate",
"target": "code"
},
{
"id": "create-file",
"type": "create",
"target": "files"
}
]
}

View File

@@ -0,0 +1,113 @@
{
"id": "simple-form",
"name": "Simple Form Example",
"description": "Example JSON-driven form page",
"icon": "Note",
"layout": {
"type": "single"
},
"dataSources": [
{
"id": "formData",
"type": "kv",
"key": "simple-form-data",
"defaultValue": {
"title": "",
"description": "",
"category": ""
}
}
],
"components": [
{
"id": "form-card",
"type": "Card",
"props": {
"className": "max-w-2xl mx-auto mt-8"
},
"children": [
{
"id": "card-header",
"type": "CardHeader",
"children": [
{
"id": "card-title",
"type": "CardTitle",
"props": {
"children": "Create New Item"
}
},
{
"id": "card-desc",
"type": "CardDescription",
"props": {
"children": "Fill out the form below to create a new item"
}
}
]
},
{
"id": "card-content",
"type": "CardContent",
"props": {
"className": "space-y-4"
},
"children": [
{
"id": "title-input",
"type": "Input",
"props": {
"placeholder": "Enter title",
"className": "w-full"
},
"dataBinding": "formData.title",
"eventHandlers": {
"onChange": "update-title"
}
},
{
"id": "description-textarea",
"type": "Textarea",
"props": {
"placeholder": "Enter description",
"rows": 4
},
"dataBinding": "formData.description",
"eventHandlers": {
"onChange": "update-description"
}
},
{
"id": "submit-button",
"type": "Button",
"props": {
"children": "Submit",
"className": "w-full"
},
"eventHandlers": {
"onClick": "submit-form"
}
}
]
}
]
}
],
"actions": [
{
"id": "update-title",
"type": "update",
"target": "formData"
},
{
"id": "update-description",
"type": "update",
"target": "formData"
},
{
"id": "submit-form",
"type": "custom",
"handler": "handleFormSubmit"
}
]
}

View File

@@ -1,5 +1,6 @@
export { useFiles } from './use-files'
export { useModels } from './use-models'
export { useComponents } from './use-components'
export { useWorkflows } from './use-workflows'
export { useLambdas } from './use-lambdas'
export * from './use-array'
export * from './use-crud'
export * from './use-search'
export * from './use-debounce'
export * from './use-sort'
export * from './use-pagination'

View File

@@ -0,0 +1,63 @@
import { useKV } from '@github/spark/hooks'
import { useCallback } from 'react'
export function useArray<T>(key: string, defaultValue: T[] = []) {
const [items, setItems] = useKV<T[]>(key, defaultValue)
const safeItems = items || []
const add = useCallback((item: T) => {
setItems((current) => [...(current || []), item])
}, [setItems])
const addMany = useCallback((newItems: T[]) => {
setItems((current) => [...(current || []), ...newItems])
}, [setItems])
const remove = useCallback((predicate: (item: T) => boolean) => {
setItems((current) => (current || []).filter((item) => !predicate(item)))
}, [setItems])
const update = useCallback(
(predicate: (item: T) => boolean, updater: (item: T) => T) => {
setItems((current) =>
(current || []).map((item) => (predicate(item) ? updater(item) : item))
)
},
[setItems]
)
const replace = useCallback((newItems: T[]) => {
setItems(newItems)
}, [setItems])
const clear = useCallback(() => {
setItems([])
}, [setItems])
const find = useCallback(
(predicate: (item: T) => boolean) => {
return safeItems.find(predicate)
},
[safeItems]
)
const filter = useCallback(
(predicate: (item: T) => boolean) => {
return safeItems.filter(predicate)
},
[safeItems]
)
return {
items: safeItems,
add,
addMany,
remove,
update,
replace,
clear,
find,
filter,
count: safeItems.length,
}
}

View File

@@ -0,0 +1,75 @@
import { useState, useCallback } from 'react'
export interface Entity {
id: string
[key: string]: any
}
export function useCRUD<T extends Entity>(
items: T[],
setItems: (items: T[] | ((prev: T[]) => T[])) => void
) {
const [selectedId, setSelectedId] = useState<string | null>(null)
const create = useCallback(
(item: T) => {
setItems((current) => [...(current || []), item])
return item.id
},
[setItems]
)
const read = useCallback(
(id: string) => {
return items?.find((item) => item.id === id)
},
[items]
)
const update = useCallback(
(id: string, updates: Partial<T>) => {
setItems((current) =>
(current || []).map((item) =>
item.id === id ? { ...item, ...updates } : item
)
)
},
[setItems]
)
const remove = useCallback(
(id: string) => {
setItems((current) => (current || []).filter((item) => item.id !== id))
if (selectedId === id) {
setSelectedId(null)
}
},
[setItems, selectedId]
)
const duplicate = useCallback(
(id: string, newId: string) => {
const item = read(id)
if (!item) return null
const duplicated = { ...item, id: newId }
create(duplicated)
return newId
},
[read, create]
)
const selected = selectedId ? read(selectedId) : null
return {
items: items || [],
create,
read,
update,
remove,
duplicate,
selectedId,
setSelectedId,
selected,
}
}

View File

@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react'
export function useDebounce<T>(value: T, delay: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(timer)
}
}, [value, delay])
return debouncedValue
}

View File

@@ -0,0 +1,52 @@
import { useState, useCallback, useMemo } from 'react'
export interface PaginationConfig {
page: number
pageSize: number
total: number
}
export function usePagination<T>(items: T[], initialPageSize: number = 10) {
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(initialPageSize)
const total = items.length
const totalPages = Math.ceil(total / pageSize)
const paginatedItems = useMemo(() => {
const start = (page - 1) * pageSize
const end = start + pageSize
return items.slice(start, end)
}, [items, page, pageSize])
const goToPage = useCallback((newPage: number) => {
setPage(Math.max(1, Math.min(newPage, totalPages)))
}, [totalPages])
const nextPage = useCallback(() => {
goToPage(page + 1)
}, [page, goToPage])
const prevPage = useCallback(() => {
goToPage(page - 1)
}, [page, goToPage])
const changePageSize = useCallback((newSize: number) => {
setPageSize(newSize)
setPage(1)
}, [])
return {
items: paginatedItems,
page,
pageSize,
total,
totalPages,
goToPage,
nextPage,
prevPage,
changePageSize,
hasNext: page < totalPages,
hasPrev: page > 1,
}
}

View File

@@ -0,0 +1,42 @@
import { useState, useEffect } from 'react'
import { useDebounce } from './use-debounce'
export function useSearch<T>(
items: T[],
searchKeys: (keyof T)[],
debounceMs: number = 300
) {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, debounceMs)
const [results, setResults] = useState<T[]>(items)
useEffect(() => {
if (!debouncedQuery.trim()) {
setResults(items)
return
}
const lowerQuery = debouncedQuery.toLowerCase()
const filtered = items.filter((item) =>
searchKeys.some((key) => {
const value = item[key]
if (typeof value === 'string') {
return value.toLowerCase().includes(lowerQuery)
}
if (typeof value === 'number') {
return value.toString().includes(lowerQuery)
}
return false
})
)
setResults(filtered)
}, [debouncedQuery, items, searchKeys])
return {
query,
setQuery,
results,
isSearching: query.length > 0,
}
}

View File

@@ -0,0 +1,49 @@
import { useState, useCallback } from 'react'
export type SortDirection = 'asc' | 'desc'
export function useSort<T>(items: T[], defaultKey?: keyof T) {
const [sortKey, setSortKey] = useState<keyof T | null>(defaultKey || null)
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
const toggleSort = useCallback(
(key: keyof T) => {
if (sortKey === key) {
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'))
} else {
setSortKey(key)
setSortDirection('asc')
}
},
[sortKey]
)
const sortedItems = [...items].sort((a, b) => {
if (!sortKey) return 0
const aVal = a[sortKey]
const bVal = b[sortKey]
if (aVal === bVal) return 0
let comparison = 0
if (typeof aVal === 'string' && typeof bVal === 'string') {
comparison = aVal.localeCompare(bVal)
} else if (typeof aVal === 'number' && typeof bVal === 'number') {
comparison = aVal - bVal
} else {
comparison = String(aVal).localeCompare(String(bVal))
}
return sortDirection === 'asc' ? comparison : -comparison
})
return {
sortedItems,
sortKey,
sortDirection,
toggleSort,
setSortKey,
setSortDirection,
}
}

2
src/hooks/forms/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './use-form'
export * from './use-form-field'

View File

@@ -0,0 +1,54 @@
import { useState, useCallback } from 'react'
export type ValidationRule<T> = {
validate: (value: T) => boolean
message: string
}
export function useFormField<T>(
initialValue: T,
rules: ValidationRule<T>[] = []
) {
const [value, setValue] = useState<T>(initialValue)
const [error, setError] = useState<string | null>(null)
const [touched, setTouched] = useState(false)
const validate = useCallback(() => {
for (const rule of rules) {
if (!rule.validate(value)) {
setError(rule.message)
return false
}
}
setError(null)
return true
}, [value, rules])
const onChange = useCallback((newValue: T) => {
setValue(newValue)
setTouched(true)
}, [])
const onBlur = useCallback(() => {
setTouched(true)
validate()
}, [validate])
const reset = useCallback(() => {
setValue(initialValue)
setError(null)
setTouched(false)
}, [initialValue])
return {
value,
setValue,
onChange,
onBlur,
error,
touched,
isValid: error === null && touched,
validate,
reset,
}
}

View File

@@ -0,0 +1,73 @@
import { useState, useCallback } from 'react'
export interface FormConfig<T> {
initialValues: T
onSubmit: (values: T) => void | Promise<void>
validate?: (values: T) => Record<string, string>
}
export function useForm<T extends Record<string, any>>({
initialValues,
onSubmit,
validate,
}: FormConfig<T>) {
const [values, setValues] = useState<T>(initialValues)
const [errors, setErrors] = useState<Record<string, string>>({})
const [touched, setTouched] = useState<Record<string, boolean>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const setValue = useCallback((field: keyof T, value: any) => {
setValues((prev) => ({ ...prev, [field]: value }))
}, [])
const setFieldTouched = useCallback((field: keyof T) => {
setTouched((prev) => ({ ...prev, [field]: true }))
}, [])
const validateForm = useCallback(() => {
if (!validate) return true
const validationErrors = validate(values)
setErrors(validationErrors)
return Object.keys(validationErrors).length === 0
}, [values, validate])
const handleSubmit = useCallback(
async (e?: React.FormEvent) => {
if (e) e.preventDefault()
setIsSubmitting(true)
if (!validateForm()) {
setIsSubmitting(false)
return
}
try {
await onSubmit(values)
} finally {
setIsSubmitting(false)
}
},
[values, validateForm, onSubmit]
)
const reset = useCallback(() => {
setValues(initialValues)
setErrors({})
setTouched({})
setIsSubmitting(false)
}, [initialValues])
return {
values,
errors,
touched,
isSubmitting,
setValue,
setFieldTouched,
handleSubmit,
reset,
isValid: Object.keys(errors).length === 0,
}
}

4
src/hooks/ui/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './use-dialog'
export * from './use-tabs'
export * from './use-selection'
export * from './use-clipboard'

View File

@@ -0,0 +1,30 @@
import { useState, useCallback } from 'react'
import { toast } from 'sonner'
export function useClipboard(successMessage?: string) {
const [copied, setCopied] = useState(false)
const copy = useCallback(
async (text: string) => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
if (successMessage) {
toast.success(successMessage)
}
setTimeout(() => setCopied(false), 2000)
return true
} catch (error) {
console.error('Failed to copy:', error)
toast.error('Failed to copy to clipboard')
return false
}
},
[successMessage]
)
return {
copied,
copy,
}
}

View File

@@ -1,17 +1,17 @@
import { useState, useCallback } from 'react'
export function useDialog(initialOpen = false) {
const [open, setOpen] = useState(initialOpen)
export function useDialog(initialOpen: boolean = false) {
const [isOpen, setIsOpen] = useState(initialOpen)
const openDialog = useCallback(() => setOpen(true), [])
const closeDialog = useCallback(() => setOpen(false), [])
const toggleDialog = useCallback(() => setOpen((prev) => !prev), [])
const open = useCallback(() => setIsOpen(true), [])
const close = useCallback(() => setIsOpen(false), [])
const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
return {
isOpen,
open,
setOpen,
openDialog,
closeDialog,
toggleDialog,
close,
toggle,
setIsOpen,
}
}

View File

@@ -1,45 +1,53 @@
import { useState, useCallback } from 'react'
export function useSelection<T extends string | number>(
initialSelection: T[] = []
) {
const [selected, setSelected] = useState<T[]>(initialSelection)
export function useSelection<T extends { id: string }>() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const select = useCallback((id: T) => {
setSelected((prev) => [...prev, id])
const select = useCallback((id: string) => {
setSelectedIds((prev) => new Set(prev).add(id))
}, [])
const deselect = useCallback((id: T) => {
setSelected((prev) => prev.filter((item) => item !== id))
const deselect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
}, [])
const toggle = useCallback((id: T) => {
setSelected((prev) =>
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]
)
const toggle = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const selectAll = useCallback((ids: T[]) => {
setSelected(ids)
const selectAll = useCallback((items: T[]) => {
setSelectedIds(new Set(items.map((item) => item.id)))
}, [])
const clear = useCallback(() => {
setSelected([])
const deselectAll = useCallback(() => {
setSelectedIds(new Set())
}, [])
const isSelected = useCallback(
(id: T) => selected.includes(id),
[selected]
(id: string) => selectedIds.has(id),
[selectedIds]
)
return {
selected,
selectedIds: Array.from(selectedIds),
select,
deselect,
toggle,
selectAll,
clear,
deselectAll,
isSelected,
count: selected.length,
count: selectedIds.size,
}
}

21
src/hooks/ui/use-tabs.ts Normal file
View File

@@ -0,0 +1,21 @@
import { useState, useCallback } from 'react'
export function useTabs<T extends string>(defaultTab: T) {
const [activeTab, setActiveTab] = useState<T>(defaultTab)
const switchTab = useCallback((tab: T) => {
setActiveTab(tab)
}, [])
const isActive = useCallback(
(tab: T) => activeTab === tab,
[activeTab]
)
return {
activeTab,
setActiveTab,
switchTab,
isActive,
}
}