775 lines
18 KiB
TypeScript
775 lines
18 KiB
TypeScript
"use client"
|
||
|
||
import {
|
||
Button,
|
||
Group,
|
||
Modal,
|
||
Paper,
|
||
Stack,
|
||
Text,
|
||
TextInput,
|
||
type ButtonProps,
|
||
type GroupProps,
|
||
type PaperProps,
|
||
type StackProps,
|
||
} from "@mantine/core"
|
||
import { notifications } from "@mantine/notifications"
|
||
import {
|
||
DataTable,
|
||
type DataTableDefaultColumnProps,
|
||
type DataTableProps,
|
||
} from "mantine-datatable"
|
||
import {
|
||
type CSSProperties,
|
||
type KeyboardEvent,
|
||
type PropsWithChildren,
|
||
type ReactNode,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useState,
|
||
} from "react"
|
||
import { create } from "zustand"
|
||
import {
|
||
persist,
|
||
type PersistStorage,
|
||
type StorageValue,
|
||
} from "zustand/middleware"
|
||
import classes from "./PageSurface.module.css"
|
||
|
||
function mergeClassName(...classNames: Array<string | undefined>) {
|
||
return classNames.filter(Boolean).join(" ")
|
||
}
|
||
|
||
export const settingModalClassNames = {
|
||
content: classes.modalContent,
|
||
header: classes.modalHeader,
|
||
title: classes.modalTitle,
|
||
body: classes.modalBody,
|
||
}
|
||
|
||
export const settingModalFormClassName = classes.modalForm
|
||
export const settingModalActionsClassName = classes.modalActions
|
||
export const settingModalMetaTextClassName = classes.modalMetaText
|
||
export const settingSurfaceClassNames = classes
|
||
|
||
const CUSTOM_PAGE_SIZE_STORAGE_KEY = "cowa:custom-records-per-page-options:v1"
|
||
|
||
function normalizePageSizeOptions(values: number[]) {
|
||
return Array.from(
|
||
new Set(values.filter((value) => Number.isInteger(value) && value > 0))
|
||
).sort((a, b) => a - b)
|
||
}
|
||
|
||
type AddCustomPageSizeResult = "added" | "exists" | "invalid"
|
||
|
||
type CustomPageSizePersistedState = {
|
||
customPageSizeCache: number[]
|
||
}
|
||
|
||
type CustomPageSizeStoreState = CustomPageSizePersistedState & {
|
||
appendCustomPageSize: (size: number) => void
|
||
clearCustomPageSizeCache: () => void
|
||
}
|
||
|
||
function normalizeCustomPageSizePersistedState(
|
||
value: unknown
|
||
): CustomPageSizePersistedState {
|
||
if (!value || typeof value !== "object") {
|
||
return { customPageSizeCache: [] }
|
||
}
|
||
|
||
const { customPageSizeCache } = value as {
|
||
customPageSizeCache?: unknown
|
||
}
|
||
if (!Array.isArray(customPageSizeCache)) {
|
||
return { customPageSizeCache: [] }
|
||
}
|
||
|
||
return {
|
||
customPageSizeCache: normalizePageSizeOptions(
|
||
customPageSizeCache.map((item) => Number(item))
|
||
),
|
||
}
|
||
}
|
||
|
||
const customPageSizeStorage: PersistStorage<CustomPageSizePersistedState> = {
|
||
getItem: (name) => {
|
||
if (typeof window === "undefined") return null
|
||
|
||
try {
|
||
const rawValue = window.localStorage.getItem(name)
|
||
if (!rawValue) return null
|
||
|
||
const parsedValue = JSON.parse(rawValue) as
|
||
| StorageValue<CustomPageSizePersistedState>
|
||
| number[]
|
||
| null
|
||
|
||
if (Array.isArray(parsedValue)) {
|
||
return {
|
||
state: {
|
||
customPageSizeCache: normalizePageSizeOptions(
|
||
parsedValue.map((item) => Number(item))
|
||
),
|
||
},
|
||
version: 0,
|
||
}
|
||
}
|
||
|
||
if (!parsedValue || typeof parsedValue !== "object") return null
|
||
|
||
return {
|
||
...parsedValue,
|
||
state: normalizeCustomPageSizePersistedState(parsedValue.state),
|
||
}
|
||
} catch {
|
||
return null
|
||
}
|
||
},
|
||
setItem: (name, value) => {
|
||
if (typeof window === "undefined") return
|
||
|
||
try {
|
||
const normalizedValue: StorageValue<CustomPageSizePersistedState> = {
|
||
...value,
|
||
state: normalizeCustomPageSizePersistedState(value.state),
|
||
}
|
||
|
||
if (normalizedValue.state.customPageSizeCache.length === 0) {
|
||
window.localStorage.removeItem(name)
|
||
return
|
||
}
|
||
|
||
window.localStorage.setItem(name, JSON.stringify(normalizedValue))
|
||
} catch {
|
||
// Ignore persistence failures and keep the UI usable.
|
||
}
|
||
},
|
||
removeItem: (name) => {
|
||
if (typeof window === "undefined") return
|
||
|
||
try {
|
||
window.localStorage.removeItem(name)
|
||
} catch {
|
||
// Ignore persistence failures and keep the UI usable.
|
||
}
|
||
},
|
||
}
|
||
|
||
const useCustomPageSizeStore = create<CustomPageSizeStoreState>()(
|
||
persist(
|
||
(set) => ({
|
||
customPageSizeCache: [],
|
||
appendCustomPageSize: (size) =>
|
||
set((state) => ({
|
||
customPageSizeCache: normalizePageSizeOptions([
|
||
...state.customPageSizeCache,
|
||
size,
|
||
]),
|
||
})),
|
||
clearCustomPageSizeCache: () => set({ customPageSizeCache: [] }),
|
||
}),
|
||
{
|
||
name: CUSTOM_PAGE_SIZE_STORAGE_KEY,
|
||
storage: customPageSizeStorage,
|
||
partialize: (state) => ({
|
||
customPageSizeCache: state.customPageSizeCache,
|
||
}),
|
||
}
|
||
)
|
||
)
|
||
|
||
export function usePersistentPageSizeOptions(
|
||
defaultOptions: number[],
|
||
currentPageSize?: number
|
||
) {
|
||
const normalizedDefaultOptions = useMemo(
|
||
() => normalizePageSizeOptions(defaultOptions),
|
||
[defaultOptions]
|
||
)
|
||
const customPageSizeCache = useCustomPageSizeStore(
|
||
(state) => state.customPageSizeCache
|
||
)
|
||
const appendCustomPageSize = useCustomPageSizeStore(
|
||
(state) => state.appendCustomPageSize
|
||
)
|
||
const clearCustomPageSizeCache = useCustomPageSizeStore(
|
||
(state) => state.clearCustomPageSizeCache
|
||
)
|
||
|
||
useEffect(() => {
|
||
void useCustomPageSizeStore.persist.rehydrate()
|
||
|
||
const handleStorage = (event: StorageEvent) => {
|
||
if (event.key !== CUSTOM_PAGE_SIZE_STORAGE_KEY) return
|
||
void useCustomPageSizeStore.persist.rehydrate()
|
||
}
|
||
|
||
window.addEventListener("storage", handleStorage)
|
||
return () => {
|
||
window.removeEventListener("storage", handleStorage)
|
||
}
|
||
}, [])
|
||
|
||
const persistedRecordsPerPageOptions = useMemo(
|
||
() =>
|
||
normalizePageSizeOptions([
|
||
...normalizedDefaultOptions,
|
||
...customPageSizeCache,
|
||
]),
|
||
[customPageSizeCache, normalizedDefaultOptions]
|
||
)
|
||
|
||
const recordsPerPageOptions = useMemo(() => {
|
||
const nextOptions = [...persistedRecordsPerPageOptions]
|
||
if (
|
||
typeof currentPageSize === "number" &&
|
||
Number.isInteger(currentPageSize) &&
|
||
currentPageSize > 0
|
||
) {
|
||
nextOptions.push(currentPageSize)
|
||
}
|
||
return normalizePageSizeOptions(nextOptions)
|
||
}, [currentPageSize, persistedRecordsPerPageOptions])
|
||
|
||
const addCustomPageSize = useCallback(
|
||
(size: number): AddCustomPageSizeResult => {
|
||
if (!Number.isInteger(size) || size <= 0) return "invalid"
|
||
if (persistedRecordsPerPageOptions.includes(size)) return "exists"
|
||
|
||
appendCustomPageSize(size)
|
||
return "added"
|
||
},
|
||
[appendCustomPageSize, persistedRecordsPerPageOptions]
|
||
)
|
||
|
||
return {
|
||
recordsPerPageOptions,
|
||
hasCustomPageSizeCache: customPageSizeCache.length > 0,
|
||
addCustomPageSize,
|
||
clearCustomPageSizeCache,
|
||
}
|
||
}
|
||
|
||
type SettingCustomPageSizeControlProps = {
|
||
addCustomPageSize: (size: number) => AddCustomPageSizeResult
|
||
clearCustomPageSizeCache: () => void
|
||
hasCustomPageSizeCache: boolean
|
||
triggerButtonProps?: Omit<ButtonProps, "children" | "onClick">
|
||
confirmButtonProps?: Omit<ButtonProps, "children" | "onClick">
|
||
triggerLabel?: ReactNode
|
||
}
|
||
|
||
export function SettingCustomPageSizeControl({
|
||
addCustomPageSize,
|
||
clearCustomPageSizeCache,
|
||
hasCustomPageSizeCache,
|
||
triggerButtonProps,
|
||
confirmButtonProps,
|
||
triggerLabel = "自定义每页条数",
|
||
}: SettingCustomPageSizeControlProps) {
|
||
const [opened, setOpened] = useState(false)
|
||
const [customPageSizeInput, setCustomPageSizeInput] = useState("")
|
||
|
||
const closeCustomPageSizeModal = useCallback(() => {
|
||
setOpened(false)
|
||
setCustomPageSizeInput("")
|
||
}, [])
|
||
|
||
const handleAddCustomPageSize = useCallback(() => {
|
||
const rawValue = customPageSizeInput.trim()
|
||
if (!rawValue) return
|
||
|
||
const size = Number(rawValue)
|
||
const result = addCustomPageSize(size)
|
||
|
||
if (result === "invalid") {
|
||
notifications.show({
|
||
color: "red",
|
||
title: "输入无效",
|
||
message: "请填写大于 0 的整数条数",
|
||
})
|
||
return
|
||
}
|
||
|
||
if (result === "exists") {
|
||
notifications.show({
|
||
color: "blue",
|
||
title: "已存在",
|
||
message: `每页 ${size} 条已在可选项中`,
|
||
})
|
||
return
|
||
}
|
||
|
||
closeCustomPageSizeModal()
|
||
notifications.show({
|
||
color: "green",
|
||
title: "添加成功",
|
||
message: `已新增每页 ${size} 条`,
|
||
})
|
||
}, [addCustomPageSize, closeCustomPageSizeModal, customPageSizeInput])
|
||
|
||
const handleCustomPageSizeEnter = useCallback(
|
||
(event: KeyboardEvent<HTMLInputElement>) => {
|
||
if (event.key !== "Enter") return
|
||
event.preventDefault()
|
||
handleAddCustomPageSize()
|
||
},
|
||
[handleAddCustomPageSize]
|
||
)
|
||
|
||
const handleClearCachedPageSizes = useCallback(() => {
|
||
clearCustomPageSizeCache()
|
||
closeCustomPageSizeModal()
|
||
notifications.show({
|
||
color: "green",
|
||
title: "清除成功",
|
||
message: "已清除自定义每页条数缓存",
|
||
})
|
||
}, [clearCustomPageSizeCache, closeCustomPageSizeModal])
|
||
|
||
return (
|
||
<>
|
||
<Button
|
||
variant="light"
|
||
color="gray"
|
||
size="xs"
|
||
onClick={() => setOpened(true)}
|
||
{...triggerButtonProps}>
|
||
{triggerLabel}
|
||
</Button>
|
||
<Modal
|
||
opened={opened}
|
||
onClose={closeCustomPageSizeModal}
|
||
title="添加每页条数"
|
||
classNames={settingModalClassNames}
|
||
centered>
|
||
<Stack gap={12} className={settingModalFormClassName}>
|
||
<TextInput
|
||
data-autofocus
|
||
label="每页条数"
|
||
placeholder="请输入大于 0 的整数,例如 99"
|
||
value={customPageSizeInput}
|
||
onChange={(event) =>
|
||
setCustomPageSizeInput(event.currentTarget.value)
|
||
}
|
||
onKeyDown={handleCustomPageSizeEnter}
|
||
/>
|
||
<Text className={settingModalMetaTextClassName}>
|
||
已添加的自定义条数会缓存在当前浏览器中,可随时清除。
|
||
</Text>
|
||
<Group
|
||
justify={hasCustomPageSizeCache ? "space-between" : "flex-end"}
|
||
className={settingModalActionsClassName}
|
||
wrap="wrap">
|
||
{hasCustomPageSizeCache ? (
|
||
<Button
|
||
variant="subtle"
|
||
color="red"
|
||
onClick={handleClearCachedPageSizes}>
|
||
清除缓存
|
||
</Button>
|
||
) : null}
|
||
<Group gap={12}>
|
||
<Button variant="default" onClick={closeCustomPageSizeModal}>
|
||
取消
|
||
</Button>
|
||
<Button onClick={handleAddCustomPageSize} {...confirmButtonProps}>
|
||
添加
|
||
</Button>
|
||
</Group>
|
||
</Group>
|
||
</Stack>
|
||
</Modal>
|
||
</>
|
||
)
|
||
}
|
||
|
||
type SettingTableViewportProps = PropsWithChildren<{
|
||
maxHeight: number | string
|
||
className?: string
|
||
style?: CSSProperties
|
||
}>
|
||
|
||
export function SettingPage({
|
||
className,
|
||
...props
|
||
}: PropsWithChildren<StackProps>) {
|
||
return (
|
||
<Stack
|
||
w="100%"
|
||
h="calc(100vh - 60px)"
|
||
p="md"
|
||
gap="md"
|
||
className={mergeClassName(classes.page, className)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
export function SettingPanel({
|
||
className,
|
||
withBorder = true,
|
||
radius = "sm",
|
||
p = "md",
|
||
shadow,
|
||
...props
|
||
}: PropsWithChildren<PaperProps>) {
|
||
return (
|
||
<Paper
|
||
withBorder={withBorder}
|
||
radius={radius}
|
||
p={p}
|
||
shadow={shadow}
|
||
className={mergeClassName(classes.panel, className)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
export function SettingTableViewport({
|
||
maxHeight,
|
||
className,
|
||
style,
|
||
children,
|
||
}: SettingTableViewportProps) {
|
||
return (
|
||
<div
|
||
style={{ maxHeight, ...style }}
|
||
className={mergeClassName(classes.tableViewport, className)}>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function SettingFilterPanel({
|
||
className,
|
||
...props
|
||
}: PropsWithChildren<PaperProps>) {
|
||
return (
|
||
<SettingPanel
|
||
className={mergeClassName(classes.filterPanel, className)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
export function SettingContentPanel({
|
||
className,
|
||
...props
|
||
}: PropsWithChildren<PaperProps>) {
|
||
return (
|
||
<SettingPanel
|
||
className={mergeClassName(classes.contentPanel, className)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
type SettingSectionHeaderProps = GroupProps & {
|
||
eyebrow?: string
|
||
title: ReactNode
|
||
description?: ReactNode
|
||
actions?: ReactNode
|
||
}
|
||
|
||
export function SettingSectionHeader({
|
||
eyebrow,
|
||
title,
|
||
description,
|
||
actions,
|
||
className,
|
||
...props
|
||
}: SettingSectionHeaderProps) {
|
||
return (
|
||
<Group
|
||
justify="space-between"
|
||
align="flex-start"
|
||
gap="sm"
|
||
wrap="wrap"
|
||
className={mergeClassName(classes.sectionHeader, className)}
|
||
{...props}>
|
||
<Stack gap={2}>
|
||
{eyebrow ? (
|
||
<Text className={classes.sectionEyebrow}>{eyebrow}</Text>
|
||
) : null}
|
||
<Text className={classes.sectionTitle}>{title}</Text>
|
||
{description ? (
|
||
<Text className={classes.sectionDescription}>{description}</Text>
|
||
) : null}
|
||
</Stack>
|
||
{actions}
|
||
</Group>
|
||
)
|
||
}
|
||
|
||
type SettingListHeaderProps = {
|
||
title: ReactNode
|
||
count?: ReactNode
|
||
actions?: ReactNode
|
||
className?: string
|
||
}
|
||
|
||
export function SettingListHeader({
|
||
title,
|
||
count,
|
||
actions,
|
||
className,
|
||
}: SettingListHeaderProps) {
|
||
return (
|
||
<Group
|
||
justify="space-between"
|
||
align="center"
|
||
wrap="wrap"
|
||
gap="sm"
|
||
className={mergeClassName(classes.listHeader, className)}>
|
||
<Group gap="xs">
|
||
<Text className={classes.listHeaderTitle}>{title}</Text>
|
||
{count !== undefined ? (
|
||
<Text className={classes.listHeaderCount}>{count}</Text>
|
||
) : null}
|
||
</Group>
|
||
{actions}
|
||
</Group>
|
||
)
|
||
}
|
||
|
||
export function SettingHeaderActions({
|
||
className,
|
||
gap = "sm",
|
||
...props
|
||
}: PropsWithChildren<GroupProps>) {
|
||
return (
|
||
<Group
|
||
gap={gap}
|
||
wrap="wrap"
|
||
className={mergeClassName(classes.headerActions, className)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
export function SettingFilterActions({
|
||
className,
|
||
gap = "sm",
|
||
justify = "flex-end",
|
||
mt = "md",
|
||
...props
|
||
}: PropsWithChildren<GroupProps>) {
|
||
return (
|
||
<Group
|
||
justify={justify}
|
||
gap={gap}
|
||
wrap="wrap"
|
||
mt={mt}
|
||
className={mergeClassName(classes.filterActions, className)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
export function SettingFilterStack({
|
||
className,
|
||
gap = 12,
|
||
...props
|
||
}: PropsWithChildren<StackProps>) {
|
||
return (
|
||
<Stack
|
||
gap={gap}
|
||
className={mergeClassName(classes.filterStack, className)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
type SettingInlineFilterFieldProps = {
|
||
label: ReactNode
|
||
children: ReactNode
|
||
className?: string
|
||
labelClassName?: string
|
||
controlClassName?: string
|
||
labelWidth?: number | string
|
||
}
|
||
|
||
export function SettingInlineFilterField({
|
||
label,
|
||
children,
|
||
className,
|
||
labelClassName,
|
||
controlClassName,
|
||
labelWidth = 80,
|
||
}: SettingInlineFilterFieldProps) {
|
||
const style = {
|
||
"--setting-inline-filter-label-width":
|
||
typeof labelWidth === "number" ? `${labelWidth}px` : labelWidth,
|
||
} as CSSProperties
|
||
|
||
return (
|
||
<div
|
||
style={style}
|
||
className={mergeClassName(classes.filterField, className)}>
|
||
<Text className={mergeClassName(classes.filterLabel, labelClassName)}>
|
||
{label}
|
||
</Text>
|
||
<div className={mergeClassName(classes.filterControl, controlClassName)}>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const settingDataTableStyles = {
|
||
header: {
|
||
backgroundColor: "#F7F8FA",
|
||
borderBottom: "1px solid #E5E6EB",
|
||
color: "#4E5969",
|
||
fontSize: "14px",
|
||
fontWeight: 500,
|
||
},
|
||
root: {
|
||
maxWidth: "100%",
|
||
minWidth: 0,
|
||
fontSize: "14px",
|
||
color: "#1D2129",
|
||
borderBottom: "1px solid #E5E6EB",
|
||
borderRadius: 4,
|
||
overflow: "hidden",
|
||
boxSizing: "border-box",
|
||
backgroundColor: "#FFFFFF",
|
||
},
|
||
table: {
|
||
backgroundColor: "#FFFFFF",
|
||
},
|
||
footer: {
|
||
backgroundColor: "#FFFFFF",
|
||
},
|
||
pagination: {
|
||
padding: "12px 16px",
|
||
borderTop: "1px solid #E5E6EB",
|
||
},
|
||
} as const
|
||
|
||
const settingDataTableDefaultColumnProps: DataTableDefaultColumnProps<
|
||
Record<string, unknown>
|
||
> = {
|
||
titleStyle: {
|
||
whiteSpace: "nowrap",
|
||
fontWeight: 500,
|
||
},
|
||
}
|
||
|
||
export function SettingDataTable<T = Record<string, unknown>>(
|
||
props: DataTableProps<T>
|
||
) {
|
||
const {
|
||
styles,
|
||
defaultColumnProps,
|
||
withTableBorder,
|
||
withRowBorders,
|
||
striped,
|
||
highlightOnHover,
|
||
loaderBackgroundBlur,
|
||
loadingText,
|
||
noRecordsText,
|
||
minHeight,
|
||
maxHeight,
|
||
height,
|
||
page,
|
||
onPageChange,
|
||
totalRecords,
|
||
recordsPerPage,
|
||
recordsPerPageLabel,
|
||
paginationText,
|
||
scrollAreaProps,
|
||
...rest
|
||
} = props
|
||
|
||
const paginationProps =
|
||
page !== undefined &&
|
||
onPageChange !== undefined &&
|
||
recordsPerPage !== undefined
|
||
? {
|
||
page,
|
||
onPageChange,
|
||
totalRecords,
|
||
recordsPerPage,
|
||
loadingText: loadingText ?? "正在加载数据...",
|
||
recordsPerPageLabel: recordsPerPageLabel ?? "每页条数",
|
||
paginationText:
|
||
paginationText ??
|
||
(({
|
||
from,
|
||
to,
|
||
totalRecords,
|
||
}: {
|
||
from: number
|
||
to: number
|
||
totalRecords: number
|
||
}) => `显示 ${from}-${to} 条,共 ${totalRecords} 条记录`),
|
||
}
|
||
: {}
|
||
|
||
const tableProps = {
|
||
withTableBorder: withTableBorder ?? true,
|
||
withRowBorders: withRowBorders ?? true,
|
||
striped: striped ?? false,
|
||
highlightOnHover: highlightOnHover ?? false,
|
||
defaultColumnProps:
|
||
defaultColumnProps ??
|
||
(settingDataTableDefaultColumnProps as DataTableDefaultColumnProps<T>),
|
||
loaderBackgroundBlur: loaderBackgroundBlur ?? 0,
|
||
noRecordsText: noRecordsText ?? "暂无数据",
|
||
minHeight: minHeight ?? 360,
|
||
maxHeight,
|
||
height: height ?? (maxHeight !== undefined ? "auto" : undefined),
|
||
scrollAreaProps: {
|
||
type: "auto",
|
||
...scrollAreaProps,
|
||
},
|
||
styles: styles
|
||
? { ...settingDataTableStyles, ...styles }
|
||
: settingDataTableStyles,
|
||
...paginationProps,
|
||
...rest,
|
||
} as DataTableProps<T>
|
||
|
||
return <DataTable {...tableProps} />
|
||
}
|
||
|
||
export const settingTabsStyles = {
|
||
list: {
|
||
padding: 4,
|
||
backgroundColor: "#F7F8FA",
|
||
border: "1px solid #E5E6EB",
|
||
borderRadius: 4,
|
||
gap: 8,
|
||
},
|
||
tab: {
|
||
minHeight: 32,
|
||
borderRadius: 4,
|
||
padding: "0 12px",
|
||
fontSize: 14,
|
||
fontWeight: 500,
|
||
lineHeight: "20px",
|
||
},
|
||
tabLabel: {
|
||
color: "inherit",
|
||
fontSize: 14,
|
||
lineHeight: "20px",
|
||
},
|
||
panel: {
|
||
flex: 1,
|
||
minHeight: 0,
|
||
minWidth: 0,
|
||
paddingTop: 16,
|
||
},
|
||
} as const
|
||
|
||
export const settingTabsClassNames = {
|
||
list: classes.settingTabsList,
|
||
tab: classes.settingTabsTab,
|
||
tabLabel: classes.settingTabsTabLabel,
|
||
panel: classes.settingTabsPanel,
|
||
} as const
|