From e0dfb55c5457aec489fc084c4567e791b4c65eb1 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 26 Mar 2025 00:37:41 +0000 Subject: 3/25 까지의 대표님 작업사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hooks/use-callback-ref.ts | 27 ++++ hooks/use-data-table.ts | 334 ++++++++++++++++++++++++++++++++++++++++ hooks/use-debounce.ts | 15 ++ hooks/use-debounced-callback.ts | 29 ++++ hooks/use-form-schema.ts | 113 ++++++++++++++ hooks/use-media-query.ts | 19 +++ hooks/use-meta-color.ts | 25 +++ hooks/use-mobile.tsx | 19 +++ hooks/use-query-string.ts | 24 +++ hooks/use-toast.ts | 194 +++++++++++++++++++++++ hooks/useAutoSizeColumns.ts | 86 +++++++++++ 11 files changed, 885 insertions(+) create mode 100644 hooks/use-callback-ref.ts create mode 100644 hooks/use-data-table.ts create mode 100644 hooks/use-debounce.ts create mode 100644 hooks/use-debounced-callback.ts create mode 100644 hooks/use-form-schema.ts create mode 100644 hooks/use-media-query.ts create mode 100644 hooks/use-meta-color.ts create mode 100644 hooks/use-mobile.tsx create mode 100644 hooks/use-query-string.ts create mode 100644 hooks/use-toast.ts create mode 100644 hooks/useAutoSizeColumns.ts (limited to 'hooks') diff --git a/hooks/use-callback-ref.ts b/hooks/use-callback-ref.ts new file mode 100644 index 00000000..f379a9c3 --- /dev/null +++ b/hooks/use-callback-ref.ts @@ -0,0 +1,27 @@ +import * as React from "react" + +/** + * @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-callback-ref/src/useCallbackRef.tsx + */ + +/** + * A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a + * prop or avoid re-executing effects when passed as a dependency + */ +function useCallbackRef unknown>( + callback: T | undefined +): T { + const callbackRef = React.useRef(callback) + + React.useEffect(() => { + callbackRef.current = callback + }) + + // https://github.com/facebook/react/issues/19240 + return React.useMemo( + () => ((...args) => callbackRef.current?.(...args)) as T, + [] + ) +} + +export { useCallbackRef } diff --git a/hooks/use-data-table.ts b/hooks/use-data-table.ts new file mode 100644 index 00000000..a3301067 --- /dev/null +++ b/hooks/use-data-table.ts @@ -0,0 +1,334 @@ +"use client" + +import * as React from "react" +import type { DataTableFilterField, ExtendedSortingState } from "@/types/table" +import { + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + getGroupedRowModel, + getExpandedRowModel, + useReactTable, + type ColumnFiltersState, + type PaginationState, + type RowSelectionState, + type SortingState, + type TableOptions, + type TableState, + type Updater, + type VisibilityState, + type ExpandedState, +} from "@tanstack/react-table" +import { + parseAsArrayOf, + parseAsInteger, + parseAsString, + useQueryState, + useQueryStates, + type Parser, + type UseQueryStateOptions, +} from "nuqs" + +import { getSortingStateParser } from "@/lib/parsers" +import { useDebouncedCallback } from "@/hooks/use-debounced-callback" + +interface UseDataTableProps + extends Omit< + TableOptions, + | "state" + | "pageCount" + | "getCoreRowModel" + | "manualFiltering" + | "manualPagination" + | "manualSorting" + | "onGroupingChange" + | "onExpandedChange" + | "getExpandedRowModel" + >, + Required, "pageCount">> { + filterFields?: DataTableFilterField[] + enableAdvancedFilter?: boolean + history?: "push" | "replace" + scroll?: boolean + shallow?: boolean + throttleMs?: number + debounceMs?: number + startTransition?: React.TransitionStartFunction + clearOnDefault?: boolean + initialState?: Omit, "sorting"> & { + sorting?: ExtendedSortingState + /** + * 기본 그룹핑 컬럼 배열 (멀티 그룹핑) + */ + grouping?: string[] + /** + * 그룹 확장/접기 상태 + */ + expanded?: Record + } +} + +export function useDataTable({ + pageCount = -1, + filterFields = [], + enableAdvancedFilter = false, + history = "replace", + scroll = false, + shallow = true, + throttleMs = 50, + debounceMs = 300, + clearOnDefault = false, + startTransition, + initialState, + ...props +}: UseDataTableProps) { + // 공통 URL QueryState 옵션 + const queryStateOptions = React.useMemo< + Omit, "parse"> + >(() => { + return { + history, + scroll, + shallow, + throttleMs, + debounceMs, + clearOnDefault, + startTransition, + } + }, [ + history, + scroll, + shallow, + throttleMs, + debounceMs, + clearOnDefault, + startTransition, + ]) + + // -------- RowSelection & ColumnVisibility는 URL 동기화 없이 로컬 상태 ---------- + const [rowSelection, setRowSelection] = React.useState( + initialState?.rowSelection ?? {} + ) + const [columnVisibility, setColumnVisibility] = + React.useState(initialState?.columnVisibility ?? {}) + + // -------- Pagination (page, perPage) URL 동기화 -------- + const [page, setPage] = useQueryState( + "page", + parseAsInteger.withOptions(queryStateOptions).withDefault(1) + ) + const [perPage, setPerPage] = useQueryState( + "perPage", + parseAsInteger + .withOptions(queryStateOptions) + .withDefault(initialState?.pagination?.pageSize ?? 10) + ) + + // -------- Sorting (sort) URL 동기화 -------- + const [sorting, setSorting] = useQueryState( + "sort", + getSortingStateParser() + .withOptions(queryStateOptions) + .withDefault(initialState?.sorting ?? []) + ) + function onSortingChange(updaterOrValue: Updater) { + if (typeof updaterOrValue === "function") { + const newSorting = updaterOrValue(sorting) as ExtendedSortingState + void setSorting(newSorting) + } else { + void setSorting(updaterOrValue as ExtendedSortingState) + } + } + + // -------- Grouping (group) URL 동기화 (멀티 컬럼) -------- + const [grouping, setGrouping] = useQueryState( + "group", + parseAsArrayOf(parseAsString, ",") + .withOptions(queryStateOptions) + .withDefault(initialState?.grouping ?? []) + ) + function onGroupingChange(updaterOrValue: Updater) { + if (typeof updaterOrValue === "function") { + const newGrouping = updaterOrValue(grouping) + void setGrouping(newGrouping) + } else { + void setGrouping(updaterOrValue) + } + } + + // -------- Group Expand/Collapse -------- + const [expanded, setExpanded] = React.useState({}) // or true/false + + function onExpandedChange(updater: Updater) { + setExpanded((old) => (typeof updater === "function" ? updater(old) : updater)) + } + + // -------- Filters (search/faceted) URL 동기화 -------- + const filterParsers = React.useMemo(() => { + return filterFields.reduce< + Record | Parser> + >((acc, field) => { + if (field.options) { + // Faceted filter -> 여러 값 가능 + acc[field.id] = parseAsArrayOf(parseAsString, ",").withOptions( + queryStateOptions + ) + } else { + // Search filter -> 단일 값 + acc[field.id] = parseAsString.withOptions(queryStateOptions) + } + return acc + }, {}) + }, [filterFields, queryStateOptions]) + + const [filterValues, setFilterValues] = useQueryStates(filterParsers) + const debouncedSetFilterValues = useDebouncedCallback( + setFilterValues, + debounceMs + ) + + // -------- PaginationState 객체 -------- + const pagination: PaginationState = { + pageIndex: page - 1, + pageSize: perPage, + } + function onPaginationChange(updaterOrValue: Updater) { + if (typeof updaterOrValue === "function") { + const newPagination = updaterOrValue(pagination) + void setPage(newPagination.pageIndex + 1) + void setPerPage(newPagination.pageSize) + } else { + void setPage(updaterOrValue.pageIndex + 1) + void setPerPage(updaterOrValue.pageSize) + } + } + + // -------- ColumnFiltersState -------- + const initialColumnFilters: ColumnFiltersState = React.useMemo(() => { + // AdvancedFilter 모드가 아니면 URL에서 온 filterValues를 그대로 적용 + return enableAdvancedFilter + ? [] + : Object.entries(filterValues).reduce( + (filters, [key, value]) => { + if (value !== null) { + filters.push({ + id: key, + value: Array.isArray(value) ? value : [value], + }) + } + return filters + }, + [] + ) + }, [filterValues, enableAdvancedFilter]) + + const [columnFilters, setColumnFilters] = + React.useState(initialColumnFilters) + + // 검색용 / Facet 필터용 컬럼 구분 + const { searchableColumns, filterableColumns } = React.useMemo(() => { + return enableAdvancedFilter + ? { searchableColumns: [], filterableColumns: [] } + : { + searchableColumns: filterFields.filter((field) => !field.options), + filterableColumns: filterFields.filter((field) => field.options), + } + }, [filterFields, enableAdvancedFilter]) + + const onColumnFiltersChange = React.useCallback( + (updaterOrValue: Updater) => { + if (enableAdvancedFilter) return + setColumnFilters((prev) => { + const next = + typeof updaterOrValue === "function" + ? updaterOrValue(prev) + : updaterOrValue + + const filterUpdates = next.reduce< + Record + >((acc, filter) => { + if (searchableColumns.find((col) => col.id === filter.id)) { + acc[filter.id] = filter.value as string + } else if (filterableColumns.find((col) => col.id === filter.id)) { + acc[filter.id] = filter.value as string[] + } + return acc + }, {}) + + // 빠진 필터는 null로 설정 + prev.forEach((prevFilter) => { + if (!next.some((filter) => filter.id === prevFilter.id)) { + filterUpdates[prevFilter.id] = null + } + }) + + // 필터가 바뀌면 첫 페이지로 + void setPage(1) + debouncedSetFilterValues(filterUpdates) + return next + }) + }, + [ + debouncedSetFilterValues, + enableAdvancedFilter, + filterableColumns, + searchableColumns, + setPage, + ] + ) + + // -------- TanStack Table 인스턴스 생성 -------- + const table = useReactTable({ + ...props, + initialState, + pageCount, + state: { + pagination, + sorting, + columnVisibility, + rowSelection, + columnFilters: enableAdvancedFilter ? [] : columnFilters, + grouping, + expanded, + }, + + // 콜백들 + onRowSelectionChange: setRowSelection, + onPaginationChange, + onSortingChange, + onColumnFiltersChange, + onColumnVisibilityChange: setColumnVisibility, + onGroupingChange, + onExpandedChange, + + // 기본 모델 + getCoreRowModel: getCoreRowModel(), + // 필터 (Advanced 모드 아니면 사용) + getFilteredRowModel: enableAdvancedFilter ? undefined : getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + // 그룹 + 확장 + getGroupedRowModel: getGroupedRowModel(), + getExpandedRowModel: getExpandedRowModel(), + + // Faceted (Facet 필터용) + getFacetedRowModel: enableAdvancedFilter ? undefined : getFacetedRowModel(), + getFacetedUniqueValues: enableAdvancedFilter + ? undefined + : getFacetedUniqueValues(), + + // 서버 사이드 사용 + manualPagination: true, + manualSorting: true, + manualFiltering: true, + // 그룹핑도 서버에서 처리한다면 별도 로직이 필요하지만, + // TanStack Table v8에는 manualGrouping 옵션은 없음 + // (그룹핑을 서버에서 이미 해서 내려받는다면 row 구조 처리 필요) + + }) + + return { table } +} \ No newline at end of file diff --git a/hooks/use-debounce.ts b/hooks/use-debounce.ts new file mode 100644 index 00000000..5c0b504a --- /dev/null +++ b/hooks/use-debounce.ts @@ -0,0 +1,15 @@ +import * as React from "react" + +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = React.useState(value) + + React.useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay ?? 500) + + return () => { + clearTimeout(timer) + } + }, [value, delay]) + + return debouncedValue +} diff --git a/hooks/use-debounced-callback.ts b/hooks/use-debounced-callback.ts new file mode 100644 index 00000000..51f5dd17 --- /dev/null +++ b/hooks/use-debounced-callback.ts @@ -0,0 +1,29 @@ +/** + * @see https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-debounced-callback/use-debounced-callback.ts + */ + +import * as React from "react" + +import { useCallbackRef } from "@/hooks/use-callback-ref" + +export function useDebouncedCallback unknown>( + callback: T, + delay: number +) { + const handleCallback = useCallbackRef(callback) + const debounceTimerRef = React.useRef(0) + React.useEffect(() => () => window.clearTimeout(debounceTimerRef.current), []) + + const setValue = React.useCallback( + (...args: Parameters) => { + window.clearTimeout(debounceTimerRef.current) + debounceTimerRef.current = window.setTimeout( + () => handleCallback(...args), + delay + ) + }, + [handleCallback, delay] + ) + + return setValue +} diff --git a/hooks/use-form-schema.ts b/hooks/use-form-schema.ts new file mode 100644 index 00000000..a4d8f3c6 --- /dev/null +++ b/hooks/use-form-schema.ts @@ -0,0 +1,113 @@ +"use client" + +import useSWR from "swr" + +// ------------------ 폼 스키마 구조 ------------------ +export interface FormField { + label: string + name: string + type: string // "text" | "number" | "select" | "date" | ... + options?: string[] // select 등에 필요한 옵션 +} + +export interface FormSchema { + formType: "table" | "form" + fields: FormField[] +} + +// ------------------ 실제 요청 함수 (mock) ------------------ +async function fetchFormSchema(itemName: string) { + // 실제 API 요청 예시: + // const res = await fetch(`/api/form-schema?item=${encodeURIComponent(itemName)}`) + // return await res.json() + + // 여기서는 예시 데이터(하드코딩) + if (itemName === "Mechanical Data Sheet") { + // 예: 테이블 형태 (설비 리스트) + return { + formType: "table", + fields: [ + { label: "Equipment Name", name: "equipmentName", type: "text" }, + { label: "Tag No.", name: "tagNo", type: "text" }, + { label: "Capacity", name: "capacity", type: "number" }, + { label: "Manufacturer", name: "manufacturer", type: "text" }, + { label: "Model", name: "model", type: "text" }, + { label: "Weight (kg)", name: "weight", type: "number" }, + { label: "Power (kW)", name: "power", type: "number" }, + // 필요하다면 옵션이 있는 select 필드 등도 추가 + // { label: "Power Source", name: "powerSource", type: "select", options: ["AC380V", "AC220V", "DC", "Other"] }, + ], + } + } else if (itemName === "Room List") { + // 예: 또 다른 테이블 형태 (건물 방 리스트) + return { + formType: "table", + fields: [ + { label: "Room Name", name: "roomName", type: "text" }, + { label: "Area (m²)", name: "area", type: "number" }, + { label: "Floor No.", name: "floorNo", type: "number" }, + { label: "Ceiling Type", name: "ceiling", type: "text" }, + { label: "Wall Finish", name: "wallFinish",type: "text" }, + { label: "Floor Finish", name: "floorFinish", type: "text" }, + // select 예시 + { label: "Usage", name: "usage", type: "select", options: ["Office", "Laboratory", "Storage", "Other"] }, + ], + } + } else if (itemName === "Piping Line List") { + // 예: 테이블 형태 (배관 정보) + return { + formType: "table", + fields: [ + { label: "Line Number", name: "lineNumber", type: "text" }, + { label: "Size (inches)", name: "size", type: "number" }, + { label: "Service", name: "service", type: "text" }, + { label: "Material Spec", name: "materialSpec",type: "text" }, + { label: "Insulation", name: "insulation", type: "text" }, + { label: "Design Temp (°C)", name: "designTemp", type: "number" }, + { label: "Design Press (bar)", name: "designPress", type: "number" }, + ], + } + } else if (itemName === "Electrical Load List") { + // 추가 예시: 테이블 or form (여기선 테이블로 예시) + return { + formType: "table", + fields: [ + { label: "Load Name", name: "loadName", type: "text" }, + { label: "Voltage (V)", name: "voltage", type: "number" }, + { label: "Phase", name: "phase", type: "select", options: ["Single-phase", "Three-phase"] }, + { label: "Load (kW)", name: "loadKw", type: "number" }, + { label: "Power Factor (%)", name: "powerFactor", type: "number" }, + { label: "Location", name: "location", type: "text" }, + ], + } + } else { + // 디폴트: formType: "form" (단일 폼) + // 예: 임시로 "Vendor Contact Info" 라고 가정 + return { + formType: "form", + fields: [ + { label: "Vendor Name", name: "vendorName", type: "text" }, + { label: "Contact Name", name: "contactName", type: "text" }, + { label: "Phone Number", name: "phoneNumber", type: "text" }, + { label: "Email", name: "email", type: "text" }, + // date 예시 + { label: "Contract Date", name: "contractDate", type: "date" }, + ], + } + } +} + +// ------------------ SWR 훅 ------------------ +export function useFormSchema(itemName: string | null) { + // itemName이 없으면(선택 안 됨) 요청하지 않음 + const { data, error, isLoading } = useSWR( + itemName ? `/api/form-schema?item=${encodeURIComponent(itemName)}` : null, + () => (itemName ? fetchFormSchema(itemName) : null) + ) + + return { + schema: data as FormSchema | undefined, + isLoading, + isError: !!error, + } +} \ No newline at end of file diff --git a/hooks/use-media-query.ts b/hooks/use-media-query.ts new file mode 100644 index 00000000..95e552cd --- /dev/null +++ b/hooks/use-media-query.ts @@ -0,0 +1,19 @@ +import * as React from "react" + +export function useMediaQuery(query: string) { + const [value, setValue] = React.useState(false) + + React.useEffect(() => { + function onChange(event: MediaQueryListEvent) { + setValue(event.matches) + } + + const result = matchMedia(query) + result.addEventListener("change", onChange) + setValue(result.matches) + + return () => result.removeEventListener("change", onChange) + }, [query]) + + return value +} diff --git a/hooks/use-meta-color.ts b/hooks/use-meta-color.ts new file mode 100644 index 00000000..65d33433 --- /dev/null +++ b/hooks/use-meta-color.ts @@ -0,0 +1,25 @@ +import * as React from "react" +import { useTheme } from "next-themes" + +import { META_THEME_COLORS } from "@/config/site" + +export function useMetaColor() { + const { resolvedTheme } = useTheme() + + const metaColor = React.useMemo(() => { + return resolvedTheme !== "dark" + ? META_THEME_COLORS.light + : META_THEME_COLORS.dark + }, [resolvedTheme]) + + const setMetaColor = React.useCallback((color: string) => { + document + .querySelector('meta[name="theme-color"]') + ?.setAttribute("content", color) + }, []) + + return { + metaColor, + setMetaColor, + } +} \ No newline at end of file diff --git a/hooks/use-mobile.tsx b/hooks/use-mobile.tsx new file mode 100644 index 00000000..2b0fe1df --- /dev/null +++ b/hooks/use-mobile.tsx @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange) + }, []) + + return !!isMobile +} diff --git a/hooks/use-query-string.ts b/hooks/use-query-string.ts new file mode 100644 index 00000000..5f523230 --- /dev/null +++ b/hooks/use-query-string.ts @@ -0,0 +1,24 @@ +import * as React from "react" + +type QueryParams = Record + +export function useQueryString(searchParams: URLSearchParams) { + const createQueryString = React.useCallback( + (params: QueryParams) => { + const newSearchParams = new URLSearchParams(searchParams?.toString()) + + for (const [key, value] of Object.entries(params)) { + if (value === null) { + newSearchParams.delete(key) + } else { + newSearchParams.set(key, String(value)) + } + } + + return newSearchParams.toString() + }, + [searchParams] + ) + + return { createQueryString } +} diff --git a/hooks/use-toast.ts b/hooks/use-toast.ts new file mode 100644 index 00000000..02e111d8 --- /dev/null +++ b/hooks/use-toast.ts @@ -0,0 +1,194 @@ +"use client" + +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/hooks/useAutoSizeColumns.ts b/hooks/useAutoSizeColumns.ts new file mode 100644 index 00000000..3750de97 --- /dev/null +++ b/hooks/useAutoSizeColumns.ts @@ -0,0 +1,86 @@ +// hooks/useAutoSizeColumns.ts +import { useEffect, useRef } from "react" +import { Table } from "@tanstack/react-table" + +export function useAutoSizeColumns( + table: Table, + enabled: boolean = true +) { + const hasResized = useRef(false) + + useEffect(() => { + if (!enabled || hasResized.current) return + + // Wait for the table to render + const timer = setTimeout(() => { + const calculateColumnWidths = () => { + const defaultMinWidth = 80 + const defaultPadding = 24 + + const newColumnSizing: Record = {} + + // Process each visible column + table.getAllColumns().forEach(column => { + if (column.id === 'select' || column.id === 'actions') { + // Fixed width for utility columns + newColumnSizing[column.id] = column.id === 'select' ? 40 : 60 + return + } + + const columnId = column.id + // Get column-specific padding from meta if available + const paddingFactor = column.columnDef.meta?.paddingFactor as number || 1 + const extraPadding = paddingFactor * defaultPadding + + // Get all cells for this column + const headerElement = document.querySelector(`th[data-column-id="${columnId}"]`) + const cells = document.querySelectorAll(`tbody td[data-column-id="${columnId}"]`) + + // Create measuring element + const measureElement = document.createElement('div') + measureElement.style.position = 'absolute' + measureElement.style.visibility = 'hidden' + measureElement.style.whiteSpace = 'nowrap' + measureElement.style.font = headerElement + ? window.getComputedStyle(headerElement).font + : window.getComputedStyle(document.body).font + document.body.appendChild(measureElement) + + // Measure header + const headerText = headerElement?.textContent || "" + measureElement.textContent = headerText + let maxWidth = measureElement.getBoundingClientRect().width + + // Measure cells (limit to first 20 for performance) + Array.from(cells).slice(0, 20).forEach(cell => { + const cellText = cell.textContent || "" + measureElement.textContent = cellText + const cellWidth = measureElement.getBoundingClientRect().width + maxWidth = Math.max(maxWidth, cellWidth) + }) + + // Clean up measuring element + document.body.removeChild(measureElement) + + // Calculate final width + let finalWidth = maxWidth + extraPadding + const minWidth = column.columnDef.minSize || defaultMinWidth + finalWidth = Math.max(finalWidth, minWidth) + + // Add to sizing object + newColumnSizing[columnId] = finalWidth + }) + + // Apply all column sizes at once + table.setColumnSizing(newColumnSizing) + hasResized.current = true + } + + calculateColumnWidths() + }, 100) // Short delay to ensure DOM is ready + + return () => clearTimeout(timer) + }, [table, enabled]) + + return { resetAutoSize: () => { hasResized.current = false } } +} \ No newline at end of file -- cgit v1.2.3