🛂 Migrate to Chakra UI v3 (#1496)
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
40
frontend/src/components/ui/button.tsx
Normal file
40
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ButtonProps as ChakraButtonProps } from "@chakra-ui/react"
|
||||
import {
|
||||
AbsoluteCenter,
|
||||
Button as ChakraButton,
|
||||
Span,
|
||||
Spinner,
|
||||
} from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
|
||||
interface ButtonLoadingProps {
|
||||
loading?: boolean
|
||||
loadingText?: React.ReactNode
|
||||
}
|
||||
|
||||
export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
function Button(props, ref) {
|
||||
const { loading, disabled, loadingText, children, ...rest } = props
|
||||
return (
|
||||
<ChakraButton disabled={loading || disabled} ref={ref} {...rest}>
|
||||
{loading && !loadingText ? (
|
||||
<>
|
||||
<AbsoluteCenter display="inline-flex">
|
||||
<Spinner size="inherit" color="inherit" />
|
||||
</AbsoluteCenter>
|
||||
<Span opacity={0}>{children}</Span>
|
||||
</>
|
||||
) : loading && loadingText ? (
|
||||
<>
|
||||
<Spinner size="inherit" color="inherit" />
|
||||
{loadingText}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</ChakraButton>
|
||||
)
|
||||
},
|
||||
)
|
||||
25
frontend/src/components/ui/checkbox.tsx
Normal file
25
frontend/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Checkbox as ChakraCheckbox } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
|
||||
export interface CheckboxProps extends ChakraCheckbox.RootProps {
|
||||
icon?: React.ReactNode
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>
|
||||
rootRef?: React.Ref<HTMLLabelElement>
|
||||
}
|
||||
|
||||
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
function Checkbox(props, ref) {
|
||||
const { icon, children, inputProps, rootRef, ...rest } = props
|
||||
return (
|
||||
<ChakraCheckbox.Root ref={rootRef} {...rest}>
|
||||
<ChakraCheckbox.HiddenInput ref={ref} {...inputProps} />
|
||||
<ChakraCheckbox.Control>
|
||||
{icon || <ChakraCheckbox.Indicator />}
|
||||
</ChakraCheckbox.Control>
|
||||
{children != null && (
|
||||
<ChakraCheckbox.Label>{children}</ChakraCheckbox.Label>
|
||||
)}
|
||||
</ChakraCheckbox.Root>
|
||||
)
|
||||
},
|
||||
)
|
||||
17
frontend/src/components/ui/close-button.tsx
Normal file
17
frontend/src/components/ui/close-button.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ButtonProps } from "@chakra-ui/react"
|
||||
import { IconButton as ChakraIconButton } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
import { LuX } from "react-icons/lu"
|
||||
|
||||
export type CloseButtonProps = ButtonProps
|
||||
|
||||
export const CloseButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
CloseButtonProps
|
||||
>(function CloseButton(props, ref) {
|
||||
return (
|
||||
<ChakraIconButton variant="ghost" aria-label="Close" ref={ref} {...props}>
|
||||
{props.children ?? <LuX />}
|
||||
</ChakraIconButton>
|
||||
)
|
||||
})
|
||||
107
frontend/src/components/ui/color-mode.tsx
Normal file
107
frontend/src/components/ui/color-mode.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import type { IconButtonProps, SpanProps } from "@chakra-ui/react"
|
||||
import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react"
|
||||
import { ThemeProvider, useTheme } from "next-themes"
|
||||
import type { ThemeProviderProps } from "next-themes"
|
||||
import * as React from "react"
|
||||
import { LuMoon, LuSun } from "react-icons/lu"
|
||||
|
||||
export interface ColorModeProviderProps extends ThemeProviderProps {}
|
||||
|
||||
export function ColorModeProvider(props: ColorModeProviderProps) {
|
||||
return (
|
||||
<ThemeProvider attribute="class" disableTransitionOnChange {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export type ColorMode = "light" | "dark"
|
||||
|
||||
export interface UseColorModeReturn {
|
||||
colorMode: ColorMode
|
||||
setColorMode: (colorMode: ColorMode) => void
|
||||
toggleColorMode: () => void
|
||||
}
|
||||
|
||||
export function useColorMode(): UseColorModeReturn {
|
||||
const { resolvedTheme, setTheme } = useTheme()
|
||||
const toggleColorMode = () => {
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}
|
||||
return {
|
||||
colorMode: resolvedTheme as ColorMode,
|
||||
setColorMode: setTheme,
|
||||
toggleColorMode,
|
||||
}
|
||||
}
|
||||
|
||||
export function useColorModeValue<T>(light: T, dark: T) {
|
||||
const { colorMode } = useColorMode()
|
||||
return colorMode === "dark" ? dark : light
|
||||
}
|
||||
|
||||
export function ColorModeIcon() {
|
||||
const { colorMode } = useColorMode()
|
||||
return colorMode === "dark" ? <LuMoon /> : <LuSun />
|
||||
}
|
||||
|
||||
interface ColorModeButtonProps extends Omit<IconButtonProps, "aria-label"> {}
|
||||
|
||||
export const ColorModeButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ColorModeButtonProps
|
||||
>(function ColorModeButton(props, ref) {
|
||||
const { toggleColorMode } = useColorMode()
|
||||
return (
|
||||
<ClientOnly fallback={<Skeleton boxSize="8" />}>
|
||||
<IconButton
|
||||
onClick={toggleColorMode}
|
||||
variant="ghost"
|
||||
aria-label="Toggle color mode"
|
||||
size="sm"
|
||||
ref={ref}
|
||||
{...props}
|
||||
css={{
|
||||
_icon: {
|
||||
width: "5",
|
||||
height: "5",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ColorModeIcon />
|
||||
</IconButton>
|
||||
</ClientOnly>
|
||||
)
|
||||
})
|
||||
|
||||
export const LightMode = React.forwardRef<HTMLSpanElement, SpanProps>(
|
||||
function LightMode(props, ref) {
|
||||
return (
|
||||
<Span
|
||||
color="fg"
|
||||
display="contents"
|
||||
className="chakra-theme light"
|
||||
colorPalette="gray"
|
||||
colorScheme="light"
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const DarkMode = React.forwardRef<HTMLSpanElement, SpanProps>(
|
||||
function DarkMode(props, ref) {
|
||||
return (
|
||||
<Span
|
||||
color="fg"
|
||||
display="contents"
|
||||
className="chakra-theme dark"
|
||||
colorPalette="gray"
|
||||
colorScheme="dark"
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
62
frontend/src/components/ui/dialog.tsx
Normal file
62
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Dialog as ChakraDialog, Portal } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
import { CloseButton } from "./close-button"
|
||||
|
||||
interface DialogContentProps extends ChakraDialog.ContentProps {
|
||||
portalled?: boolean
|
||||
portalRef?: React.RefObject<HTMLElement>
|
||||
backdrop?: boolean
|
||||
}
|
||||
|
||||
export const DialogContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
DialogContentProps
|
||||
>(function DialogContent(props, ref) {
|
||||
const {
|
||||
children,
|
||||
portalled = true,
|
||||
portalRef,
|
||||
backdrop = true,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
{backdrop && <ChakraDialog.Backdrop />}
|
||||
<ChakraDialog.Positioner>
|
||||
<ChakraDialog.Content ref={ref} {...rest} asChild={false}>
|
||||
{children}
|
||||
</ChakraDialog.Content>
|
||||
</ChakraDialog.Positioner>
|
||||
</Portal>
|
||||
)
|
||||
})
|
||||
|
||||
export const DialogCloseTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ChakraDialog.CloseTriggerProps
|
||||
>(function DialogCloseTrigger(props, ref) {
|
||||
return (
|
||||
<ChakraDialog.CloseTrigger
|
||||
position="absolute"
|
||||
top="2"
|
||||
insetEnd="2"
|
||||
{...props}
|
||||
asChild
|
||||
>
|
||||
<CloseButton size="sm" ref={ref}>
|
||||
{props.children}
|
||||
</CloseButton>
|
||||
</ChakraDialog.CloseTrigger>
|
||||
)
|
||||
})
|
||||
|
||||
export const DialogRoot = ChakraDialog.Root
|
||||
export const DialogFooter = ChakraDialog.Footer
|
||||
export const DialogHeader = ChakraDialog.Header
|
||||
export const DialogBody = ChakraDialog.Body
|
||||
export const DialogBackdrop = ChakraDialog.Backdrop
|
||||
export const DialogTitle = ChakraDialog.Title
|
||||
export const DialogDescription = ChakraDialog.Description
|
||||
export const DialogTrigger = ChakraDialog.Trigger
|
||||
export const DialogActionTrigger = ChakraDialog.ActionTrigger
|
||||
52
frontend/src/components/ui/drawer.tsx
Normal file
52
frontend/src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Drawer as ChakraDrawer, Portal } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
import { CloseButton } from "./close-button"
|
||||
|
||||
interface DrawerContentProps extends ChakraDrawer.ContentProps {
|
||||
portalled?: boolean
|
||||
portalRef?: React.RefObject<HTMLElement>
|
||||
offset?: ChakraDrawer.ContentProps["padding"]
|
||||
}
|
||||
|
||||
export const DrawerContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
DrawerContentProps
|
||||
>(function DrawerContent(props, ref) {
|
||||
const { children, portalled = true, portalRef, offset, ...rest } = props
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraDrawer.Positioner padding={offset}>
|
||||
<ChakraDrawer.Content ref={ref} {...rest} asChild={false}>
|
||||
{children}
|
||||
</ChakraDrawer.Content>
|
||||
</ChakraDrawer.Positioner>
|
||||
</Portal>
|
||||
)
|
||||
})
|
||||
|
||||
export const DrawerCloseTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ChakraDrawer.CloseTriggerProps
|
||||
>(function DrawerCloseTrigger(props, ref) {
|
||||
return (
|
||||
<ChakraDrawer.CloseTrigger
|
||||
position="absolute"
|
||||
top="2"
|
||||
insetEnd="2"
|
||||
{...props}
|
||||
asChild
|
||||
>
|
||||
<CloseButton size="sm" ref={ref} />
|
||||
</ChakraDrawer.CloseTrigger>
|
||||
)
|
||||
})
|
||||
|
||||
export const DrawerTrigger = ChakraDrawer.Trigger
|
||||
export const DrawerRoot = ChakraDrawer.Root
|
||||
export const DrawerFooter = ChakraDrawer.Footer
|
||||
export const DrawerHeader = ChakraDrawer.Header
|
||||
export const DrawerBody = ChakraDrawer.Body
|
||||
export const DrawerBackdrop = ChakraDrawer.Backdrop
|
||||
export const DrawerDescription = ChakraDrawer.Description
|
||||
export const DrawerTitle = ChakraDrawer.Title
|
||||
export const DrawerActionTrigger = ChakraDrawer.ActionTrigger
|
||||
33
frontend/src/components/ui/field.tsx
Normal file
33
frontend/src/components/ui/field.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Field as ChakraField } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
|
||||
export interface FieldProps extends Omit<ChakraField.RootProps, "label"> {
|
||||
label?: React.ReactNode
|
||||
helperText?: React.ReactNode
|
||||
errorText?: React.ReactNode
|
||||
optionalText?: React.ReactNode
|
||||
}
|
||||
|
||||
export const Field = React.forwardRef<HTMLDivElement, FieldProps>(
|
||||
function Field(props, ref) {
|
||||
const { label, children, helperText, errorText, optionalText, ...rest } =
|
||||
props
|
||||
return (
|
||||
<ChakraField.Root ref={ref} {...rest}>
|
||||
{label && (
|
||||
<ChakraField.Label>
|
||||
{label}
|
||||
<ChakraField.RequiredIndicator fallback={optionalText} />
|
||||
</ChakraField.Label>
|
||||
)}
|
||||
{children}
|
||||
{helperText && (
|
||||
<ChakraField.HelperText>{helperText}</ChakraField.HelperText>
|
||||
)}
|
||||
{errorText && (
|
||||
<ChakraField.ErrorText>{errorText}</ChakraField.ErrorText>
|
||||
)}
|
||||
</ChakraField.Root>
|
||||
)
|
||||
},
|
||||
)
|
||||
53
frontend/src/components/ui/input-group.tsx
Normal file
53
frontend/src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { BoxProps, InputElementProps } from "@chakra-ui/react"
|
||||
import { Group, InputElement } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
|
||||
export interface InputGroupProps extends BoxProps {
|
||||
startElementProps?: InputElementProps
|
||||
endElementProps?: InputElementProps
|
||||
startElement?: React.ReactNode
|
||||
endElement?: React.ReactNode
|
||||
children: React.ReactElement<InputElementProps>
|
||||
startOffset?: InputElementProps["paddingStart"]
|
||||
endOffset?: InputElementProps["paddingEnd"]
|
||||
}
|
||||
|
||||
export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
|
||||
function InputGroup(props, ref) {
|
||||
const {
|
||||
startElement,
|
||||
startElementProps,
|
||||
endElement,
|
||||
endElementProps,
|
||||
children,
|
||||
startOffset = "6px",
|
||||
endOffset = "6px",
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const child =
|
||||
React.Children.only<React.ReactElement<InputElementProps>>(children)
|
||||
|
||||
return (
|
||||
<Group ref={ref} {...rest}>
|
||||
{startElement && (
|
||||
<InputElement pointerEvents="none" {...startElementProps}>
|
||||
{startElement}
|
||||
</InputElement>
|
||||
)}
|
||||
{React.cloneElement(child, {
|
||||
...(startElement && {
|
||||
ps: `calc(var(--input-height) - ${startOffset})`,
|
||||
}),
|
||||
...(endElement && { pe: `calc(var(--input-height) - ${endOffset})` }),
|
||||
...children.props,
|
||||
})}
|
||||
{endElement && (
|
||||
<InputElement placement="end" {...endElementProps}>
|
||||
{endElement}
|
||||
</InputElement>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
},
|
||||
)
|
||||
12
frontend/src/components/ui/link-button.tsx
Normal file
12
frontend/src/components/ui/link-button.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import type { HTMLChakraProps, RecipeProps } from "@chakra-ui/react"
|
||||
import { createRecipeContext } from "@chakra-ui/react"
|
||||
|
||||
export interface LinkButtonProps
|
||||
extends HTMLChakraProps<"a", RecipeProps<"button">> {}
|
||||
|
||||
const { withContext } = createRecipeContext({ key: "button" })
|
||||
|
||||
// Replace "a" with your framework's link component
|
||||
export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>("a")
|
||||
112
frontend/src/components/ui/menu.tsx
Normal file
112
frontend/src/components/ui/menu.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import { AbsoluteCenter, Menu as ChakraMenu, Portal } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
import { LuCheck, LuChevronRight } from "react-icons/lu"
|
||||
|
||||
interface MenuContentProps extends ChakraMenu.ContentProps {
|
||||
portalled?: boolean
|
||||
portalRef?: React.RefObject<HTMLElement>
|
||||
}
|
||||
|
||||
export const MenuContent = React.forwardRef<HTMLDivElement, MenuContentProps>(
|
||||
function MenuContent(props, ref) {
|
||||
const { portalled = true, portalRef, ...rest } = props
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraMenu.Positioner>
|
||||
<ChakraMenu.Content ref={ref} {...rest} />
|
||||
</ChakraMenu.Positioner>
|
||||
</Portal>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const MenuArrow = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChakraMenu.ArrowProps
|
||||
>(function MenuArrow(props, ref) {
|
||||
return (
|
||||
<ChakraMenu.Arrow ref={ref} {...props}>
|
||||
<ChakraMenu.ArrowTip />
|
||||
</ChakraMenu.Arrow>
|
||||
)
|
||||
})
|
||||
|
||||
export const MenuCheckboxItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChakraMenu.CheckboxItemProps
|
||||
>(function MenuCheckboxItem(props, ref) {
|
||||
return (
|
||||
<ChakraMenu.CheckboxItem ps="8" ref={ref} {...props}>
|
||||
<AbsoluteCenter axis="horizontal" insetStart="4" asChild>
|
||||
<ChakraMenu.ItemIndicator>
|
||||
<LuCheck />
|
||||
</ChakraMenu.ItemIndicator>
|
||||
</AbsoluteCenter>
|
||||
{props.children}
|
||||
</ChakraMenu.CheckboxItem>
|
||||
)
|
||||
})
|
||||
|
||||
export const MenuRadioItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChakraMenu.RadioItemProps
|
||||
>(function MenuRadioItem(props, ref) {
|
||||
const { children, ...rest } = props
|
||||
return (
|
||||
<ChakraMenu.RadioItem ps="8" ref={ref} {...rest}>
|
||||
<AbsoluteCenter axis="horizontal" insetStart="4" asChild>
|
||||
<ChakraMenu.ItemIndicator>
|
||||
<LuCheck />
|
||||
</ChakraMenu.ItemIndicator>
|
||||
</AbsoluteCenter>
|
||||
<ChakraMenu.ItemText>{children}</ChakraMenu.ItemText>
|
||||
</ChakraMenu.RadioItem>
|
||||
)
|
||||
})
|
||||
|
||||
export const MenuItemGroup = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChakraMenu.ItemGroupProps
|
||||
>(function MenuItemGroup(props, ref) {
|
||||
const { title, children, ...rest } = props
|
||||
return (
|
||||
<ChakraMenu.ItemGroup ref={ref} {...rest}>
|
||||
{title && (
|
||||
<ChakraMenu.ItemGroupLabel userSelect="none">
|
||||
{title}
|
||||
</ChakraMenu.ItemGroupLabel>
|
||||
)}
|
||||
{children}
|
||||
</ChakraMenu.ItemGroup>
|
||||
)
|
||||
})
|
||||
|
||||
export interface MenuTriggerItemProps extends ChakraMenu.ItemProps {
|
||||
startIcon?: React.ReactNode
|
||||
}
|
||||
|
||||
export const MenuTriggerItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
MenuTriggerItemProps
|
||||
>(function MenuTriggerItem(props, ref) {
|
||||
const { startIcon, children, ...rest } = props
|
||||
return (
|
||||
<ChakraMenu.TriggerItem ref={ref} {...rest}>
|
||||
{startIcon}
|
||||
{children}
|
||||
<LuChevronRight />
|
||||
</ChakraMenu.TriggerItem>
|
||||
)
|
||||
})
|
||||
|
||||
export const MenuRadioItemGroup = ChakraMenu.RadioItemGroup
|
||||
export const MenuContextTrigger = ChakraMenu.ContextTrigger
|
||||
export const MenuRoot = ChakraMenu.Root
|
||||
export const MenuSeparator = ChakraMenu.Separator
|
||||
|
||||
export const MenuItem = ChakraMenu.Item
|
||||
export const MenuItemText = ChakraMenu.ItemText
|
||||
export const MenuItemCommand = ChakraMenu.ItemCommand
|
||||
export const MenuTrigger = ChakraMenu.Trigger
|
||||
211
frontend/src/components/ui/pagination.tsx
Normal file
211
frontend/src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
"use client"
|
||||
|
||||
import type { ButtonProps, TextProps } from "@chakra-ui/react"
|
||||
import {
|
||||
Button,
|
||||
Pagination as ChakraPagination,
|
||||
IconButton,
|
||||
Text,
|
||||
createContext,
|
||||
usePaginationContext,
|
||||
} from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
import {
|
||||
HiChevronLeft,
|
||||
HiChevronRight,
|
||||
HiMiniEllipsisHorizontal,
|
||||
} from "react-icons/hi2"
|
||||
import { LinkButton } from "./link-button"
|
||||
|
||||
interface ButtonVariantMap {
|
||||
current: ButtonProps["variant"]
|
||||
default: ButtonProps["variant"]
|
||||
ellipsis: ButtonProps["variant"]
|
||||
}
|
||||
|
||||
type PaginationVariant = "outline" | "solid" | "subtle"
|
||||
|
||||
interface ButtonVariantContext {
|
||||
size: ButtonProps["size"]
|
||||
variantMap: ButtonVariantMap
|
||||
getHref?: (page: number) => string
|
||||
}
|
||||
|
||||
const [RootPropsProvider, useRootProps] = createContext<ButtonVariantContext>({
|
||||
name: "RootPropsProvider",
|
||||
})
|
||||
|
||||
export interface PaginationRootProps
|
||||
extends Omit<ChakraPagination.RootProps, "type"> {
|
||||
size?: ButtonProps["size"]
|
||||
variant?: PaginationVariant
|
||||
getHref?: (page: number) => string
|
||||
}
|
||||
|
||||
const variantMap: Record<PaginationVariant, ButtonVariantMap> = {
|
||||
outline: { default: "ghost", ellipsis: "plain", current: "outline" },
|
||||
solid: { default: "outline", ellipsis: "outline", current: "solid" },
|
||||
subtle: { default: "ghost", ellipsis: "plain", current: "subtle" },
|
||||
}
|
||||
|
||||
export const PaginationRoot = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
PaginationRootProps
|
||||
>(function PaginationRoot(props, ref) {
|
||||
const { size = "sm", variant = "outline", getHref, ...rest } = props
|
||||
return (
|
||||
<RootPropsProvider
|
||||
value={{ size, variantMap: variantMap[variant], getHref }}
|
||||
>
|
||||
<ChakraPagination.Root
|
||||
ref={ref}
|
||||
type={getHref ? "link" : "button"}
|
||||
{...rest}
|
||||
/>
|
||||
</RootPropsProvider>
|
||||
)
|
||||
})
|
||||
|
||||
export const PaginationEllipsis = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChakraPagination.EllipsisProps
|
||||
>(function PaginationEllipsis(props, ref) {
|
||||
const { size, variantMap } = useRootProps()
|
||||
return (
|
||||
<ChakraPagination.Ellipsis ref={ref} {...props} asChild>
|
||||
<Button as="span" variant={variantMap.ellipsis} size={size}>
|
||||
<HiMiniEllipsisHorizontal />
|
||||
</Button>
|
||||
</ChakraPagination.Ellipsis>
|
||||
)
|
||||
})
|
||||
|
||||
export const PaginationItem = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ChakraPagination.ItemProps
|
||||
>(function PaginationItem(props, ref) {
|
||||
const { page } = usePaginationContext()
|
||||
const { size, variantMap, getHref } = useRootProps()
|
||||
|
||||
const current = page === props.value
|
||||
const variant = current ? variantMap.current : variantMap.default
|
||||
|
||||
if (getHref) {
|
||||
return (
|
||||
<LinkButton href={getHref(props.value)} variant={variant} size={size}>
|
||||
{props.value}
|
||||
</LinkButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChakraPagination.Item ref={ref} {...props} asChild>
|
||||
<Button variant={variant} size={size}>
|
||||
{props.value}
|
||||
</Button>
|
||||
</ChakraPagination.Item>
|
||||
)
|
||||
})
|
||||
|
||||
export const PaginationPrevTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ChakraPagination.PrevTriggerProps
|
||||
>(function PaginationPrevTrigger(props, ref) {
|
||||
const { size, variantMap, getHref } = useRootProps()
|
||||
const { previousPage } = usePaginationContext()
|
||||
|
||||
if (getHref) {
|
||||
return (
|
||||
<LinkButton
|
||||
href={previousPage != null ? getHref(previousPage) : undefined}
|
||||
variant={variantMap.default}
|
||||
size={size}
|
||||
>
|
||||
<HiChevronLeft />
|
||||
</LinkButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChakraPagination.PrevTrigger ref={ref} asChild {...props}>
|
||||
<IconButton variant={variantMap.default} size={size}>
|
||||
<HiChevronLeft />
|
||||
</IconButton>
|
||||
</ChakraPagination.PrevTrigger>
|
||||
)
|
||||
})
|
||||
|
||||
export const PaginationNextTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ChakraPagination.NextTriggerProps
|
||||
>(function PaginationNextTrigger(props, ref) {
|
||||
const { size, variantMap, getHref } = useRootProps()
|
||||
const { nextPage } = usePaginationContext()
|
||||
|
||||
if (getHref) {
|
||||
return (
|
||||
<LinkButton
|
||||
href={nextPage != null ? getHref(nextPage) : undefined}
|
||||
variant={variantMap.default}
|
||||
size={size}
|
||||
>
|
||||
<HiChevronRight />
|
||||
</LinkButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChakraPagination.NextTrigger ref={ref} asChild {...props}>
|
||||
<IconButton variant={variantMap.default} size={size}>
|
||||
<HiChevronRight />
|
||||
</IconButton>
|
||||
</ChakraPagination.NextTrigger>
|
||||
)
|
||||
})
|
||||
|
||||
export const PaginationItems = (props: React.HTMLAttributes<HTMLElement>) => {
|
||||
return (
|
||||
<ChakraPagination.Context>
|
||||
{({ pages }) =>
|
||||
pages.map((page, index) => {
|
||||
return page.type === "ellipsis" ? (
|
||||
<PaginationEllipsis key={index} index={index} {...props} />
|
||||
) : (
|
||||
<PaginationItem
|
||||
key={index}
|
||||
type="page"
|
||||
value={page.value}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</ChakraPagination.Context>
|
||||
)
|
||||
}
|
||||
|
||||
interface PageTextProps extends TextProps {
|
||||
format?: "short" | "compact" | "long"
|
||||
}
|
||||
|
||||
export const PaginationPageText = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
PageTextProps
|
||||
>(function PaginationPageText(props, ref) {
|
||||
const { format = "compact", ...rest } = props
|
||||
const { page, totalPages, pageRange, count } = usePaginationContext()
|
||||
const content = React.useMemo(() => {
|
||||
if (format === "short") return `${page} / ${totalPages}`
|
||||
if (format === "compact") return `${page} of ${totalPages}`
|
||||
return `${pageRange.start + 1} - ${Math.min(
|
||||
pageRange.end,
|
||||
count,
|
||||
)} of ${count}`
|
||||
}, [format, page, totalPages, pageRange, count])
|
||||
|
||||
return (
|
||||
<Text fontWeight="medium" ref={ref} {...rest}>
|
||||
{content}
|
||||
</Text>
|
||||
)
|
||||
})
|
||||
162
frontend/src/components/ui/password-input.tsx
Normal file
162
frontend/src/components/ui/password-input.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
import type {
|
||||
ButtonProps,
|
||||
GroupProps,
|
||||
InputProps,
|
||||
StackProps,
|
||||
} from "@chakra-ui/react"
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
IconButton,
|
||||
Input,
|
||||
Stack,
|
||||
mergeRefs,
|
||||
useControllableState,
|
||||
} from "@chakra-ui/react"
|
||||
import { forwardRef, useRef } from "react"
|
||||
import { FiEye, FiEyeOff } from "react-icons/fi"
|
||||
import { Field } from "./field"
|
||||
import { InputGroup } from "./input-group"
|
||||
|
||||
export interface PasswordVisibilityProps {
|
||||
defaultVisible?: boolean
|
||||
visible?: boolean
|
||||
onVisibleChange?: (visible: boolean) => void
|
||||
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode }
|
||||
}
|
||||
|
||||
export interface PasswordInputProps
|
||||
extends InputProps,
|
||||
PasswordVisibilityProps {
|
||||
rootProps?: GroupProps
|
||||
startElement?: React.ReactNode
|
||||
type: string
|
||||
errors: any
|
||||
}
|
||||
|
||||
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
|
||||
function PasswordInput(props, ref) {
|
||||
const {
|
||||
rootProps,
|
||||
defaultVisible,
|
||||
visible: visibleProp,
|
||||
onVisibleChange,
|
||||
visibilityIcon = { on: <FiEye />, off: <FiEyeOff /> },
|
||||
startElement,
|
||||
type,
|
||||
errors,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const [visible, setVisible] = useControllableState({
|
||||
value: visibleProp,
|
||||
defaultValue: defaultVisible || false,
|
||||
onChange: onVisibleChange,
|
||||
})
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<Field
|
||||
invalid={!!errors[type]}
|
||||
errorText={errors[type]?.message}
|
||||
alignSelf="start"
|
||||
>
|
||||
<InputGroup
|
||||
width="100%"
|
||||
startElement={startElement}
|
||||
endElement={
|
||||
<VisibilityTrigger
|
||||
disabled={rest.disabled}
|
||||
onPointerDown={(e) => {
|
||||
if (rest.disabled) return
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
setVisible(!visible)
|
||||
}}
|
||||
>
|
||||
{visible ? visibilityIcon.off : visibilityIcon.on}
|
||||
</VisibilityTrigger>
|
||||
}
|
||||
{...rootProps}
|
||||
>
|
||||
<Input
|
||||
{...rest}
|
||||
ref={mergeRefs(ref, inputRef)}
|
||||
type={visible ? "text" : "password"}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const VisibilityTrigger = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
function VisibilityTrigger(props, ref) {
|
||||
return (
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
ref={ref}
|
||||
me="-2"
|
||||
aspectRatio="square"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
height="calc(100% - {spacing.2})"
|
||||
aria-label="Toggle password visibility"
|
||||
color="inherit"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
interface PasswordStrengthMeterProps extends StackProps {
|
||||
max?: number
|
||||
value: number
|
||||
}
|
||||
|
||||
export const PasswordStrengthMeter = forwardRef<
|
||||
HTMLDivElement,
|
||||
PasswordStrengthMeterProps
|
||||
>(function PasswordStrengthMeter(props, ref) {
|
||||
const { max = 4, value, ...rest } = props
|
||||
|
||||
const percent = (value / max) * 100
|
||||
const { label, colorPalette } = getColorPalette(percent)
|
||||
|
||||
return (
|
||||
<Stack align="flex-end" gap="1" ref={ref} {...rest}>
|
||||
<HStack width="full" ref={ref} {...rest}>
|
||||
{Array.from({ length: max }).map((_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
height="1"
|
||||
flex="1"
|
||||
rounded="sm"
|
||||
data-selected={index < value ? "" : undefined}
|
||||
layerStyle="fill.subtle"
|
||||
colorPalette="gray"
|
||||
_selected={{
|
||||
colorPalette,
|
||||
layerStyle: "fill.solid",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
{label && <HStack textStyle="xs">{label}</HStack>}
|
||||
</Stack>
|
||||
)
|
||||
})
|
||||
|
||||
function getColorPalette(percent: number) {
|
||||
switch (true) {
|
||||
case percent < 33:
|
||||
return { label: "Low", colorPalette: "red" }
|
||||
case percent < 66:
|
||||
return { label: "Medium", colorPalette: "orange" }
|
||||
default:
|
||||
return { label: "High", colorPalette: "green" }
|
||||
}
|
||||
}
|
||||
18
frontend/src/components/ui/provider.tsx
Normal file
18
frontend/src/components/ui/provider.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import { ChakraProvider } from "@chakra-ui/react"
|
||||
import React, { type PropsWithChildren } from "react"
|
||||
import { system } from "../../theme"
|
||||
import { ColorModeProvider } from "./color-mode"
|
||||
import { Toaster } from "./toaster"
|
||||
|
||||
export function CustomProvider(props: PropsWithChildren) {
|
||||
return (
|
||||
<ChakraProvider value={system}>
|
||||
<ColorModeProvider defaultTheme="light">
|
||||
{props.children}
|
||||
</ColorModeProvider>
|
||||
<Toaster />
|
||||
</ChakraProvider>
|
||||
)
|
||||
}
|
||||
24
frontend/src/components/ui/radio.tsx
Normal file
24
frontend/src/components/ui/radio.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { RadioGroup as ChakraRadioGroup } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
|
||||
export interface RadioProps extends ChakraRadioGroup.ItemProps {
|
||||
rootRef?: React.Ref<HTMLDivElement>
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>
|
||||
}
|
||||
|
||||
export const Radio = React.forwardRef<HTMLInputElement, RadioProps>(
|
||||
function Radio(props, ref) {
|
||||
const { children, inputProps, rootRef, ...rest } = props
|
||||
return (
|
||||
<ChakraRadioGroup.Item ref={rootRef} {...rest}>
|
||||
<ChakraRadioGroup.ItemHiddenInput ref={ref} {...inputProps} />
|
||||
<ChakraRadioGroup.ItemIndicator />
|
||||
{children && (
|
||||
<ChakraRadioGroup.ItemText>{children}</ChakraRadioGroup.ItemText>
|
||||
)}
|
||||
</ChakraRadioGroup.Item>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const RadioGroup = ChakraRadioGroup.Root
|
||||
47
frontend/src/components/ui/skeleton.tsx
Normal file
47
frontend/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
SkeletonProps as ChakraSkeletonProps,
|
||||
CircleProps,
|
||||
} from "@chakra-ui/react"
|
||||
import { Skeleton as ChakraSkeleton, Circle, Stack } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
|
||||
export interface SkeletonCircleProps extends ChakraSkeletonProps {
|
||||
size?: CircleProps["size"]
|
||||
}
|
||||
|
||||
export const SkeletonCircle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
SkeletonCircleProps
|
||||
>(function SkeletonCircle(props, ref) {
|
||||
const { size, ...rest } = props
|
||||
return (
|
||||
<Circle size={size} asChild ref={ref}>
|
||||
<ChakraSkeleton {...rest} />
|
||||
</Circle>
|
||||
)
|
||||
})
|
||||
|
||||
export interface SkeletonTextProps extends ChakraSkeletonProps {
|
||||
noOfLines?: number
|
||||
}
|
||||
|
||||
export const SkeletonText = React.forwardRef<HTMLDivElement, SkeletonTextProps>(
|
||||
function SkeletonText(props, ref) {
|
||||
const { noOfLines = 3, gap, ...rest } = props
|
||||
return (
|
||||
<Stack gap={gap} width="full" ref={ref}>
|
||||
{Array.from({ length: noOfLines }).map((_, index) => (
|
||||
<ChakraSkeleton
|
||||
height="4"
|
||||
key={index}
|
||||
{...props}
|
||||
_last={{ maxW: "80%" }}
|
||||
{...rest}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const Skeleton = ChakraSkeleton
|
||||
43
frontend/src/components/ui/toaster.tsx
Normal file
43
frontend/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Toaster as ChakraToaster,
|
||||
Portal,
|
||||
Spinner,
|
||||
Stack,
|
||||
Toast,
|
||||
createToaster,
|
||||
} from "@chakra-ui/react"
|
||||
|
||||
export const toaster = createToaster({
|
||||
placement: "top-end",
|
||||
pauseOnPageIdle: true,
|
||||
})
|
||||
|
||||
export const Toaster = () => {
|
||||
return (
|
||||
<Portal>
|
||||
<ChakraToaster toaster={toaster} insetInline={{ mdDown: "4" }}>
|
||||
{(toast) => (
|
||||
<Toast.Root width={{ md: "sm" }} color={toast.meta?.color}>
|
||||
{toast.type === "loading" ? (
|
||||
<Spinner size="sm" color="blue.solid" />
|
||||
) : (
|
||||
<Toast.Indicator />
|
||||
)}
|
||||
<Stack gap="1" flex="1" maxWidth="100%">
|
||||
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
|
||||
{toast.description && (
|
||||
<Toast.Description>{toast.description}</Toast.Description>
|
||||
)}
|
||||
</Stack>
|
||||
{toast.action && (
|
||||
<Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>
|
||||
)}
|
||||
{toast.meta?.closable && <Toast.CloseTrigger />}
|
||||
</Toast.Root>
|
||||
)}
|
||||
</ChakraToaster>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user