significant refactor
This commit is contained in:
@@ -122,7 +122,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
size="compact-sm"
|
||||
onClick={toggleDetails}
|
||||
>
|
||||
{detailsOpened ? 'Hide' : 'Show'} technical details
|
||||
{detailsOpened ? 'Hide' : 'Show'} stack trace
|
||||
</Button>
|
||||
<Collapse in={detailsOpened}>
|
||||
<Code block mt="md" p="md">
|
||||
|
||||
@@ -1,18 +1,43 @@
|
||||
import { Avatar as MantineAvatar, AvatarProps as MantineAvatarProps, Paper } from '@mantine/core';
|
||||
import {
|
||||
Avatar as MantineAvatar,
|
||||
AvatarProps as MantineAvatarProps,
|
||||
Paper,
|
||||
} from "@mantine/core";
|
||||
|
||||
interface AvatarProps extends Omit<MantineAvatarProps, 'radius' | 'color' | 'size'> {
|
||||
interface AvatarProps
|
||||
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
||||
name: string;
|
||||
size?: number;
|
||||
radius?: string | number;
|
||||
withBorder?: boolean;
|
||||
}
|
||||
|
||||
const Avatar = ({ name, size = 35, radius = '100%', withBorder = true, ...props }: AvatarProps) => {
|
||||
return <Paper p={size / 20} radius={radius} withBorder={withBorder}>
|
||||
<MantineAvatar alt={name} key={name} name={name} color='initials' size={size} radius={radius} w='fit-content' styles={{ image: {
|
||||
objectFit: 'contain'
|
||||
} }} {...props} />
|
||||
</Paper>
|
||||
}
|
||||
const Avatar = ({
|
||||
name,
|
||||
size = 35,
|
||||
radius = "100%",
|
||||
withBorder = true,
|
||||
...props
|
||||
}: AvatarProps) => {
|
||||
return (
|
||||
<Paper p={size / 20} radius={radius} withBorder={withBorder}>
|
||||
<MantineAvatar
|
||||
alt={name}
|
||||
key={name}
|
||||
name={name}
|
||||
color="initials"
|
||||
size={size}
|
||||
radius={radius}
|
||||
w="fit-content"
|
||||
styles={{
|
||||
image: {
|
||||
objectFit: "contain",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Avatar;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Button as MantineButton, ButtonProps as MantineButtonProps } from '@mantine/core';
|
||||
import { forwardRef, ComponentPropsWithoutRef } from 'react';
|
||||
import {
|
||||
Button as MantineButton,
|
||||
ButtonProps as MantineButtonProps,
|
||||
} from "@mantine/core";
|
||||
import { forwardRef, ComponentPropsWithoutRef } from "react";
|
||||
|
||||
type ButtonProps = MantineButtonProps & ComponentPropsWithoutRef<'button'>;
|
||||
type ButtonProps = MantineButtonProps & ComponentPropsWithoutRef<"button">;
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
||||
return <MantineButton fullWidth ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
Button.displayName = 'Button';
|
||||
Button.displayName = "Button";
|
||||
export default Button;
|
||||
|
||||
@@ -10,13 +10,18 @@ interface DateTimePickerProps {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const DateTimePicker = ({ value, onChange, label, ...rest }: DateTimePickerProps) => {
|
||||
const DateTimePicker = ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
...rest
|
||||
}: DateTimePickerProps) => {
|
||||
const timeRef = useRef<HTMLInputElement>(null);
|
||||
const currentDate = value ? new Date(value) : null;
|
||||
|
||||
const formatDate = (date: Date | null): string => {
|
||||
if (!date) return "";
|
||||
return date.toISOString().split('T')[0];
|
||||
return date.toISOString().split("T")[0];
|
||||
};
|
||||
|
||||
const formatTime = (date: Date | null): string => {
|
||||
@@ -26,35 +31,35 @@ const DateTimePicker = ({ value, onChange, label, ...rest }: DateTimePickerProps
|
||||
|
||||
const handleDateChange = (dateString: string | null) => {
|
||||
if (!dateString) {
|
||||
onChange('');
|
||||
onChange("");
|
||||
return;
|
||||
}
|
||||
|
||||
const newDate = new Date(dateString + 'T00:00:00');
|
||||
|
||||
const newDate = new Date(dateString + "T00:00:00");
|
||||
|
||||
if (currentDate) {
|
||||
newDate.setHours(currentDate.getHours());
|
||||
newDate.setMinutes(currentDate.getMinutes());
|
||||
}
|
||||
|
||||
|
||||
onChange(newDate.toISOString());
|
||||
};
|
||||
|
||||
const handleTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const timeValue = event.target.value;
|
||||
if (!timeValue) return;
|
||||
|
||||
const [hours, minutes] = timeValue.split(':').map(Number);
|
||||
|
||||
const [hours, minutes] = timeValue.split(":").map(Number);
|
||||
if (isNaN(hours) || isNaN(minutes)) return;
|
||||
|
||||
|
||||
const baseDate = currentDate || new Date();
|
||||
const newDate = new Date(baseDate);
|
||||
|
||||
|
||||
newDate.setHours(hours);
|
||||
newDate.setMinutes(minutes);
|
||||
newDate.setSeconds(0);
|
||||
newDate.setMilliseconds(0);
|
||||
|
||||
|
||||
onChange(newDate.toISOString());
|
||||
};
|
||||
|
||||
@@ -73,9 +78,9 @@ const DateTimePicker = ({ value, onChange, label, ...rest }: DateTimePickerProps
|
||||
value={formatTime(currentDate)}
|
||||
onChange={handleTimeChange}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => timeRef.current?.showPicker()}
|
||||
>
|
||||
<ClockIcon size={16} />
|
||||
@@ -87,4 +92,4 @@ const DateTimePicker = ({ value, onChange, label, ...rest }: DateTimePickerProps
|
||||
);
|
||||
};
|
||||
|
||||
export { DateTimePicker };
|
||||
export { DateTimePicker };
|
||||
|
||||
@@ -10,21 +10,18 @@ interface ListButtonProps {
|
||||
const ListButton = ({ label, onClick, Icon }: ListButtonProps) => {
|
||||
return (
|
||||
<>
|
||||
<UnstyledButton
|
||||
w='100%'
|
||||
p='md'
|
||||
component={'button'}
|
||||
onClick={onClick}
|
||||
>
|
||||
<UnstyledButton w="100%" p="md" component={"button"} onClick={onClick}>
|
||||
<Group>
|
||||
<Icon weight='bold' size={20} />
|
||||
<Text fw={500} size='md'>{label}</Text>
|
||||
<CaretRightIcon style={{ marginLeft: 'auto' }} size={20} />
|
||||
<Icon weight="bold" size={20} />
|
||||
<Text fw={500} size="md">
|
||||
{label}
|
||||
</Text>
|
||||
<CaretRightIcon style={{ marginLeft: "auto" }} size={20} />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Divider />
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default ListButton;
|
||||
|
||||
@@ -14,17 +14,21 @@ const ListLink = ({ label, to, Icon }: ListLinkProps) => {
|
||||
return (
|
||||
<>
|
||||
<NavLink
|
||||
w='100%'
|
||||
p='md'
|
||||
component={'button'}
|
||||
w="100%"
|
||||
p="md"
|
||||
component={"button"}
|
||||
onClick={() => navigate({ to })}
|
||||
label={<Text fw={500} size='md'>{label}</Text>}
|
||||
leftSection={Icon && <Icon weight='bold' size={20} />}
|
||||
label={
|
||||
<Text fw={500} size="md">
|
||||
{label}
|
||||
</Text>
|
||||
}
|
||||
leftSection={Icon && <Icon weight="bold" size={20} />}
|
||||
rightSection={<CaretRightIcon size={20} />}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default ListLink;
|
||||
|
||||
@@ -7,9 +7,19 @@ interface PageProps extends ContainerProps, React.PropsWithChildren {
|
||||
|
||||
const Page = ({ children, noPadding, ...props }: PageProps) => {
|
||||
const { header } = useRouterConfig();
|
||||
return <Container px={noPadding ? 0 : 'md'} pt={header.collapsed ? 60 : 0} pb={20} m={0} maw={600} mx='auto' {...props}>
|
||||
return (
|
||||
<Container
|
||||
px={noPadding ? 0 : "md"}
|
||||
pt={header.collapsed ? 60 : 0}
|
||||
pb={20}
|
||||
m={0}
|
||||
maw={600}
|
||||
mx="auto"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Container>
|
||||
}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Input, InputProps, Group, Text } from '@mantine/core';
|
||||
import { CheckFat, Phone } from '@phosphor-icons/react';
|
||||
import { IMaskInput } from 'react-imask';
|
||||
import { Input, InputProps, Group, Text } from "@mantine/core";
|
||||
import { CheckFat, Phone } from "@phosphor-icons/react";
|
||||
import { IMaskInput } from "react-imask";
|
||||
|
||||
interface PhoneNumberInputProps extends InputProps {
|
||||
id: string;
|
||||
@@ -11,24 +11,48 @@ interface PhoneNumberInputProps extends InputProps {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const PhoneNumberInput: React.FC<PhoneNumberInputProps> = ({ id, value, onChange, label, description, error, ...props }) => {
|
||||
const PhoneNumberInput: React.FC<PhoneNumberInputProps> = ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
description,
|
||||
error,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Input.Wrapper id={id} label={label} description={description} error={error}>
|
||||
<Input.Wrapper
|
||||
id={id}
|
||||
label={label}
|
||||
description={description}
|
||||
error={error}
|
||||
>
|
||||
<Input
|
||||
id={id}
|
||||
component={IMaskInput}
|
||||
mask="(000) 000-0000"
|
||||
leftSection={<Group gap={2}><Phone size={20} /> <Text c='dimmed' size='sm'>+1</Text></Group>}
|
||||
leftSection={
|
||||
<Group gap={2}>
|
||||
<Phone size={20} /> {" "}
|
||||
<Text c="dimmed" size="sm">
|
||||
+1
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
leftSectionWidth={50}
|
||||
leftSectionProps={{ style: { padding: 0 } }}
|
||||
placeholder="(713) 867-5309"
|
||||
onAccept={(_, mask) => onChange(mask.unmaskedValue)}
|
||||
rightSection={value?.length === 10 && <CheckFat color='green' size={20} weight='fill' />}
|
||||
rightSection={
|
||||
value?.length === 10 && (
|
||||
<CheckFat color="green" size={20} weight="fill" />
|
||||
)
|
||||
}
|
||||
value={value}
|
||||
{...props}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default PhoneNumberInput;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Box, Container, useComputedColorScheme } from "@mantine/core";
|
||||
import { PropsWithChildren, useEffect } from "react";
|
||||
import { Drawer as VaulDrawer } from 'vaul';
|
||||
import { useMantineColorScheme } from '@mantine/core';
|
||||
import styles from './styles.module.css';
|
||||
import { Drawer as VaulDrawer } from "vaul";
|
||||
import { useMantineColorScheme } from "@mantine/core";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface DrawerProps extends PropsWithChildren {
|
||||
title?: string;
|
||||
@@ -10,44 +10,51 @@ interface DrawerProps extends PropsWithChildren {
|
||||
onChange: (next: boolean) => void;
|
||||
}
|
||||
|
||||
const Drawer: React.FC<DrawerProps> = ({ title, children, opened, onChange }) => {
|
||||
const colorScheme = useComputedColorScheme('light');
|
||||
const Drawer: React.FC<DrawerProps> = ({
|
||||
title,
|
||||
children,
|
||||
opened,
|
||||
onChange,
|
||||
}) => {
|
||||
const colorScheme = useComputedColorScheme("light");
|
||||
|
||||
useEffect(() => {
|
||||
const appElement = document.querySelector('.app') as HTMLElement;
|
||||
|
||||
const appElement = document.querySelector(".app") as HTMLElement;
|
||||
|
||||
if (!appElement) return;
|
||||
|
||||
let themeColorMeta = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement;
|
||||
let themeColorMeta = document.querySelector(
|
||||
'meta[name="theme-color"]'
|
||||
) as HTMLMetaElement;
|
||||
if (!themeColorMeta) {
|
||||
themeColorMeta = document.createElement('meta');
|
||||
themeColorMeta.name = 'theme-color';
|
||||
themeColorMeta = document.createElement("meta");
|
||||
themeColorMeta.name = "theme-color";
|
||||
document.head.appendChild(themeColorMeta);
|
||||
}
|
||||
|
||||
const colors = {
|
||||
light: {
|
||||
normal: 'rgb(255,255,255)',
|
||||
overlay: 'rgb(153,153,153)'
|
||||
normal: "rgb(255,255,255)",
|
||||
overlay: "rgb(153,153,153)",
|
||||
},
|
||||
dark: {
|
||||
normal: 'rgb(36,36,36)',
|
||||
overlay: 'rgb(22,22,22)'
|
||||
}
|
||||
normal: "rgb(36,36,36)",
|
||||
overlay: "rgb(22,22,22)",
|
||||
},
|
||||
};
|
||||
|
||||
const currentColors = colors[colorScheme] || colors.light;
|
||||
|
||||
|
||||
if (opened) {
|
||||
appElement.classList.add('drawer-scaling');
|
||||
appElement.classList.add("drawer-scaling");
|
||||
themeColorMeta.content = currentColors.overlay;
|
||||
} else {
|
||||
appElement.classList.remove('drawer-scaling');
|
||||
appElement.classList.remove("drawer-scaling");
|
||||
themeColorMeta.content = currentColors.normal;
|
||||
}
|
||||
|
||||
return () => {
|
||||
appElement.classList.remove('drawer-scaling');
|
||||
appElement.classList.remove("drawer-scaling");
|
||||
themeColorMeta.content = currentColors.normal;
|
||||
};
|
||||
}, [opened, colorScheme]);
|
||||
@@ -57,9 +64,17 @@ const Drawer: React.FC<DrawerProps> = ({ title, children, opened, onChange }) =>
|
||||
<VaulDrawer.Portal>
|
||||
<VaulDrawer.Overlay className={styles.drawerOverlay} />
|
||||
<VaulDrawer.Content className={styles.drawerContent}>
|
||||
<Container flex={1} p='md'>
|
||||
<Box mb='sm' bg='var(--mantine-color-gray-4)' w='3rem' h='0.375rem' ml='auto' mr='auto' style={{ borderRadius: '9999px' }} />
|
||||
<Container mah='fit-content' mx='auto' maw='28rem' px={0}>
|
||||
<Container flex={1} p="md">
|
||||
<Box
|
||||
mb="sm"
|
||||
bg="var(--mantine-color-gray-4)"
|
||||
w="3rem"
|
||||
h="0.375rem"
|
||||
ml="auto"
|
||||
mr="auto"
|
||||
style={{ borderRadius: "9999px" }}
|
||||
/>
|
||||
<Container mah="fit-content" mx="auto" maw="28rem" px={0}>
|
||||
<VaulDrawer.Title>{title}</VaulDrawer.Title>
|
||||
{children}
|
||||
</Container>
|
||||
@@ -67,7 +82,7 @@ const Drawer: React.FC<DrawerProps> = ({ title, children, opened, onChange }) =>
|
||||
</VaulDrawer.Content>
|
||||
</VaulDrawer.Portal>
|
||||
</VaulDrawer.Root>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Drawer;
|
||||
|
||||
@@ -8,9 +8,13 @@ interface ModalProps extends PropsWithChildren {
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({ title, children, opened, onClose }) => (
|
||||
<MantineModal opened={opened} onClose={onClose} title={<Title order={3}>{title}</Title>}>
|
||||
<MantineModal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Title order={3}>{title}</Title>}
|
||||
>
|
||||
{children}
|
||||
</MantineModal>
|
||||
)
|
||||
);
|
||||
|
||||
export default Modal;
|
||||
|
||||
@@ -17,14 +17,22 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
||||
const SheetComponent = isMobile ? Drawer : Modal;
|
||||
|
||||
return (
|
||||
<SheetComponent title={title} opened={opened} onChange={onChange} onClose={handleClose}>
|
||||
<ScrollArea style={{ flex: 1 }} scrollbarSize={8} scrollbars='y' type='scroll'>
|
||||
<Box mah='70vh'>
|
||||
{children}
|
||||
</Box>
|
||||
<SheetComponent
|
||||
title={title}
|
||||
opened={opened}
|
||||
onChange={onChange}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<ScrollArea
|
||||
style={{ flex: 1 }}
|
||||
scrollbarSize={8}
|
||||
scrollbars="y"
|
||||
type="scroll"
|
||||
>
|
||||
<Box mah="70vh">{children}</Box>
|
||||
</ScrollArea>
|
||||
</SheetComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sheet;
|
||||
export default Sheet;
|
||||
|
||||
@@ -32,12 +32,12 @@ const SlidePanelField = ({
|
||||
const context = useContext(SlidePanelContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('SlidePanelField must be used within a SlidePanel');
|
||||
throw new Error("SlidePanelField must be used within a SlidePanel");
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (!onChange) return;
|
||||
|
||||
|
||||
context.openPanel({
|
||||
title,
|
||||
Component,
|
||||
@@ -64,26 +64,51 @@ const SlidePanelField = ({
|
||||
<Box>
|
||||
<UnstyledButton
|
||||
onClick={handleClick}
|
||||
p='sm'
|
||||
p="sm"
|
||||
style={{
|
||||
width: '100%',
|
||||
border: error ? '1px solid var(--mantine-color-error)' : '1px solid var(--mantine-color-dimmed)',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
textAlign: 'left',
|
||||
width: "100%",
|
||||
border: error
|
||||
? "1px solid var(--mantine-color-error)"
|
||||
: "1px solid var(--mantine-color-dimmed)",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
backgroundColor: "var(--mantine-color-body)",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Stack>
|
||||
<Text size="sm" fw={500}>{label}{withAsterisk && <Text span size="sm" c='var(--mantine-color-error)' fw={500} ml={4}>*</Text>}</Text>
|
||||
<Text size="sm" c='dimmed'>{displayValue()}</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{label}
|
||||
{withAsterisk && (
|
||||
<Text
|
||||
span
|
||||
size="sm"
|
||||
c="var(--mantine-color-error)"
|
||||
fw={500}
|
||||
ml={4}
|
||||
>
|
||||
*
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{displayValue()}
|
||||
</Text>
|
||||
</Stack>
|
||||
<CaretRightIcon size={24} weight='thin' style={{ marginRight: '12px' }} />
|
||||
<CaretRightIcon
|
||||
size={24}
|
||||
weight="thin"
|
||||
style={{ marginRight: "12px" }}
|
||||
/>
|
||||
</Flex>
|
||||
</UnstyledButton>
|
||||
{error && <Text size="xs" c='var(--mantine-color-error)' fw={500} ml={4} mt={4}>{error}</Text>}
|
||||
{error && (
|
||||
<Text size="xs" c="var(--mantine-color-error)" fw={500} ml={4} mt={4}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export { SlidePanelField };
|
||||
export { SlidePanelField };
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Box, Text, Group, ActionIcon, ScrollArea, Divider } from "@mantine/core";
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Group,
|
||||
ActionIcon,
|
||||
ScrollArea,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import { useState, ReactNode} from "react";
|
||||
import { useState, ReactNode } from "react";
|
||||
import { SlidePanelContext, type PanelConfig } from "./slide-panel-context";
|
||||
import Button from "@/components/button";
|
||||
|
||||
@@ -15,20 +22,15 @@ interface SlidePanelProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SlidePanel is a form component meant to be used inside a drawer/modal
|
||||
* It is used to create a form with multiple views/panels that slides in from the side
|
||||
* Use with SlidePanelField for an extra panel
|
||||
*/
|
||||
const SlidePanel = ({
|
||||
children,
|
||||
const SlidePanel = ({
|
||||
children,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitText = "Submit",
|
||||
cancelText = "Cancel",
|
||||
maxHeight = "70vh",
|
||||
formProps = {},
|
||||
loading = false
|
||||
loading = false,
|
||||
}: SlidePanelProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [panelConfig, setPanelConfig] = useState<PanelConfig | null>(null);
|
||||
@@ -58,54 +60,62 @@ const SlidePanel = ({
|
||||
|
||||
return (
|
||||
<SlidePanelContext.Provider value={{ openPanel, closePanel }}>
|
||||
<Box
|
||||
style={{
|
||||
position: 'relative',
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
height: maxHeight,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
transform: isOpen ? 'translateX(-100%)' : 'translateX(0)',
|
||||
transition: 'transform 0.3s ease-in-out',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
transform: isOpen ? "translateX(-100%)" : "translateX(0)",
|
||||
transition: "transform 0.3s ease-in-out",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<form
|
||||
{...formProps}
|
||||
onSubmit={handleFormSubmit}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
...formProps.style,
|
||||
}}
|
||||
>
|
||||
<ScrollArea style={{ flex: 1 }} scrollbarSize={8} scrollbars='y' type='always'>
|
||||
<Box p="md">
|
||||
{children}
|
||||
</Box>
|
||||
<ScrollArea
|
||||
style={{ flex: 1 }}
|
||||
scrollbarSize={8}
|
||||
scrollbars="y"
|
||||
type="always"
|
||||
>
|
||||
<Box p="md">{children}</Box>
|
||||
</ScrollArea>
|
||||
|
||||
<Box p="sm">
|
||||
<Group gap="md">
|
||||
<Button type="submit" fullWidth loading={loading} disabled={loading}>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{submitText}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
fullWidth
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
fullWidth
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={loading}
|
||||
@@ -120,32 +130,41 @@ const SlidePanel = ({
|
||||
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
transform: isOpen ? 'translateX(0)' : 'translateX(100%)',
|
||||
transition: 'transform 0.3s ease-in-out',
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
transform: isOpen ? "translateX(0)" : "translateX(100%)",
|
||||
transition: "transform 0.3s ease-in-out",
|
||||
backgroundColor: "var(--mantine-color-body)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{panelConfig && (
|
||||
<>
|
||||
<Group justify="space-between" p="md" align="center" w='100%'>
|
||||
<Group justify="space-between" p="md" align="center" w="100%">
|
||||
<ActionIcon variant="transparent" onClick={closePanel}>
|
||||
<ArrowLeftIcon size={24} />
|
||||
</ActionIcon>
|
||||
<Text fw={500}>{panelConfig.title}</Text>
|
||||
<ActionIcon variant="transparent" color="green" onClick={handleConfirm}>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="green"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<CheckIcon size={24} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<Divider h='1px' w='100%' bg='var(--mantine-color-dimmed)' my='xs'/>
|
||||
<Divider
|
||||
h="1px"
|
||||
w="100%"
|
||||
bg="var(--mantine-color-dimmed)"
|
||||
my="xs"
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<panelConfig.Component
|
||||
@@ -162,4 +181,4 @@ const SlidePanel = ({
|
||||
);
|
||||
};
|
||||
|
||||
export { SlidePanel };
|
||||
export { SlidePanel };
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { FloatingIndicator, UnstyledButton, Box, Text } from "@mantine/core";
|
||||
import { Carousel } from "@mantine/carousel";
|
||||
import { useState, useEffect, ReactNode, useRef, useCallback, useMemo } from "react";
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
ReactNode,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
|
||||
interface TabItem {
|
||||
@@ -14,15 +21,21 @@ interface SwipeableTabsProps {
|
||||
onTabChange?: (index: number, tab: TabItem) => void;
|
||||
}
|
||||
|
||||
function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps) {
|
||||
function SwipeableTabs({
|
||||
tabs,
|
||||
defaultTab = 0,
|
||||
onTabChange,
|
||||
}: SwipeableTabsProps) {
|
||||
const router = useRouter();
|
||||
const search = router.state.location.search as any;
|
||||
const [embla, setEmbla] = useState<any>(null);
|
||||
|
||||
|
||||
const getActiveTabFromUrl = useCallback(() => {
|
||||
const urlTab = search?.tab;
|
||||
if (typeof urlTab === 'string') {
|
||||
const tabIndex = tabs.findIndex(tab => tab.label.toLowerCase() === urlTab.toLowerCase());
|
||||
if (typeof urlTab === "string") {
|
||||
const tabIndex = tabs.findIndex(
|
||||
(tab) => tab.label.toLowerCase() === urlTab.toLowerCase()
|
||||
);
|
||||
return tabIndex !== -1 ? tabIndex : defaultTab;
|
||||
}
|
||||
return defaultTab;
|
||||
@@ -32,22 +45,25 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
||||
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
||||
const controlsRefs = useRef<Record<number, HTMLSpanElement | null>>({});
|
||||
const slideRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
const [carouselHeight, setCarouselHeight] = useState<number | 'auto'>('auto');
|
||||
const [carouselHeight, setCarouselHeight] = useState<number | "auto">("auto");
|
||||
|
||||
const changeTab = useCallback((index: number) => {
|
||||
if (index === activeTab || index < 0 || index >= tabs.length) return;
|
||||
|
||||
setActiveTab(index);
|
||||
embla?.scrollTo(index);
|
||||
onTabChange?.(index, tabs[index]);
|
||||
|
||||
const tabLabel = tabs[index].label.toLowerCase();
|
||||
if (typeof window !== 'undefined') {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('tab', tabLabel);
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
}
|
||||
}, [activeTab, tabs, embla, onTabChange]);
|
||||
const changeTab = useCallback(
|
||||
(index: number) => {
|
||||
if (index === activeTab || index < 0 || index >= tabs.length) return;
|
||||
|
||||
setActiveTab(index);
|
||||
embla?.scrollTo(index);
|
||||
onTabChange?.(index, tabs[index]);
|
||||
|
||||
const tabLabel = tabs[index].label.toLowerCase();
|
||||
if (typeof window !== "undefined") {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabLabel);
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}
|
||||
},
|
||||
[activeTab, tabs, embla, onTabChange]
|
||||
);
|
||||
|
||||
const handleEmblaSelect = useCallback(() => {
|
||||
if (!embla) return;
|
||||
@@ -78,13 +94,19 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const setControlRef = useCallback((index: number) => (node: HTMLSpanElement | null) => {
|
||||
controlsRefs.current[index] = node;
|
||||
}, []);
|
||||
const setControlRef = useCallback(
|
||||
(index: number) => (node: HTMLSpanElement | null) => {
|
||||
controlsRefs.current[index] = node;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const setSlideRef = useCallback((index: number) => (node: HTMLDivElement | null) => {
|
||||
slideRefs.current[index] = node;
|
||||
}, []);
|
||||
const setSlideRef = useCallback(
|
||||
(index: number) => (node: HTMLDivElement | null) => {
|
||||
slideRefs.current[index] = node;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -93,10 +115,10 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
||||
pos="sticky"
|
||||
top={0}
|
||||
style={{
|
||||
display: 'flex',
|
||||
marginBottom: 'var(--mantine-spacing-md)',
|
||||
display: "flex",
|
||||
marginBottom: "var(--mantine-spacing-md)",
|
||||
zIndex: 100,
|
||||
backgroundColor: 'var(--mantine-color-body)'
|
||||
backgroundColor: "var(--mantine-color-body)",
|
||||
}}
|
||||
>
|
||||
<FloatingIndicator
|
||||
@@ -104,9 +126,9 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
||||
parent={rootRef}
|
||||
styles={{
|
||||
root: {
|
||||
borderBottom: '2px solid var(--mantine-primary-color-filled)',
|
||||
paddingInline: '0.5rem'
|
||||
}
|
||||
borderBottom: "2px solid var(--mantine-primary-color-filled)",
|
||||
paddingInline: "0.5rem",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{tabs.map((tab, index) => (
|
||||
@@ -115,25 +137,26 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
||||
onClick={() => changeTab(index)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 'var(--mantine-spacing-sm) var(--mantine-spacing-md)',
|
||||
textAlign: 'center',
|
||||
color: activeTab === index
|
||||
? 'var(--mantine-color-blue-6)'
|
||||
: 'var(--mantine-color-text)',
|
||||
padding: "var(--mantine-spacing-sm) var(--mantine-spacing-md)",
|
||||
textAlign: "center",
|
||||
color:
|
||||
activeTab === index
|
||||
? "var(--mantine-primary-color-filled)"
|
||||
: "var(--mantine-color-text)",
|
||||
fontWeight: activeTab === index ? 600 : 400,
|
||||
transition: 'color 200ms ease, font-weight 200ms ease',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
transition: "color 200ms ease, font-weight 200ms ease",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
borderRadius: 0,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
<Text
|
||||
size="sm"
|
||||
component="span"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
paddingInline: '1rem',
|
||||
paddingBottom: '0.25rem'
|
||||
style={{
|
||||
display: "inline-block",
|
||||
paddingInline: "1rem",
|
||||
paddingBottom: "0.25rem",
|
||||
}}
|
||||
ref={setControlRef(index)}
|
||||
>
|
||||
@@ -150,17 +173,14 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
||||
slideSize="100%"
|
||||
initialSlide={activeTab}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
height: carouselHeight === 'auto' ? 'auto' : `${carouselHeight}px`,
|
||||
transition: 'height 300ms ease'
|
||||
overflow: "hidden",
|
||||
height: carouselHeight === "auto" ? "auto" : `${carouselHeight}px`,
|
||||
transition: "height 300ms ease",
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<Carousel.Slide key={`${tab.label}-content-${index}`}>
|
||||
<Box
|
||||
ref={setSlideRef(index)}
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<Box ref={setSlideRef(index)} style={{ height: "auto" }}>
|
||||
{tab.content}
|
||||
</Box>
|
||||
</Carousel.Slide>
|
||||
@@ -170,4 +190,4 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
||||
);
|
||||
}
|
||||
|
||||
export default SwipeableTabs;
|
||||
export default SwipeableTabs;
|
||||
|
||||
Reference in New Issue
Block a user