feat(ui): form system overhaul

This commit is contained in:
melvinchia3636
2026-06-01 19:44:16 +08:00
parent d8f7c88032
commit 5502c167b7
71 changed files with 1591 additions and 2909 deletions

Submodule apps/lifeforge--achievements added at 8e8aaaf918

Submodule apps/lifeforge--wallet added at a4116d2fc6

View File

@@ -247,6 +247,7 @@
"version": "0.25.29-1",
"dependencies": {
"@headlessui/react": "^2.2.9",
"@hookform/resolvers": "^5.4.0",
"@iconify/collections": "^1.0.690",
"@iconify/react": "^6.0.2",
"@iconify/tools": "^4.1.4",
@@ -284,6 +285,7 @@
"react-datepicker": "^8.7.0",
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.77.0",
"react-i18next": "^15.7.4",
"react-medium-image-zoom": "^5.4.0",
"react-otp-input": "^3.1.1",
@@ -635,6 +637,8 @@
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@hookform/resolvers": ["@hookform/resolvers@5.4.0", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
@@ -967,6 +971,8 @@
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@storybook/addon-docs": ["@storybook/addon-docs@10.1.0", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.1.0", "@storybook/icons": "^2.0.0", "@storybook/react-dom-shim": "10.1.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.1.0" } }, "sha512-CVW2pc9iAfz1A6/L9S0z8XqKUON+u92xaOTC1x6d3WS8cyOT94nD7tfohT8aWydwvvmtwRHZJzl0aWnKUNgSJw=="],
"@storybook/addon-onboarding": ["@storybook/addon-onboarding@10.1.0", "", { "peerDependencies": { "storybook": "^10.1.0" } }, "sha512-1ejlsj3gb2f2mVgTbLJyQbnF7e3iT5xUwIyFnynHIbdw8HkcQtF+Kt56HOkP27llGv2zAvpmXZ4tu1y461hzzA=="],
@@ -3063,6 +3069,8 @@
"react-grid-layout": ["react-grid-layout@1.5.3", "", { "dependencies": { "clsx": "^2.1.1", "fast-equals": "^4.0.3", "prop-types": "^15.8.1", "react-draggable": "^4.4.6", "react-resizable": "^3.0.5", "resize-observer-polyfill": "^1.5.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-KaG6IbjD6fYhagUtIvOzhftXG+ViKZjCjADe86X1KHl7C/dsBN2z0mi14nbvZKTkp0RKiil9RPcJBgq3LnoA8g=="],
"react-hook-form": ["react-hook-form@7.77.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg=="],
"react-i18next": ["react-i18next@16.6.6", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.10.9", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],

View File

@@ -29,6 +29,7 @@
},
"dependencies": {
"@headlessui/react": "^2.2.9",
"@hookform/resolvers": "^5.4.0",
"@iconify/collections": "^1.0.690",
"@iconify/react": "^6.0.2",
"@iconify/tools": "^4.1.4",
@@ -66,6 +67,7 @@
"react-datepicker": "^8.7.0",
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.77.0",
"react-i18next": "^15.7.4",
"react-medium-image-zoom": "^5.4.0",
"react-otp-input": "^3.1.1",

View File

@@ -0,0 +1,172 @@
import { zodResolver } from '@hookform/resolvers/zod'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { useForm } from 'react-hook-form'
import z from 'zod'
import { Button } from '@/components/inputs'
import { useModalStore } from '@/providers'
import { FormModal } from '.'
import { createDefaultValues } from '../../hooks/createDefaultValues'
import { ColorField, ListboxField, NumberField, TextField } from '../fields'
const meta = {
component: FormModal,
parameters: {
deepControls: { enabled: true }
}
} satisfies Meta<typeof FormModal>
export default meta
type Story = StoryObj<typeof meta>
const cuteFormSchema = z
.object({
age: z
.number()
.int('Invalid age. Age must be an integer.')
.nonnegative('Invalid age. Age must be positive.'),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid color hex'),
confirmPassword: z.string(),
icon: z.enum(['tabler:star', 'tabler:check', 'tabler:x', 'tabler:heart']),
name: z.string().min(1, 'Name is required'),
password: z.string().min(8, 'Password must be at least 8 characters')
})
.superRefine(function ({ confirmPassword, password }, ctx) {
if (confirmPassword !== password) {
ctx.addIssue({
code: 'custom',
message: 'Password does not match',
path: ['confirmPassword']
})
}
})
function getIconOptions(name: string) {
return [
...(name === 'melvin'
? [
{
color: '#FF0000',
icon: 'tabler:heart',
text: 'Heart',
value: 'tabler:heart'
}
]
: []),
{ icon: 'tabler:star', text: 'Star', value: 'tabler:star' },
{ icon: 'tabler:check', text: 'Check', value: 'tabler:check' },
{ icon: 'tabler:x', text: 'X', value: 'tabler:x' }
]
}
function MyFormModal({ onClose }: { onClose: () => void }) {
const form = useForm({
defaultValues: {
...createDefaultValues(cuteFormSchema),
color: '#FFFFFF'
},
resolver: zodResolver(cuteFormSchema)
})
const iconOptions = getIconOptions(form.watch('name'))
return (
<FormModal
form={form}
submissionConfig={{
handler: async function (values) {
alert(JSON.stringify(values))
},
template: 'update'
}}
uiConfig={{
icon: 'tabler:forms',
onClose: onClose,
title: 'Form Modal'
}}
>
<TextField
required
control={form.control}
icon="tabler:user"
label="Name"
name="name"
placeholder="John Doe"
/>
<NumberField
required
control={form.control}
icon="tabler:number-123"
label="Age"
name="age"
/>
<ColorField control={form.control} label="Color" name="color" />
<ListboxField
required
control={form.control}
icon="tabler:icons"
label="Icon"
name="icon"
options={iconOptions}
/>
<TextField
isPassword
required
actionButtonProps={{
icon: 'tabler:dice',
onClick: () => {
const randomPassword = Math.random().toString(36).slice(-8)
form.setValue('password', randomPassword, { shouldValidate: true })
form.setValue('confirmPassword', randomPassword, {
shouldValidate: true
})
},
variant: 'plain'
}}
control={form.control}
icon="tabler:lock"
label="Password"
name="password"
placeholder="••••••••"
/>
<TextField
isPassword
required
control={form.control}
icon="tabler:lock"
label="Confirm Password"
name="confirmPassword"
placeholder="••••••••"
/>
</FormModal>
)
}
export const Default: Story = {
args: {
ui: {
icon: 'tabler:forms',
loading: false,
namespace: '',
onClose: function () {},
submitButton: 'update',
title: 'Form Modal'
}
} as never,
render: function () {
const { open } = useModalStore()
function handleButtonClick() {
open(MyFormModal, {})
}
return (
<Button icon="tabler:plus" onClick={handleButtonClick}>
Open Form Modal
</Button>
)
}
}

View File

@@ -0,0 +1,111 @@
import {
type FieldValues,
type SubmitHandler,
type UseFormReturn
} from 'react-hook-form'
import { toast } from 'react-toastify'
import { usePromiseLoading } from '@lifeforge/shared'
import { LoadingScreen } from '@/components/feedback'
import { Button } from '@/components/inputs'
import { ModalHeader } from '@/components/overlays'
import { Flex, Stack } from '@/components/primitives'
const SUBMISSION_CONFIG_TEMPLATE = {
create: {
label: 'Create',
icon: 'tabler:plus'
},
update: {
label: 'Update',
icon: 'tabler:pencil'
}
} as const
type SubmissionConfig<T extends FieldValues> =
| {
template: keyof typeof SUBMISSION_CONFIG_TEMPLATE
disabled?: boolean
handler: SubmitHandler<T>
}
| {
label: string
icon: string
disabled?: boolean
handler: SubmitHandler<T>
}
export function FormModal<T extends FieldValues>({
form,
uiConfig: { title, icon, namespace, loading = false, onClose, headerActions },
submissionConfig,
children
}: {
form: UseFormReturn<T>
uiConfig: {
title: string | React.ReactNode
icon: string
onClose: () => void
namespace?: string
loading?: boolean
headerActions?: React.ReactNode
}
submissionConfig: SubmissionConfig<T>
children: React.ReactNode
}) {
const handleSubmit = form.handleSubmit(async values => {
try {
await submissionConfig.handler(values)
onClose?.()
} catch (error) {
console.error(error)
toast.error('Failed to submit form. Please check the console for details')
}
})
const [submitButtonLoading, onSubmitButtonClick] =
usePromiseLoading(handleSubmit)
const finalSubmissionConfig =
'template' in submissionConfig
? SUBMISSION_CONFIG_TEMPLATE[submissionConfig.template]
: submissionConfig
return (
<Stack gap="sm" minWidth="50vw">
<ModalHeader
headerActions={headerActions}
icon={icon}
namespace={namespace ? namespace : undefined}
title={title}
onClose={onClose}
/>
{!loading ? (
<>
{children}
<Button
icon={finalSubmissionConfig.icon}
loading={submitButtonLoading}
mt="lg"
width="100%"
onClick={onSubmitButtonClick}
>
{finalSubmissionConfig.label}
</Button>
</>
) : (
<Flex
align="center"
direction="column"
flex="1"
justify="center"
minHeight="24em"
>
<LoadingScreen />
</Flex>
)}
</Stack>
)
}

View File

@@ -0,0 +1,68 @@
import _ from 'lodash'
import {
type Control,
type FieldPathByValue,
type FieldValues,
useController
} from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Switch } from '@/components/inputs'
import { Flex, Icon, Text } from '@/components/primitives'
type CheckboxFieldProps<TFieldValues extends FieldValues> = {
control: Control<TFieldValues>
name: FieldPathByValue<TFieldValues, boolean>
label: string
icon: string
disabled?: boolean
namespace?: string
}
export function CheckboxField<TFieldValues extends FieldValues>({
control,
name,
label,
icon,
disabled = false,
namespace
}: CheckboxFieldProps<TFieldValues>) {
const { field, fieldState } = useController({
control,
name
})
const { t } = useTranslation(namespace)
const labelText = t([
['inputs', _.camelCase(label), 'label'].filter(Boolean).join('.'),
['inputs', _.camelCase(label)].filter(Boolean).join('.')
])
function handleSwitchChange() {
field.onChange(!field.value)
}
return (
<Flex direction="column" gap="sm" width="100%">
<Flex align="center" justify="between" py="sm">
<Flex align="center" gap="sm">
<Icon icon={icon} size="1.5em" />
<Text size="lg">
{labelText}
</Text>
</Flex>
<Switch
disabled={disabled}
value={!!field.value}
onChange={handleSwitchChange}
/>
</Flex>
{fieldState.error?.message && (
<Text color="dangerous" size="sm">
{fieldState.error.message}
</Text>
)}
</Flex>
)
}

View File

@@ -0,0 +1,33 @@
import {
type Control,
type FieldPathByValue,
type FieldValues,
useController
} from 'react-hook-form'
import { ColorInput, type ColorInputProps } from '@/components/inputs'
type ColorFieldProps<TFieldValues extends FieldValues> = {
control: Control<TFieldValues>
name: FieldPathByValue<TFieldValues, string>
} & Omit<ColorInputProps, 'value' | 'onChange'>
export function ColorField<TFieldValues extends FieldValues>({
control,
name,
...rest
}: ColorFieldProps<TFieldValues>) {
const { field, fieldState } = useController({
control,
name
})
return (
<ColorInput
errorMsg={fieldState.error?.message}
value={field.value ?? ''}
onChange={field.onChange}
{...rest}
/>
)
}

View File

@@ -0,0 +1,33 @@
import {
type Control,
type FieldPathByValue,
type FieldValues,
useController
} from 'react-hook-form'
import { CurrencyInput, type CurrencyInputProps } from '@/components/inputs'
type CurrencyFieldProps<TFieldValues extends FieldValues> = {
control: Control<TFieldValues>
name: FieldPathByValue<TFieldValues, number>
} & Omit<CurrencyInputProps, 'value' | 'onChange'>
export function CurrencyField<TFieldValues extends FieldValues>({
control,
name,
...rest
}: CurrencyFieldProps<TFieldValues>) {
const { field, fieldState } = useController({
control,
name
})
return (
<CurrencyInput
errorMsg={fieldState.error?.message}
value={field.value ?? 0}
onChange={field.onChange}
{...rest}
/>
)
}

View File

@@ -0,0 +1,42 @@
import dayjs from 'dayjs'
import {
type Control,
type FieldPathByValue,
type FieldValues,
useController
} from 'react-hook-form'
import { DateInput, type DateInputProps } from '@/components/inputs'
type DateFieldProps<TFieldValues extends FieldValues> = {
control: Control<TFieldValues>
name: FieldPathByValue<TFieldValues, Date | null | string>
} & Omit<DateInputProps, 'value' | 'onChange'>
export function DateField<TFieldValues extends FieldValues>({
control,
name,
...rest
}: DateFieldProps<TFieldValues>) {
const { field, fieldState } = useController({
control,
name
})
const val = field.value as unknown
const dateValue = val
? val instanceof Date
? val
: dayjs(val as string).toDate()
: null
return (
<DateInput
errorMsg={fieldState.error?.message}
value={dateValue}
onChange={field.onChange}
{...rest}
/>
)
}

View File

@@ -0,0 +1,41 @@
import {
type Control,
type FieldPathByValue,
type FieldValues,
useController
} from 'react-hook-form'
import { FileInput, type FilePickerSourceConfig, type FileValue } from '@/components/inputs'
type FileFieldProps<TFieldValues extends FieldValues> = {
control: Control<TFieldValues>
name: FieldPathByValue<TFieldValues, FileValue>
icon: string
label: string
reminderText?: string
onImageRemoved?: () => void
required?: boolean
namespace?: string
disabled?: boolean
sources?: FilePickerSourceConfig
mimeTypes?: Record<string, string[]>
}
export function FileField<TFieldValues extends FieldValues>({
control,
name,
...rest
}: FileFieldProps<TFieldValues>) {
const { field } = useController({
control,
name
})
return (
<FileInput
value={field.value ?? { type: 'empty' }}
onChange={field.onChange}
{...rest}
/>
)
}

View File

@@ -0,0 +1,33 @@
import {
type Control,
type FieldPathByValue,
type FieldValues,
useController
} from 'react-hook-form'
import { IconInput, type IconInputProps } from '@/components/inputs'
type IconFieldProps<TFieldValues extends FieldValues> = {
control: Control<TFieldValues>
name: FieldPathByValue<TFieldValues, string>
} & Omit<IconInputProps, 'value' | 'onChange'>
export function IconField<TFieldValues extends FieldValues>({
control,
name,
...rest
}: IconFieldProps<TFieldValues>) {
const { field, fieldState } = useController({
control,
name
})
return (
<IconInput
errorMsg={fieldState.error?.message}
value={field.value ?? ''}
onChange={field.onChange}
{...rest}
/>
)
}

View File

@@ -0,0 +1,199 @@
import { Fragment } from 'react'
import {
type Control,
type FieldPathByValue,
type FieldValues,
useController
} from 'react-hook-form'
import { ListboxInput, ListboxOption } from '@/components/inputs'
import { Box, Flex, Icon, Text } from '@/components/primitives'
type ListboxOptionType<TOption> = {
value: TOption
text: string
icon?: string
color?: string
}
type ListboxFieldProps<TFieldValues extends FieldValues, TOption> = {
control: Control<TFieldValues>
name: FieldPathByValue<TFieldValues, TOption | TOption[] | null | undefined>
icon: string
label: string
multiple?: boolean
disabled?: boolean
namespace?: string
required?: boolean
options: ListboxOptionType<TOption>[]
actionButtonOption?: {
text: string
onClick: () => void
icon: string
}
}
function OptionColorAndIcon({
color,
icon
}: {
color?: string
icon?: string
}) {
if (color && icon) {
return <Icon icon={icon} style={{ color }} />
}
if (!color) {
return <Icon icon={icon ?? ''} />
}
return (
<Box
display="inline-block"
flexShrink="0"
height="0.75em"
r="full"
style={{
backgroundColor: color
}}
width="0.75em"
/>
)
}
function ListboxButtonContent<TOption>({
multiple,
value,
options
}: {
multiple?: boolean
value: unknown
options: ListboxOptionType<TOption>[]
}) {
if (multiple === true && Array.isArray(value)) {
return (
<Flex align="center" gap="md" wrap="wrap">
{value.length > 0 &&
value.map(function (item: TOption, i: number) {
const target = options.find(function (l) {
return l.value === item
})
return (
<Fragment key={String(item)}>
<Flex align="center" gap="xs">
<Icon
icon={target?.icon ?? ''}
style={{
color: target?.color
}}
/>
<Text truncate>{target?.text ?? 'None'}</Text>
</Flex>
{i !== value.length - 1 && (
<Icon icon="tabler:circle-filled" size="0.25em" />
)}
</Fragment>
)
})}
</Flex>
)
}
const targetOption = options.find(function (l) {
return l.value === value
})
if (!targetOption) {
return <Text>None</Text>
}
return (
<Flex align="center" gap="sm">
<OptionColorAndIcon color={targetOption.color} icon={targetOption.icon} />
<Text truncate>{targetOption.text}</Text>
</Flex>
)
}
export function ListboxField<TFieldValues extends FieldValues, TOption>({
control,
name,
icon,
label,
multiple = false,
disabled = false,
namespace,
required = false,
options,
actionButtonOption
}: ListboxFieldProps<TFieldValues, TOption>) {
const { field, fieldState } = useController({
control,
name
})
function handleListboxChange(val: unknown) {
let cleanVal = val
if (Array.isArray(cleanVal) && cleanVal.includes(null)) {
cleanVal = cleanVal.filter(function (v) {
return v !== null
})
}
if (cleanVal === null) {
return
}
field.onChange(cleanVal)
}
function handleRenderContent() {
return (
<ListboxButtonContent
multiple={multiple}
options={options}
value={field.value}
/>
)
}
return (
<ListboxInput
disabled={disabled}
errorMsg={fieldState.error?.message}
icon={icon}
label={label}
multiple={multiple}
namespace={namespace}
renderContent={handleRenderContent}
required={required}
value={field.value}
onChange={handleListboxChange}
>
{options.map(function ({ text, color, icon: optIcon, value: v }) {
return (
<ListboxOption
key={String(v)}
color={color}
icon={optIcon}
label={text}
selected={JSON.stringify(v) === JSON.stringify(field.value)}
value={v}
/>
)
})}
{actionButtonOption && (
<ListboxOption
icon={actionButtonOption.icon}
label={actionButtonOption.text}
selected={false}
value={null as unknown as TOption}
onClick={actionButtonOption.onClick}
/>
)}
</ListboxInput>
)
}

View File

@@ -0,0 +1,44 @@
import {
type Control,
type FieldPathByValue,
type FieldValues,
useController
} from 'react-hook-form'
import { type Location, LocationInput } from '@/components/inputs'
type LocationFieldProps<TFieldValues extends FieldValues> = {
control: Control<TFieldValues>
name: FieldPathByValue<TFieldValues, Location | null>
icon?: string
label: string
required?: boolean
disabled?: boolean
autoFocus?: boolean
namespace?: string
}
export function LocationField<TFieldValues extends FieldValues>({
control,
name,
disabled = false,
required = false,
autoFocus = false,
...rest
}: LocationFieldProps<TFieldValues>) {
const { field } = useController({
control,
name
})
return (
<LocationInput
autoFocus={autoFocus}
disabled={disabled}
required={required}
value={field.value ?? null}
onChange={field.onChange}
{...rest}
/>
)
}

View File

@@ -0,0 +1,33 @@
import {
type Control,
type FieldPathByValue,
type FieldValues,
useController
} from 'react-hook-form'
import { NumberInput, type NumberInputProps } from '@/components/inputs'
type NumberFieldProps<TFieldValues extends FieldValues> = {
control: Control<TFieldValues>
name: FieldPathByValue<TFieldValues, number>
} & Omit<NumberInputProps, 'value' | 'onChange'>
export function NumberField<TFieldValues extends FieldValues>({
control,
name,
...rest
}: NumberFieldProps<TFieldValues>) {
const { field, fieldState } = useController({
control,
name
})
return (
<NumberInput
errorMsg={fieldState.error?.message}
value={field.value ?? 0}
onChange={field.onChange}
{...rest}
/>
)
}

View File

@@ -0,0 +1,33 @@
import {
type Control,
type FieldPathByValue,
type FieldValues,
useController
} from 'react-hook-form'
import { RRuleInput } from '@/components/inputs'
type RRuleFieldProps<TFieldValues extends FieldValues> = {
control: Control<TFieldValues>
name: FieldPathByValue<TFieldValues, string>
hasDuration?: boolean
}
export function RRuleField<TFieldValues extends FieldValues>({
control,
name,
hasDuration = false
}: RRuleFieldProps<TFieldValues>) {
const { field } = useController({
control,
name
})
return (
<RRuleInput
hasDuration={hasDuration}
value={field.value ?? ''}
onChange={field.onChange}
/>
)
}

View File

@@ -0,0 +1,32 @@
import {
type Control,
type FieldPathByValue,
type FieldValues,
useController
} from 'react-hook-form'
import { SliderInput, type SliderInputProps } from '@/components/inputs'
type SliderFieldProps<TFieldValues extends FieldValues> = {
control: Control<TFieldValues>
name: FieldPathByValue<TFieldValues, number>
} & Omit<SliderInputProps, 'value' | 'onChange'>
export function SliderField<TFieldValues extends FieldValues>({
control,
name,
...rest
}: SliderFieldProps<TFieldValues>) {
const { field } = useController({
control,
name
})
return (
<SliderInput
value={field.value ?? 0}
onChange={field.onChange}
{...rest}
/>
)
}

View File

@@ -0,0 +1,33 @@
import {
type Control,
type FieldPathByValue,
type FieldValues,
useController
} from 'react-hook-form'
import { TextAreaInput, type TextAreaInputProps } from '@/components/inputs'
type TextAreaFieldProps<TFieldValues extends FieldValues> = {
control: Control<TFieldValues>
name: FieldPathByValue<TFieldValues, string>
} & Omit<TextAreaInputProps, 'value' | 'onChange'>
export function TextAreaField<TFieldValues extends FieldValues>({
control,
name,
...rest
}: TextAreaFieldProps<TFieldValues>) {
const { field, fieldState } = useController({
control,
name
})
return (
<TextAreaInput
errorMsg={fieldState.error?.message}
value={field.value ?? ''}
onChange={field.onChange}
{...rest}
/>
)
}

View File

@@ -0,0 +1,62 @@
import {
type Control,
type FieldPathByValue,
type FieldValues,
useController
} from 'react-hook-form'
import { QRCodeScanner, TextInput, type TextInputProps } from '@/components/inputs'
import { useModalStore } from '@/providers'
type TextFieldProps<TFieldValues extends FieldValues> = {
control: Control<TFieldValues>
name: FieldPathByValue<TFieldValues, string>
qrScanner?: boolean
} & Omit<TextInputProps, 'value' | 'onChange'>
export function TextField<TFieldValues extends FieldValues>({
control,
name,
qrScanner = false,
actionButtonProps,
...rest
}: TextFieldProps<TFieldValues>) {
const { open } = useModalStore()
const { field, fieldState } = useController({
control,
name
})
function handleQRScanned(data: string) {
field.onChange(data)
}
function openQRScanner() {
open(QRCodeScanner, {
onScanned: handleQRScanned
})
}
const computedActionButtonProps = actionButtonProps
? actionButtonProps
: qrScanner
? {
icon: 'tabler:qrcode',
onClick: openQRScanner
}
: undefined
const textInputProps = {
...rest,
actionButtonProps: computedActionButtonProps,
errorMsg: fieldState.error?.message,
value: field.value ?? '',
onChange: field.onChange
} as TextInputProps
return (
<TextInput
{...textInputProps}
/>
)
}

View File

@@ -0,0 +1,25 @@
export * from './NumberField'
export * from './CheckboxField'
export * from './ColorField'
export * from './CurrencyField'
export * from './DateField'
export * from './FileField'
export * from './IconField'
export * from './ListboxField'
export * from './LocationField'
export * from './RRuleField'
export * from './SliderField'
export * from './TextAreaField'
export * from './TextField'

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import { createDefaultValues } from './createDefaultValues'
describe('createDefaultValues', () => {
it('should resolve string schema to empty string', () => {
const schema = z.string()
expect(createDefaultValues(schema)).toBe('')
})
it('should resolve number schema to 0', () => {
const schema = z.number()
expect(createDefaultValues(schema)).toBe(0)
})
it('should resolve boolean schema to false', () => {
const schema = z.boolean()
expect(createDefaultValues(schema)).toBe(false)
})
it('should resolve array schema to empty array', () => {
const schema = z.array(z.string())
expect(createDefaultValues(schema)).toEqual([])
})
it('should resolve object schema to an object with default values recursively', () => {
const schema = z.object({
name: z.string(),
age: z.number(),
active: z.boolean(),
tags: z.array(z.string()),
nested: z.object({
value: z.string()
})
})
expect(createDefaultValues(schema)).toEqual({
name: '',
age: 0,
active: false,
tags: [],
nested: {
value: ''
}
})
})
it('should resolve optional schema to undefined', () => {
const schema = z.string().optional()
expect(createDefaultValues(schema)).toBeUndefined()
})
it('should resolve nullable schema to null', () => {
const schema = z.string().nullable()
expect(createDefaultValues(schema)).toBeNull()
})
it('should resolve default schema to the specified default value', () => {
const schema1 = z.string().default('hello')
const schema2 = z.string().default(function () {
return 'dynamic-hello'
})
expect(createDefaultValues(schema1)).toBe('hello')
expect(createDefaultValues(schema2)).toBe('dynamic-hello')
})
it('should resolve readonly schema to its inner default value', () => {
const schema = z.string().readonly()
expect(createDefaultValues(schema)).toBe('')
})
it('should resolve lazy schema to its inner default value', () => {
const schema = z.lazy(function () {
return z.string()
})
expect(createDefaultValues(schema)).toBe('')
})
it('should resolve pipe/transform schema to its inner default value', () => {
const schema = z.string().transform(function (val) {
return val.length
})
expect(createDefaultValues(schema)).toBe('')
})
it('should resolve unsupported schemas to undefined', () => {
const schema1 = z.date()
const schema2 = z.enum(['active', 'inactive'])
expect(createDefaultValues(schema1)).toBeUndefined()
expect(createDefaultValues(schema2)).toBeUndefined()
})
})

View File

@@ -0,0 +1,70 @@
/**
* ⚠️ WARNING
*
* Do not add business-specific defaults here.
*
* This utility only derives defaults from Zod types.
*
* Keep this utility dumb. Very dumb. Dumber than you think it should be.
*/
import { z } from 'zod'
export function createDefaultValues<TSchema extends z.ZodTypeAny>(
schema: TSchema
): z.infer<TSchema> {
if (schema instanceof z.ZodOptional) {
return undefined as z.infer<TSchema>
}
if (schema instanceof z.ZodNullable) {
return null as z.infer<TSchema>
}
if (schema instanceof z.ZodDefault) {
return schema.def.defaultValue as z.infer<TSchema>
}
if (schema instanceof z.ZodPipe) {
return createDefaultValues(schema.in as z.ZodTypeAny) as z.infer<TSchema>
}
if (schema instanceof z.ZodReadonly || schema instanceof z.ZodLazy) {
return createDefaultValues(
schema.unwrap() as z.ZodTypeAny
) as z.infer<TSchema>
}
if (schema instanceof z.ZodString) {
return '' as z.infer<TSchema>
}
if (schema instanceof z.ZodNumber) {
return 0 as z.infer<TSchema>
}
if (schema instanceof z.ZodBoolean) {
return false as z.infer<TSchema>
}
if (schema instanceof z.ZodArray) {
return [] as unknown as z.infer<TSchema>
}
if (schema instanceof z.ZodEnum) {
return schema.options[0] as z.infer<TSchema>
}
if (schema instanceof z.ZodObject) {
const shape = schema.shape
const objDefaults: Record<string, unknown> = {}
for (const key in shape) {
objDefaults[key] = createDefaultValues(shape[key])
}
return objDefaults as z.infer<TSchema>
}
return undefined as z.infer<TSchema>
}

View File

@@ -0,0 +1,5 @@
export * from './components/FormModal'
export * from './components/fields'
export * from './hooks/createDefaultValues'

View File

@@ -0,0 +1,21 @@
export * from './primitives'
export * from './auth'
export * from './data-display'
export * from './inputs'
export * from './feedback'
export * from './inputs'
export * from './form'
export * from './layout'
export * from './navigation'
export * from './overlays'
export * from './utilities'

View File

@@ -14,7 +14,7 @@ import type { InputVariants } from '../shared/types'
import { autoFocusableRef } from '../shared/utils/autoFocusableRef'
import { ColorPickerModal } from './ColorPickerModal'
interface ColorInputProps {
export interface ColorInputProps {
/** The label text displayed above the color input field. Required for 'classic' style. */
label?: string
/** The current color value in hex format (e.g., "#FF0000"). */

View File

@@ -13,7 +13,7 @@ import { useInputLabel } from '../shared/hooks/useInputLabel'
import type { InputVariants } from '../shared/types'
import { autoFocusableRef } from '../shared/utils/autoFocusableRef'
type CurrencyInputProps = {
export type CurrencyInputProps = {
/** The currency symbol to display, or the currency code (e.g., "$", "€", "USD"). */
prefix?: string
/** The label text displayed above the currency input field. Required for 'classic' style. */

View File

@@ -22,7 +22,7 @@ import { CalendarHeader } from './components/CalendarHeader'
/**
* Props for the DateInput component.
*/
interface DateInputProps {
export interface DateInputProps {
variant?: 'classic' | 'plain'
/** The label text displayed above the date input field. Required for 'classic' style. */
label?: string

View File

@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import { useState } from 'react'
import { FileInput } from './index'
import type { FileValue } from './index'
const meta = {
component: FileInput
@@ -13,32 +14,24 @@ type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
acceptedMimeTypes: {
mimeTypes: {
application: ['pdf'],
image: ['jpeg', 'png']
},
file: null,
value: { type: 'empty' },
icon: 'tabler:file',
label: 'Upload Image',
namespace: 'namespace',
preview: null,
setData: () => {}
onChange: function () {}
},
render: args => {
const [image, setImage] = useState<string | File | null>(null)
const [preview, setPreview] = useState<string | null>(null)
render: function (args) {
const [val, setVal] = useState<FileValue>({ type: 'empty' })
return (
<FileInput
{...args}
enablePixabay
file={image}
preview={preview}
setData={({ file, preview }) => {
setImage(file)
setPreview(preview)
}}
value={val}
onChange={setVal}
/>
)
}
@@ -46,33 +39,25 @@ export const Default: Story = {
export const Disabled: Story = {
args: {
acceptedMimeTypes: {
mimeTypes: {
application: ['pdf'],
image: ['jpeg', 'png']
},
file: null,
value: { type: 'empty' },
icon: 'tabler:file',
label: 'Upload Image',
namespace: 'namespace',
preview: null,
setData: () => {}
onChange: function () {}
},
render: args => {
const [image, setImage] = useState<string | File | null>(null)
const [preview, setPreview] = useState<string | null>(null)
render: function (args) {
const [val, setVal] = useState<FileValue>({ type: 'empty' })
return (
<FileInput
{...args}
disabled
enablePixabay
file={image}
preview={preview}
setData={({ file, preview }) => {
setImage(file)
setPreview(preview)
}}
value={val}
onChange={setVal}
/>
)
}

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import { usePromiseLoading } from '@lifeforge/shared'
import { Button } from '@/components/inputs'
import { Button, type FilePickerSourceConfig } from '@/components/inputs'
import { Tabs } from '@/components/navigation'
import { ModalHeader } from '@/components/overlays'
import { Box, Flex } from '@/components/primitives'
@@ -14,22 +14,12 @@ import { LocalUpload } from './components/LocalUpload'
import { Pixabay } from './components/Pixabay'
export function FilePickerModal({
data: {
enablePixabay = false,
enableUrl = false,
enableAI = false,
defaultAIPrompt = '',
acceptedMimeTypes,
onSelect
},
data: { sources, mimeTypes, onSelect },
onClose
}: {
data: {
enablePixabay?: boolean
enableUrl?: boolean
enableAI?: boolean
defaultAIPrompt?: string
acceptedMimeTypes?: Record<string, string[]>
sources: FilePickerSourceConfig
mimeTypes?: Record<string, string[]>
onSelect: (file: string | File, preview: string | null) => Promise<void>
}
onClose: () => void
@@ -62,15 +52,11 @@ export function FilePickerModal({
title="imagePicker.title"
onClose={onClose}
/>
{(enablePixabay || enableUrl || enableAI) && (
{Object.values(sources).some(e => e) && (
<Tabs
currentTab={mode}
enabled={(['local', 'url', 'pixabay', 'ai'] as const).filter(
name =>
name === 'local' ||
(name === 'pixabay' && enablePixabay) ||
(name === 'url' && enableUrl) ||
(name === 'ai' && enableAI)
name => name === 'local' || sources[name]
)}
items={[
{
@@ -112,7 +98,7 @@ export function FilePickerModal({
case 'local':
return (
<LocalUpload
acceptedMimeTypes={acceptedMimeTypes}
acceptedMimeTypes={mimeTypes}
file={file}
preview={preview}
setFile={setFile}
@@ -138,7 +124,7 @@ export function FilePickerModal({
case 'ai':
return (
<AIImageGenerator
defaultPrompt={defaultAIPrompt || ''}
defaultPrompt={sources.ai ? sources.ai.defaultPrompt : ''}
file={file}
setFile={setFile}
setPreview={setPreview}

View File

@@ -0,0 +1,63 @@
import { Button } from '@/components/inputs'
import { Flex, Icon, Text } from '@/components/primitives'
import type { FileValue } from '..'
import { FILE_ICONS } from '../FilePickerModal/constants/file_icons'
export function CompactFileDisplay({
value,
onChange,
onImageRemoved
}: {
value: FileValue
onChange: (value: FileValue) => void
onImageRemoved?: () => void
}) {
return (
<Flex align="center" gap="xl" justify="between" mt="md">
<Flex align="center" gap="sm" minWidth="0">
<Icon
color="muted"
icon={(() => {
if (value.type !== 'file') return 'tabler:file'
const ext = (
value.source === 'existing'
? value.filename
: value.source === 'upload'
? value.file.name
: value.url.split('/').pop()?.split('?')[0] || ''
)
.split('.')
.pop()
?.toLowerCase()
return (
FILE_ICONS[ext as keyof typeof FILE_ICONS] ||
'tabler:file'
)
})()}
size="1.5rem"
/>
<Text truncate as="p">
{(() => {
if (value.type !== 'file') return ''
if (value.source === 'existing') return value.filename
if (value.source === 'upload') return value.file.name
return value.url
})()}
</Text>
</Flex>
<Button
icon="tabler:x"
p="sm"
variant="plain"
onClick={function () {
onChange({ type: 'empty' })
onImageRemoved?.()
}}
/>
</Flex>
)
}

View File

@@ -0,0 +1,45 @@
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/inputs'
import { Box, Text } from '@/components/primitives'
export function EmptyFileInput({
acceptedMimeTypes,
reminderText,
onSelect
}: {
acceptedMimeTypes?: Record<string, string[]>
reminderText?: string
onSelect: () => void
}) {
const { t } = useTranslation('common.misc')
return (
<>
<Box asChild my="md">
<Button
icon="tabler:file-plus"
style={{ width: '100%' }}
variant="secondary"
onClick={onSelect}
>
Select
</Button>
</Box>
<Text align="center" as="p" color="muted" size="sm">
{reminderText ||
t('fileInputSupportedFormat', {
format: acceptedMimeTypes
? Object.entries(acceptedMimeTypes)
.flatMap(function ([type, exts]) {
return exts.map(function (ext) {
return `${type}/${ext}`
})
})
.join(', ') || 'N/A'
: 'N/A'
})}
</Text>
</>
)
}

View File

@@ -0,0 +1,37 @@
import Zoom from 'react-medium-image-zoom'
import { Button } from '@/components/inputs'
import { Box } from '@/components/primitives'
import type { FileValue } from '..'
export function PreviewFileDisplay({
previewUrl,
onChange,
onImageRemoved
}: {
previewUrl: string
onChange: (value: FileValue) => void
onImageRemoved?: () => void
}) {
return (
<Box mt="lg">
<Zoom zoomMargin={100}>
<Box asChild maxHeight="24rem" r="md">
<img alt="" src={previewUrl} />
</Box>
</Zoom>
<Button
dangerous
icon="tabler:x"
style={{ marginTop: '1.5rem', width: '100%' }}
onClick={function () {
onChange({ type: 'empty' })
onImageRemoved?.()
}}
>
Remove
</Button>
</Box>
)
}

View File

@@ -1,75 +1,110 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Zoom from 'react-medium-image-zoom'
import { Button } from '@/components/inputs'
import { Box, Flex, Icon, Text } from '@/components/primitives'
import { Flex, Icon, Text } from '@/components/primitives'
import { useModalStore } from '@/providers'
import { colorWithOpacity } from '@/system'
import { useInputLabel } from '../shared/hooks/useInputLabel'
import { FilePickerModal } from './FilePickerModal'
import { FILE_ICONS } from './FilePickerModal/constants/file_icons'
import { CompactFileDisplay } from './components/CompactFileDisplay'
import { EmptyFileInput } from './components/EmptyFileInput'
import { PreviewFileDisplay } from './components/PreviewFileDisplay'
export type FileData = {
file: string | File | null
preview: string | null
export type FileValue =
| {
type: 'empty'
}
| {
type: 'file'
source: 'existing'
id: string
filename: string
preview?: string
}
| {
type: 'file'
source: 'upload'
file: File
preview?: string
}
| {
type: 'file'
source: 'url'
url: string
preview?: string
}
export interface FilePickerSourceConfig {
pixabay?: boolean
url?: boolean
ai?:
| {
defaultPrompt: string
}
| false
}
export function FileInput({
icon,
label,
reminderText,
file,
preview,
setData,
value,
onChange,
onImageRemoved,
required,
namespace,
disabled,
enablePixabay = false,
enableUrl = false,
enableAI = false,
defaultAIPrompt = '',
acceptedMimeTypes
sources = {
pixabay: false,
ai: false,
url: false
},
mimeTypes
}: {
icon: string
label: string
reminderText?: string
file: string | File | null
preview: string | null
setData: (data: {
file: string | File | null
preview: string | null
}) => void
value: FileValue
onChange: (value: FileValue) => void
onImageRemoved?: () => void
required?: boolean
namespace?: string
disabled?: boolean
enablePixabay?: boolean
enableUrl?: boolean
enableAI?: boolean
defaultAIPrompt?: string
acceptedMimeTypes?: Record<string, string[]>
sources?: FilePickerSourceConfig
mimeTypes?: Record<string, string[]>
}) {
const { open } = useModalStore()
const { t } = useTranslation('common.misc')
const inputLabel = useInputLabel({ namespace, label })
const handleFilePickerOpen = useCallback(() => {
open(FilePickerModal, {
enablePixabay,
enableUrl,
enableAI,
defaultAIPrompt,
acceptedMimeTypes,
onSelect: async (file: string | File, preview: string | null) => {
setData({ file, preview })
}
})
}, [enablePixabay, enableUrl, enableAI, defaultAIPrompt, acceptedMimeTypes])
const handleFilePickerOpen = useCallback(
function () {
open(FilePickerModal, {
sources,
mimeTypes,
onSelect: async function (file: string | File, preview: string | null) {
if (file instanceof File) {
onChange({
type: 'file',
source: 'upload',
file,
preview: preview ?? undefined
})
} else {
onChange({
type: 'file',
source: 'url',
url: file,
preview: preview ?? undefined
})
}
}
})
},
[sources, mimeTypes, onChange]
)
const previewUrl = value.type === 'file' ? value.preview : undefined
return (
<Flex
@@ -101,87 +136,29 @@ export function FileInput({
</Text>
</Flex>
</Text>
{!file || file === 'removed' ? (
<>
<Box asChild my="md">
<Button
icon="tabler:file-plus"
style={{ width: '100%' }}
variant="secondary"
onClick={handleFilePickerOpen}
>
Select
</Button>
</Box>
<Text align="center" as="p" color="muted" size="sm">
{reminderText ||
t('fileInputSupportedFormat', {
format: acceptedMimeTypes
? Object.entries(acceptedMimeTypes)
.flatMap(([type, exts]) =>
exts.map(ext => `${type}/${ext}`)
)
.join(', ') || 'N/A'
: 'N/A'
})}
</Text>
</>
{value.type === 'empty' ? (
<EmptyFileInput
acceptedMimeTypes={mimeTypes}
reminderText={reminderText}
onSelect={handleFilePickerOpen}
/>
) : (
<>
{preview &&
(preview.startsWith('http') ||
preview.startsWith('blob:') ||
preview.startsWith('data:')) ? (
<Box mt="lg">
<Zoom zoomMargin={100}>
<Box asChild maxHeight="24rem" r="md">
<img alt="" src={preview} />
</Box>
</Zoom>
<Button
dangerous
icon="tabler:x"
style={{ marginTop: '1.5rem', width: '100%' }}
onClick={() => {
setData({ file: null, preview: null })
onImageRemoved?.()
}}
>
Remove
</Button>
</Box>
{previewUrl &&
(previewUrl.startsWith('http') ||
previewUrl.startsWith('blob:') ||
previewUrl.startsWith('data:')) ? (
<PreviewFileDisplay
previewUrl={previewUrl}
onChange={onChange}
onImageRemoved={onImageRemoved}
/>
) : (
<Flex align="center" gap="xl" justify="between" mt="md">
<Flex align="center" gap="sm" minWidth="0">
<Icon
color="muted"
icon={
FILE_ICONS[
(file instanceof File
? file.name.split('.').pop()
: '') as keyof typeof FILE_ICONS
] || 'tabler:file'
}
size="1.5rem"
/>
<Text truncate as="p">
{file instanceof File
? file.name
: file === 'keep'
? (preview ?? '<File to be remained unchanged>')
: (preview ?? file)}
</Text>
</Flex>
<Button
icon="tabler:x"
p="sm"
variant="plain"
onClick={() => {
setData({ file: null, preview: null })
onImageRemoved?.()
}}
/>
</Flex>
<CompactFileDisplay
value={value}
onChange={onChange}
onImageRemoved={onImageRemoved}
/>
)}
</>
)}

View File

@@ -15,7 +15,7 @@ import { autoFocusableRef } from '../shared/utils/autoFocusableRef'
import { IconPickerModal } from './IconPickerModal'
import { IconPreview } from './components/IconPreview'
interface IconInputProps {
export interface IconInputProps {
label?: string
value: string
onChange: (value: string) => void

View File

@@ -89,10 +89,13 @@ export function ListboxOption({
{renderColorAndIcon ? (
renderColorAndIcon({ color, icon })
) : icon !== undefined ? (
<Box
<Flex
centered
aspectRatio="1/1"
flexShrink="0"
p={convertedColor !== undefined ? 'sm' : undefined}
pr={convertedColor === undefined ? 'sm' : undefined}
minHeight="0"
minWidth="0"
px={convertedColor !== undefined ? 'sm' : undefined}
r="md"
style={
convertedColor !== undefined
@@ -123,7 +126,7 @@ export function ListboxOption({
) : (
icon
)}
</Box>
</Flex>
) : (
convertedColor !== undefined && (
<Bordered

View File

@@ -35,8 +35,6 @@ interface ListboxInputProps<T> {
customActive?: boolean
/** The custom content to display in the listbox button. */
renderContent?: (value: T) => React.ReactNode
/** Whether to show an action button inside the listbox. */
hasActionButton?: boolean
/** The i18n namespace for internationalization. See the [main documentation](https://docs.lifeforge.melvinchia.dev) for more details. */
namespace?: string
/** The error message to display when the field is invalid. */

View File

@@ -4,11 +4,11 @@ import { Button } from '../Button'
import { TextInput } from '../TextInput'
import type { InputVariants } from '../shared/types'
interface NumberInputProps {
export interface NumberInputProps {
/** The label text displayed above the number input field. Required for 'classic' style. */
label?: string
label: string
/** The icon to display in the input field. Should be a valid icon name from Iconify. Required for 'classic' style. */
icon?: string
icon: string
/** The current numeric value of the input. */
value: number
/** Callback function called when the input value changes. */

View File

@@ -6,7 +6,7 @@ import * as styles from './SliderInput.css'
import { SliderHeader } from './components/SliderHeader'
import { SliderTicks } from './components/SliderTicks'
interface SliderInputProps extends Omit<BoxProps<'div'>, 'value' | 'onChange'> {
export interface SliderInputProps extends Omit<BoxProps<'div'>, 'value' | 'onChange'> {
label?: string
icon?: string
value: number

View File

@@ -11,7 +11,7 @@ import { useInputLabel } from '../shared/hooks/useInputLabel'
import type { InputVariants } from '../shared/types'
import { autoFocusableRef } from '../shared/utils/autoFocusableRef'
type TextAreaInputProps = {
export type TextAreaInputProps = {
/** The label text displayed above the textarea field. Required for 'classic' style. */
label?: string
/** The icon to display next to the label. Should be a valid icon name from Iconify. Required for 'classic' style. */

View File

@@ -12,7 +12,7 @@ import { useInputLabel } from '../shared/hooks/useInputLabel'
import type { InputVariants } from '../shared/types'
import { TextInputBox } from './components/TextInputBox'
type TextInputProps = {
export type TextInputProps = {
label?: string
icon?: string
placeholder: string

View File

@@ -1,12 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Button } from '@/components/inputs'
import { Box } from '@/components/primitives'
import { ModalHeader } from './ModalHeader'
import { ModalHeader } from '.'
const meta = {
argTypes: {
actionButtonProps: { control: false },
headerActions: { control: false },
appendTitle: { control: false },
className: { control: 'text' },
hasAI: { control: 'boolean' },
@@ -49,14 +50,16 @@ function Shell({ children }: { children: React.ReactNode }) {
export const Default: Story = {
args: {
icon: 'tabler:plus',
onClose: () => {},
onClose: function () {},
title: 'Create Item'
},
render: args => (
<Shell>
<ModalHeader {...args} />
</Shell>
)
render: function (args) {
return (
<Shell>
<ModalHeader {...args} />
</Shell>
)
}
}
/**
@@ -67,14 +70,16 @@ export const Default: Story = {
export const UntranslatedTitle: Story = {
args: {
icon: 'tabler:file-unknown',
onClose: () => {},
onClose: function () {},
title: 'untranslated_modal_key_xyz'
},
render: args => (
<Shell>
<ModalHeader {...args} />
</Shell>
)
render: function (args) {
return (
<Shell>
<ModalHeader {...args} />
</Shell>
)
}
}
/**
@@ -85,14 +90,16 @@ export const WithAIBadge: Story = {
args: {
hasAI: true,
icon: 'tabler:brain',
onClose: () => {},
onClose: function () {},
title: 'Smart Suggestions'
},
render: args => (
<Shell>
<ModalHeader {...args} />
</Shell>
)
render: function (args) {
return (
<Shell>
<ModalHeader {...args} />
</Shell>
)
}
}
/**
@@ -115,36 +122,43 @@ export const WithAppendTitle: Story = {
</span>
),
icon: 'tabler:tag',
onClose: () => {},
onClose: function () {},
title: 'Manage Tags'
},
render: args => (
<Shell>
<ModalHeader {...args} />
</Shell>
)
render: function (args) {
return (
<Shell>
<ModalHeader {...args} />
</Shell>
)
}
}
/**
* `actionButtonProps` renders an additional button in the right-hand action
* `headerActions` renders an additional button in the right-hand action
* area, to the left of the close button. The most common use-case is a help
* button or a secondary action.
*/
export const WithActionButton: Story = {
args: {
actionButtonProps: {
icon: 'tabler:help',
onClick: () => {}
},
headerActions: (
<Button
icon="tabler:help"
variant="plain"
onClick={function () {}}
/>
),
icon: 'tabler:file-export',
onClose: () => {},
onClose: function () {},
title: 'Export Data'
},
render: args => (
<Shell>
<ModalHeader {...args} />
</Shell>
)
render: function (args) {
return (
<Shell>
<ModalHeader {...args} />
</Shell>
)
}
}
/**
@@ -154,21 +168,26 @@ export const WithActionButton: Story = {
*/
export const WithActionButtonVariant: Story = {
args: {
actionButtonProps: {
children: 'Preview',
icon: 'tabler:eye',
onClick: () => {},
variant: 'secondary'
},
headerActions: (
<Button
icon="tabler:eye"
variant="secondary"
onClick={function () {}}
>
Preview
</Button>
),
icon: 'tabler:send',
onClose: () => {},
onClose: function () {},
title: 'Publish Post'
},
render: args => (
<Shell>
<ModalHeader {...args} />
</Shell>
)
render: function (args) {
return (
<Shell>
<ModalHeader {...args} />
</Shell>
)
}
}
/**
@@ -177,10 +196,13 @@ export const WithActionButtonVariant: Story = {
*/
export const KitchenSink: Story = {
args: {
actionButtonProps: {
icon: 'tabler:settings',
onClick: () => {}
},
headerActions: (
<Button
icon="tabler:settings"
variant="plain"
onClick={function () {}}
/>
),
appendTitle: (
<span
style={{
@@ -199,14 +221,16 @@ export const KitchenSink: Story = {
),
hasAI: true,
icon: 'tabler:sparkles',
onClose: () => {},
onClose: function () {},
title: 'AI Content Generator'
},
render: args => (
<Shell>
<ModalHeader {...args} />
</Shell>
)
render: function (args) {
return (
<Shell>
<ModalHeader {...args} />
</Shell>
)
}
}
/**
@@ -216,18 +240,20 @@ export const KitchenSink: Story = {
export const ReactNodeTitle: Story = {
args: {
icon: 'tabler:edit',
onClose: () => {},
onClose: function () {},
title: (
<span style={{ alignItems: 'center', display: 'flex', gap: '0.5rem' }}>
Edit <strong style={{ color: '#4caf50' }}>Project Alpha</strong>
</span>
)
},
render: args => (
<Shell>
<ModalHeader {...args} />
</Shell>
)
render: function (args) {
return (
<Shell>
<ModalHeader {...args} />
</Shell>
)
}
}
/**
@@ -237,13 +263,15 @@ export const ReactNodeTitle: Story = {
export const LongTitle: Story = {
args: {
icon: 'tabler:text-size',
onClose: () => {},
onClose: function () {},
title:
'This Is An Exceptionally Long Modal Title That Might Overflow If Not Truncated Properly'
},
render: args => (
<Shell>
<ModalHeader {...args} />
</Shell>
)
render: function (args) {
return (
<Shell>
<ModalHeader {...args} />
</Shell>
)
}
}

View File

@@ -3,10 +3,9 @@ import _ from 'lodash'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/inputs'
import { Box, Flex, Icon, Text } from '@/components/primitives'
import { Button } from '../../../../inputs/Button'
function _ModalHeader({
title,
icon,
@@ -15,7 +14,7 @@ function _ModalHeader({
hasAI = false,
appendTitle,
namespace = 'common.modals',
actionButtonProps
headerActions
}: {
title: string | React.ReactNode
icon: string
@@ -24,7 +23,7 @@ function _ModalHeader({
className?: string
appendTitle?: React.ReactElement
namespace?: string
actionButtonProps?: React.ComponentProps<typeof Button>
headerActions?: React.ReactNode
}) {
const { t } = useTranslation(namespace)
@@ -39,7 +38,7 @@ function _ModalHeader({
align="center"
className={className}
justify="between"
mb="md"
mb="sm"
style={{ gap: '0.75rem' }}
>
<Text asChild size="xl" weight="semibold">
@@ -87,12 +86,7 @@ function _ModalHeader({
</Flex>
</Text>
<Flex align="center" gap="sm">
{actionButtonProps && (
<Button
{...actionButtonProps}
variant={actionButtonProps.variant || 'plain'}
/>
)}
{headerActions}
<Button
icon="tabler:x"
style={{ padding: '0.75rem' }}

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { useModalStore } from '@/providers'
import { ModalWrapper } from '../components/ModalWrapper'
import { ModalWrapper } from '../ModalWrapper'
function FinalElement({ index }: { index: number }) {
const { stack, close } = useModalStore()

View File

@@ -1,6 +1,6 @@
import { Box, Flex } from '@/components/primitives'
import { ModalHeader } from '../../core/components/ModalHeader'
import { ModalHeader } from '../ModalHeader'
export function ViewImageModal({
data: { src },

View File

@@ -1,394 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import z from 'zod'
import { Button } from '@/components/inputs'
import { useModalStore } from '@/providers'
import { defineForm } from './formBuilder'
import { FormModal } from './index'
type CuteForm = {
name: string
age: number
color: string
icon: string
password: string
confirmPassword: string
}
const meta = {
argTypes: {
form: {
control: false
},
'ui.actionButton': {
control: false,
description:
'Action button to be displayed at the top right corner besides the close button.',
table: {
type: {
summary: 'React.ComponentProps<typeof Button>'
}
},
type: {
required: false,
summary: 'React.ComponentProps<typeof Button>'
}
},
'ui.icon': {
description:
'The icon besides the form title. Must be a valid icon identifier from Iconify in the form of `<icon-set>:<icon-name>`.',
type: {
required: true,
summary: 'string'
}
},
'ui.loading': {
description:
'Whether the form modal is in a loading state. A loading spinner will be shown instead of the form fields.'
},
'ui.namespace': {
description:
'The i18n namespace for internationalization. See the [main documentation](https://docs.lifeforge.melvinchia.dev) for more details.'
},
'ui.onClose': {
description:
'Callback function triggered when the close button is clicked.',
table: {
type: {
summary: '() => void'
}
},
type: {
required: true,
summary: '() => void'
}
},
'ui.submitButton': {
control: false,
description: 'The props for the submit button in the form modal.',
table: {
type: {
summary: "'create' | 'update' | React.ComponentProps<typeof Button>"
}
},
type: {
required: true,
summary: "'create' | 'update' | React.ComponentProps<typeof Button>"
}
},
'ui.title': {
description: 'The title of the form modal.',
type: {
required: true,
summary: 'string'
}
}
} as never,
component: FormModal,
parameters: {
deepControls: { enabled: true }
}
} satisfies Meta<typeof FormModal>
export default meta
type Story = StoryObj<typeof meta>
const MyFormModal = ({ onClose }: { onClose: () => void }) => {
const { formProps, formStateStore } = defineForm<CuteForm>({
actionButton: {
icon: 'tabler:cube',
variant: 'plain'
},
icon: 'tabler:forms',
loading: false,
namespace: '',
onClose,
submitButton: 'update',
title: 'Form Modal'
})
.typesMap({
age: 'number',
color: 'color',
confirmPassword: 'text',
icon: 'listbox',
name: 'text',
password: 'text'
})
.setupFields({
age: {
icon: 'tabler:number-123',
label: 'Age',
validator: z
.number()
.int('Invalid age. Age must be an integer.')
.nonnegative('Invalid age. Age must be positive.')
},
color: {
label: 'Color'
},
confirmPassword: {
icon: 'tabler:lock',
isPassword: true,
label: 'Confirm Password',
placeholder: '••••••••',
required: true,
validator: (value, formState) => {
if (value !== formState.password) {
return 'Passwords must match'
}
return true
}
},
icon: {
icon: 'tabler:icons',
label: 'Icon',
multiple: false,
options: formState => [
...(formState.name === 'Melvin'
? [
{
color: '#FF0000',
icon: 'tabler:heart',
text: 'Heart',
value: 'tabler:heart'
}
]
: []),
{ icon: 'tabler:star', text: 'Star', value: 'tabler:star' },
{
icon: 'tabler:check',
text: 'Check',
value: 'tabler:check'
},
{ icon: 'tabler:x', text: 'X', value: 'tabler:x' }
],
required: true
},
name: {
icon: 'tabler:user',
label: 'Name',
placeholder: 'John Doe',
required: true,
validator: z
.string()
.refine(
value => /^[a-zA-Z ]+$/.test(value),
'Invalid name. Only alphabetic characters and spaces are allowed.'
)
},
password: {
actionButtonProps: {
icon: 'tabler:dice',
onClick: () => {
const randomPassword = Math.random().toString(36).slice(-8)
formStateStore.setState(() => ({
confirmPassword: randomPassword,
password: randomPassword
}))
},
variant: 'plain'
},
icon: 'tabler:lock',
isPassword: true,
label: 'Password',
placeholder: '••••••••',
required: true,
validator: z
.string()
.min(8, 'Password must be at least 8 characters long.')
.max(100, 'Password must be at most 100 characters long.')
}
})
.autoFocusField('name')
.conditionalFields({
// age: data => data.name.trim() !== '',
// confirmPassword: data => data.password.trim() !== ''
})
.initialData({})
.onSubmit(async formData => {
alert(`Form submitted with data: ${JSON.stringify(formData)}`)
await new Promise(resolve => setTimeout(resolve, 1000))
alert('Form submitted successfully!')
})
.build()
return <FormModal {...formProps} />
}
export const Default: Story = {
args: {
ui: {
icon: 'tabler:forms',
loading: false,
namespace: '',
onClose: () => {},
submitButton: 'update',
title: 'Form Modal'
}
} as never,
render: () => {
const { open } = useModalStore()
return (
<Button
icon="tabler:plus"
onClick={() => {
open(MyFormModal, {})
}}
>
Open Form Modal
</Button>
)
}
}
/**
* Story demonstrating a FormModal with a Listbox input that includes an action button option.
* This action button allows users to perform a custom action, such as opening another modal for adding new options.
*/
export const ListboxInputWithActionButtonOption: Story = {
args: {
ui: {
icon: 'tabler:forms',
loading: false,
namespace: '',
onClose: () => {},
submitButton: 'create',
title: 'Form Modal with Listbox Input Action Button Option'
}
} as never,
render: () => {
const { open } = useModalStore()
const FormWithListboxInputActionButtonOption = ({
onClose
}: {
onClose: () => void
}) => {
const { formProps } = defineForm<{ choice: string | null }>({
icon: 'tabler:forms',
namespace: '',
onClose,
submitButton: 'create',
title: 'Form Modal'
})
.typesMap({
choice: 'listbox'
})
.setupFields({
choice: {
actionButtonOption: {
icon: 'tabler:plus',
onClick: () => {
alert('Add Option clicked!')
},
text: 'Add Option'
},
icon: 'tabler:list',
label: 'Choose an option',
multiple: false,
options: [
{ icon: 'tabler:number-1', text: 'Option 1', value: 'option1' },
{ icon: 'tabler:number-2', text: 'Option 2', value: 'option2' },
{ icon: 'tabler:number-3', text: 'Option 3', value: 'option3' }
],
required: true
}
})
.onSubmit(async formData => {
alert(`Form submitted with data: ${JSON.stringify(formData)}`)
})
.build()
return <FormModal {...formProps} />
}
return (
<Button
icon="tabler:plus"
onClick={() => {
open(FormWithListboxInputActionButtonOption, {})
}}
>
Open Form Modal
</Button>
)
}
}
export const DisabledFieldsWithTooltips: Story = {
args: {
ui: {
icon: 'tabler:forms',
loading: false,
namespace: '',
onClose: () => {},
submitButton: 'create',
title: 'Form Modal with Disabled Fields and Tooltips'
}
} as never,
render: () => {
const { open } = useModalStore()
const FormWithDisabledFieldsAndTooltips = ({
onClose
}: {
onClose: () => void
}) => {
const { formProps } = defineForm<{ username: string; email: string }>({
icon: 'tabler:forms',
namespace: '',
onClose,
submitButton: 'create',
title: 'Form Modal'
})
.typesMap({
email: 'text',
username: 'text'
})
.setupFields({
email: {
disabled: true,
disabledReason:
'Email changes are restricted for security reasons.',
icon: 'tabler:mail',
label: 'Email',
placeholder: 'john_doe@example.com'
},
username: {
disabled: true,
disabledReason: 'Your username cannot be changed once set.',
icon: 'tabler:user',
label: 'Username',
placeholder: 'john_doe'
}
})
.initialData({
email: 'john_doe@example.com',
username: 'john_doe'
})
.onSubmit(async formData => {
alert(`Form submitted with data: ${JSON.stringify(formData)}`)
})
.build()
return <FormModal {...formProps} />
}
return (
<Button
icon="tabler:plus"
onClick={() => {
open(FormWithDisabledFieldsAndTooltips, {})
}}
>
Open Form Modal
</Button>
)
}
}

View File

@@ -1,54 +0,0 @@
import _ from 'lodash'
import { useTranslation } from 'react-i18next'
import { Switch } from '@/components/inputs'
import { Flex, Icon, Text } from '@/components/primitives'
import type {
BaseFieldProps,
FormInputProps
} from '../../../typescript/form.types'
export type CheckboxFieldProps = BaseFieldProps<boolean, boolean> & {
type: 'checkbox'
icon: string
}
export function FormCheckboxInput({
field,
value,
namespace,
handleChange
}: FormInputProps<CheckboxFieldProps>) {
const { t } = useTranslation(namespace)
return (
<Flex direction="column" gap="sm" width="100%">
<Flex align="center" justify="between" py="sm">
<Flex align="center" gap="sm">
<Icon icon={field.icon} size="1.5em" />
<Text size="lg">
{t([
['inputs', _.camelCase(field.label), 'label']
.filter(e => e)
.join('.'),
['inputs', _.camelCase(field.label)].filter(e => e).join('.')
])}
</Text>
</Flex>
<Switch
disabled={field.disabled}
value={value}
onChange={() => {
handleChange(!value)
}}
/>
</Flex>
{field.errorMsg && (
<Text color="dangerous" size="sm">
{field.errorMsg}
</Text>
)}
</Flex>
)
}

View File

@@ -1,31 +0,0 @@
import { ColorInput } from '@/components/inputs'
import type {
BaseFieldProps,
FormInputProps
} from '../../../typescript/form.types'
export type ColorFieldProps = BaseFieldProps<string, string, true> & {
type: 'color'
}
export function FormColorInput({
field,
value,
autoFocus,
namespace,
handleChange
}: FormInputProps<ColorFieldProps>) {
return (
<ColorInput
autoFocus={autoFocus}
disabled={field.disabled}
errorMsg={field.errorMsg}
label={field.label}
namespace={namespace}
required={field.required}
value={value}
onChange={handleChange}
/>
)
}

View File

@@ -1,34 +0,0 @@
import { CurrencyInput } from '@/components/inputs'
import type {
BaseFieldProps,
FormInputProps
} from '../../../typescript/form.types'
export type CurrencyFieldProps = BaseFieldProps<number, number, true> & {
type: 'currency'
icon: string
}
export function FormCurrencyInput({
field,
value,
autoFocus,
namespace,
handleChange
}: FormInputProps<CurrencyFieldProps>) {
return (
<CurrencyInput
autoFocus={autoFocus}
disabled={field.disabled}
errorMsg={field.errorMsg}
icon={field.icon}
label={field.label}
namespace={namespace}
placeholder="0.00"
required={field.required}
value={value}
onChange={handleChange}
/>
)
}

View File

@@ -1,34 +0,0 @@
import { DateInput } from '@/components/inputs'
import type {
BaseFieldProps,
FormInputProps
} from '../../../typescript/form.types'
export type DateFieldProps = BaseFieldProps<Date | null, string, true> & {
type: 'datetime'
icon: string
hasTime?: boolean
}
export function FormDateInput({
field,
value,
autoFocus,
namespace,
handleChange
}: FormInputProps<DateFieldProps>) {
return (
<DateInput
autoFocus={autoFocus}
disabled={field.disabled}
hasTime={field.hasTime}
icon={field.icon}
label={field.label}
namespace={namespace}
required={field.required}
value={value}
onChange={handleChange}
/>
)
}

View File

@@ -1,52 +0,0 @@
import { type FileData, FileInput } from '@/components/inputs'
import type {
BaseFieldProps,
FormInputProps
} from '../../../typescript/form.types'
export type FileFieldProps<TOptional extends boolean = false> = BaseFieldProps<
FileData,
string | File
> & {
type: 'file'
icon: string
optional: TOptional
onFileRemoved?: () => void
enablePixabay?: boolean
enableUrl?: boolean
enableAIImageGeneration?: boolean
defaultImageGenerationPrompt?: string
acceptedMimeTypes?: Record<string, string[]>
}
export function FormFileInput({
field,
value,
namespace,
handleChange
}: FormInputProps<FileFieldProps>) {
return (
<FileInput
acceptedMimeTypes={field.acceptedMimeTypes}
defaultAIPrompt={field.defaultImageGenerationPrompt}
disabled={field.disabled}
enableAI={field.enableAIImageGeneration}
enablePixabay={field.enablePixabay}
enableUrl={field.enableUrl}
file={value.file}
icon={field.icon}
label={field.label}
namespace={namespace}
preview={value.preview}
required={field.required}
setData={handleChange}
onImageRemoved={() => {
handleChange({
file: 'removed',
preview: null
})
}}
/>
)
}

View File

@@ -1,31 +0,0 @@
import { IconInput } from '@/components/inputs'
import type {
BaseFieldProps,
FormInputProps
} from '../../../typescript/form.types'
export type IconFieldProps = BaseFieldProps<string, string, true> & {
type: 'icon'
}
export function FormIconInput({
field,
value,
autoFocus,
namespace,
handleChange
}: FormInputProps<IconFieldProps>) {
return (
<IconInput
autoFocus={autoFocus}
disabled={field.disabled}
errorMsg={field.errorMsg}
label={field.label}
namespace={namespace}
required={field.required}
value={value}
onChange={handleChange}
/>
)
}

View File

@@ -1,185 +0,0 @@
import { Fragment } from 'react/jsx-runtime'
import { ListboxInput, ListboxOption } from '@/components/inputs'
import { Box, Flex, Icon, Text } from '@/components/primitives'
import type {
BaseFieldProps,
FormInputProps
} from '../../../typescript/form.types'
export type ListboxFieldProps<
TOption = any,
TMultiple extends boolean = boolean,
TFormState = any
> = BaseFieldProps<
TMultiple extends true ? TOption[] : TOption | null,
TMultiple extends true ? TOption[] : TOption | undefined
> & {
type: 'listbox'
icon: string
multiple: TMultiple
options:
| Array<{
value: TOption
text: string
icon?: string
color?: string
}>
| ((formState: TFormState) => Array<{
value: TOption
text: string
icon?: string
color?: string
}>)
actionButtonOption?: {
text: string
onClick: () => void
icon: string
}
}
function OptionColorAndIcon({
color,
icon
}: {
color?: string
icon?: string
}) {
if (color && icon) {
return <Icon icon={icon} style={{ color }} />
}
if (!color) {
return <Icon icon={icon ?? ''} />
}
return (
<Box
display="inline-block"
flexShrink="0"
height="0.75em"
r="full"
style={{
backgroundColor: color
}}
width="0.75em"
/>
)
}
function ListboxButtonContent({
field,
value,
options
}: {
field: ListboxFieldProps
value: any
options: {
value: string
text: string
icon?: string
color?: string
}[]
}) {
if (field.multiple === true && Array.isArray(value)) {
return (
<Flex align="center" gap="md" wrap="wrap">
{value.length > 0 &&
value.map((item: string, i: number) => (
<Fragment key={item}>
<Flex align="center" gap="xs">
<Icon
icon={options.find(l => l.value === item)?.icon ?? ''}
style={{
color: options.find(l => l.value === item)?.color
}}
/>
<Text truncate>
{options.find(l => l.value === item)?.text ?? 'None'}
</Text>
</Flex>
{i !== value.length - 1 && (
<Icon icon="tabler:circle-filled" size="0.25em" />
)}
</Fragment>
))}
</Flex>
)
}
const targetOption = options.find(l => l.value === value)
if (!targetOption) {
return <Text>None</Text>
}
return (
<Flex align="center" gap="sm">
<OptionColorAndIcon color={targetOption.color} icon={targetOption.icon} />
<Text truncate>
{options.find(l => l.value === value)?.text ?? 'None'}
</Text>
</Flex>
)
}
export function FormListboxInput({
field,
value,
namespace,
handleChange,
options
}: FormInputProps<ListboxFieldProps>) {
if (!options) {
return null
}
return (
<ListboxInput
renderContent={() => (
<ListboxButtonContent field={field} options={options} value={value} />
)}
disabled={field.disabled}
errorMsg={field.errorMsg}
hasActionButton={!!field.actionButtonOption}
icon={field.icon}
label={field.label}
multiple={!!field.multiple}
namespace={namespace}
required={field.required}
value={value}
onChange={val => {
if (Array.isArray(val) && val.includes(null)) {
val = val.filter(v => v !== null)
}
if (val === null) {
return
}
handleChange(val)
}}
>
{options.map(({ text, color, icon, value: v }) => (
<ListboxOption
key={v}
color={color}
icon={icon}
label={text}
selected={JSON.stringify(v) === JSON.stringify(value)}
value={v}
/>
))}
{field.actionButtonOption && (
<ListboxOption
icon={field.actionButtonOption.icon}
label={field.actionButtonOption.text}
selected={false}
value={null}
onClick={field.actionButtonOption.onClick}
/>
)}
</ListboxInput>
)
}

View File

@@ -1,34 +0,0 @@
import { type Location, LocationInput } from '@/components/inputs'
import type {
BaseFieldProps,
FormInputProps
} from '../../../typescript/form.types'
export type LocationFieldProps = BaseFieldProps<
Location | null,
Location,
true
> & {
type: 'location'
}
export function FormLocationInput({
field,
value,
autoFocus,
namespace,
handleChange
}: FormInputProps<LocationFieldProps>) {
return (
<LocationInput
autoFocus={autoFocus}
disabled={field.disabled}
label={field.label}
namespace={namespace}
required={field.required}
value={value}
onChange={value => handleChange(value)}
/>
)
}

View File

@@ -1,33 +0,0 @@
import { NumberInput } from '@/components/inputs'
import type {
BaseFieldProps,
FormInputProps
} from '../../../typescript/form.types'
export type NumberFieldProps = BaseFieldProps<number, number, true> & {
type: 'number'
icon: string
}
export function FormNumberInput({
field,
value,
autoFocus,
namespace,
handleChange
}: FormInputProps<NumberFieldProps>) {
return (
<NumberInput
autoFocus={autoFocus}
disabled={field.disabled}
errorMsg={field.errorMsg}
icon={field.icon}
label={field.label}
namespace={namespace}
required={field.required}
value={value}
onChange={handleChange}
/>
)
}

View File

@@ -1,25 +0,0 @@
import { RRuleInput } from '@/components/inputs'
import type {
BaseFieldProps,
FormInputProps
} from '../../../typescript/form.types'
export type RRuleFieldProps = BaseFieldProps<string, string> & {
type: 'rrule'
hasDuration?: boolean
}
export function FormRRuleInput({
field,
value,
handleChange
}: FormInputProps<RRuleFieldProps>) {
return (
<RRuleInput
hasDuration={!!field.hasDuration}
value={value}
onChange={handleChange}
/>
)
}

View File

@@ -1,36 +0,0 @@
import { SliderInput } from '@/components/inputs'
import type {
BaseFieldProps,
FormInputProps
} from '../../../typescript/form.types'
export type SliderFieldProps = BaseFieldProps<number, number, true> & {
type: 'slider'
icon?: string
min?: number
max?: number
step?: number
}
export function FormSliderInput({
field,
value,
namespace,
handleChange
}: FormInputProps<SliderFieldProps>) {
return (
<SliderInput
disabled={field.disabled}
icon={field.icon}
label={field.label}
max={field.max}
min={field.min}
namespace={namespace}
required={field.required}
step={field.step}
value={value}
onChange={handleChange}
/>
)
}

View File

@@ -1,35 +0,0 @@
import { TextAreaInput } from '@/components/inputs'
import type {
BaseFieldProps,
FormInputProps
} from '../../../typescript/form.types'
export type TextAreaFieldProps = BaseFieldProps<string, string, true> & {
type: 'textarea'
icon: string
placeholder: string
}
export function FormTextAreaInput({
field,
value,
autoFocus,
namespace,
handleChange
}: FormInputProps<TextAreaFieldProps>) {
return (
<TextAreaInput
autoFocus={autoFocus}
disabled={field.disabled}
errorMsg={field.errorMsg}
icon={field.icon}
label={field.label}
namespace={namespace}
placeholder={field.placeholder}
required={field.required}
value={value}
onChange={handleChange}
/>
)
}

View File

@@ -1,64 +0,0 @@
import { QRCodeScanner, TextInput } from '@/components/inputs'
import { useModalStore } from '@/providers'
import type {
BaseFieldProps,
FormInputProps
} from '../../../typescript/form.types'
export type TextFieldProps = BaseFieldProps<string, string, true> & {
type: 'text'
icon: string
isPassword?: boolean
placeholder: string
qrScanner?: boolean
actionButtonProps?: React.ComponentProps<
typeof TextInput
>['actionButtonProps']
}
export function FormTextInput({
field,
value,
autoFocus,
namespace,
handleChange
}: FormInputProps<TextFieldProps>) {
const { open } = useModalStore()
const openQRScanner = () => {
open(QRCodeScanner, {
onScanned: data => {
handleChange(data)
}
})
}
return (
<>
<TextInput
actionButtonProps={
field.actionButtonProps
? field.actionButtonProps
: field.qrScanner
? {
icon: 'tabler:qrcode',
onClick: openQRScanner
}
: undefined
}
autoFocus={autoFocus}
disabled={field.disabled}
errorMsg={field.errorMsg}
icon={field.icon}
isPassword={field.isPassword}
label={field.label}
namespace={namespace}
placeholder={field.placeholder}
required={field.required}
value={value}
onChange={handleChange}
/>
</>
)
}

View File

@@ -1,202 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { memo, useMemo } from 'react'
import { Flex } from '@/components/primitives'
import { Tooltip } from '@/components/utilities'
import {
type FormFieldPropsUnion,
type FormInputProps,
type FormState
} from '../../typescript/form.types'
import { FormCheckboxInput } from './components/FormCheckboxInput'
import { FormColorInput } from './components/FormColorInput'
import { FormCurrencyInput } from './components/FormCurrencyInput'
import { FormDateInput } from './components/FormDateInput'
import { FormFileInput } from './components/FormFileInput'
import { FormIconInput } from './components/FormIconInput'
import { FormListboxInput } from './components/FormListboxInput'
import { FormLocationInput } from './components/FormLocationInput'
import { FormNumberInput } from './components/FormNumberInput'
import { FormRRuleInput } from './components/FormRRuleInput'
import { FormSliderInput } from './components/FormSliderInput'
import { FormTextAreaInput } from './components/FormTextAreaInput'
import { FormTextInput } from './components/FormTextInput'
// Map of form field types to their corresponding components
const COMPONENT_MAP: Record<
FormFieldPropsUnion['type'],
React.FC<FormInputProps<any>>
> = {
text: FormTextInput,
number: FormNumberInput,
currency: FormCurrencyInput,
textarea: FormTextAreaInput,
datetime: FormDateInput,
listbox: FormListboxInput,
color: FormColorInput,
icon: FormIconInput,
location: FormLocationInput,
checkbox: FormCheckboxInput,
file: FormFileInput,
rrule: FormRRuleInput,
slider: FormSliderInput
}
// Memoized individual form field component to prevent unnecessary rerenders
export const MemoizedFormField = memo(
({
id,
field,
value,
autoFocus,
namespace,
errorMsg,
options,
onFieldChange
}: {
id: string
field: FormFieldPropsUnion
value: any
autoFocus?: boolean
namespace?: string
errorMsg?: string
options?: {
value: string
text: string
icon?: string
color?: string
}[]
onFieldChange: (value: any) => void
}) => {
const fieldType = field.type as FormFieldPropsUnion['type']
const FormComponent = COMPONENT_MAP[fieldType] || (() => <></>)
return (
<Flex align="center" flex="1" gap="md">
<FormComponent
key={id}
autoFocus={autoFocus}
field={{ ...field, errorMsg }}
handleChange={onFieldChange}
namespace={namespace}
options={options}
value={value}
/>
{field.disabled && field.disabledReason && (
<Tooltip
icon="tabler:info-circle"
id={`tooltip-disabled-reason-${field.label}`}
>
{field.disabledReason}
</Tooltip>
)}
</Flex>
)
},
(prevProps, nextProps) => {
return (
prevProps.id === nextProps.id &&
prevProps.value === nextProps.value &&
prevProps.errorMsg === nextProps.errorMsg &&
prevProps.onFieldChange === nextProps.onFieldChange &&
JSON.stringify(prevProps.options) === JSON.stringify(nextProps.options)
)
}
)
MemoizedFormField.displayName = 'MemoizedFormField'
export function FormInputs<T extends FormState>({
fields,
autoFocusableFieldId,
conditionalFields,
data,
setData,
errorMsgs,
removeErrorMsg,
namespace
}: {
fields: Record<string, FormFieldPropsUnion>
autoFocusableFieldId?: string
conditionalFields?: {
[K in keyof T]?: (data: T) => boolean
}
data: T
setData: React.Dispatch<React.SetStateAction<T>>
errorMsgs: Record<string, string | undefined>
removeErrorMsg: (fieldId: string) => void
namespace?: string
}) {
const changeHandlers = useMemo(() => {
const handlers: Record<string, (value: any) => void> = {}
Object.keys(fields).forEach(id => {
handlers[id] = (value: any) => {
removeErrorMsg(id)
setData(prev => ({ ...prev, [id]: value }))
}
})
return handlers
}, [fields])
return (
<Flex direction="column" gap="md">
{Object.entries(fields as Record<string, FormFieldPropsUnion>).map(
([id, field]) => {
// Render corresponding form field component based on field type
const value = data[id]
const errorMsg = errorMsgs[id]
const conditionalCall = conditionalFields?.[id]?.(data)
// Determine whether there is a conditional hidden state,
// if not, the field should be visible
const conditionalHidden =
typeof conditionalCall === 'boolean' ? !conditionalCall : false
// If the field is explicitly hidden, conditionalHidden should be overridden
const hidden = field.hidden ? true : conditionalHidden
let options:
| {
value: string
text: string
icon?: string
color?: string
}[]
| undefined = undefined
if (field.type === 'listbox') {
if (typeof field.options === 'function') {
options = field.options(data)
} else {
options = field.options
}
}
if (hidden) {
return null
}
return (
<MemoizedFormField
key={id}
autoFocus={autoFocusableFieldId === id}
errorMsg={errorMsg}
field={field}
id={id}
namespace={namespace}
options={options}
value={value}
onFieldChange={changeHandlers[id] || (() => {})}
/>
)
}
)}
</Flex>
)
}

View File

@@ -1,41 +0,0 @@
import _ from 'lodash'
import { Button } from '@/components/inputs'
export function SubmitButton({
submitButton,
submitLoading,
onSubmitButtonClick
}: {
submitButton: 'create' | 'update' | React.ComponentProps<typeof Button>
submitLoading: boolean
onSubmitButtonClick: () => Promise<void>
}) {
if (typeof submitButton === 'string') {
return (
<Button
icon={submitButton === 'create' ? 'tabler:plus' : 'tabler:pencil'}
loading={submitLoading}
mt="lg"
width="100%"
onClick={onSubmitButtonClick}
>
{_.upperFirst(submitButton)}
</Button>
)
}
if (submitButton) {
return (
<Button
mt="lg"
width="100%"
{...submitButton}
loading={submitLoading}
onClick={onSubmitButtonClick}
/>
)
}
return <></>
}

View File

@@ -1,539 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useMemo } from 'react'
import { type StoreApi, type UseBoundStore, create } from 'zustand'
import type { FormModal } from '.'
import type {
FieldsConfig,
FormState,
InferAutoFocusableFieldIds,
InferFormFinalState,
InferFormState,
MatchFieldByFormDataType
} from './typescript/form.types'
import { getInitialData } from './utils/formUtils'
/**
* Since the inferred body input data from `forgeAPI` might be complex unions,
* we need a way to flatten these unions for easier type inference.
*
* @template T - The union type to flatten
*/
type FlattenUnion<T> = {
[K in T extends any ? keyof T : never]: T extends { [k in K]?: any }
? T[K]
: never
}
/**
* A type-safe form builder class that provides a fluent API for constructing FormModal configurations.
*
* This class uses the builder pattern to allow step-by-step configuration of form properties
* with full TypeScript type safety and inference. Each method returns a new instance with
* updated type parameters, ensuring that the form configuration is valid at compile time.
*
* @template TFormState - The base form state type defining field names and data types
* @template TFieldType - Mapping of field names to their corresponding field types
* @template TFieldsConfig - Configuration object for form fields (initially undefined)
* @template TFinalFields - Final processed field configuration with type information
* @template TInitialData - Initial form data type (initially undefined)
* @template TOnSubmit - Submit handler function type (initially undefined)
*
* @example
* ```tsx
* // Define form structure
* type UserForm = {
* name: string
* email: string
* age: number
* }
*
* // Build form configuration
* const formConfig = defineForm<UserForm>({
* title: 'Create User',
* icon: 'user-plus',
* onClose: () => setModalOpen(false),
* submitButton: 'tabler:plus'
* })
* .typesMap({ name: 'text', email: 'email', age: 'number' })
* .setupFields({
* name: { label: 'Full Name', required: true },
* email: { label: 'Email Address', required: true },
* age: { label: 'Age', min: 0, max: 120 }
* })
* .initialData({ name: '', email: '', age: 0 })
* .onSubmit(async (data) => {
* console.log('Submitting:', data)
* })
* .build()
* ```
*/
export class FormBuilder<
TFormState extends FormState,
TFieldType extends {
[K in keyof TFormState]: MatchFieldByFormDataType<TFormState[K]>['type']
} = {
[K in keyof TFormState]: MatchFieldByFormDataType<TFormState[K]>['type']
},
TFieldsConfig = undefined,
TFinalFields = undefined,
TInitialData = undefined,
TOnSubmit = undefined
> {
/** UI configuration for the form modal */
private readonly uiConfig?: React.ComponentProps<typeof FormModal>['ui']
/** Mapping of field names to their types */
private readonly fieldType?: TFieldType
/** Raw field configuration object */
private readonly fields?: TFieldsConfig
/** Field ID to be autofocused */
private readonly _autoFocusableFieldId?: keyof TFormState
private readonly _conditionalFields?: {
[K in keyof TFormState]?: (data: TFormState) => boolean
}
/** Final processed field configuration with types */
private readonly finalFields?: TFinalFields
/** Initial data for form fields */
private readonly _initialData?: TInitialData
/** Form submission handler */
private readonly _onSubmit?: TOnSubmit
/**
* Creates a new FormBuilder instance. Generally not called directly - use `defineForm()` instead.
*
* @param opts - Optional configuration object containing all builder properties
*/
constructor(opts?: {
uiConfig?: React.ComponentProps<typeof FormModal>['ui']
fieldType?: TFieldType
fields?: TFieldsConfig
autoFocusableFieldId?: keyof TFormState
finalFields?: TFinalFields
initialData?: TInitialData
onSubmit?: TOnSubmit
conditionalFields?: {
[K in keyof TFormState]?: (data: TFormState) => boolean
}
}) {
if (opts) {
this.uiConfig = opts.uiConfig
this.fieldType = opts.fieldType
this.fields = opts.fields
this._autoFocusableFieldId = opts.autoFocusableFieldId
this.finalFields = opts.finalFields
this._initialData = opts.initialData
this._onSubmit = opts.onSubmit
this._conditionalFields = opts.conditionalFields
}
}
/**
* Maps form field names to their corresponding input types.
* This step is required and defines what type of input component will be rendered for each field.
*
* @param fieldType - Object mapping field names to their input types.
* IntelliSense for the `setupFields()` function will provide suggestions
* based on this mapping. Available field types for each form field are inferred
* from the form state type.
* @returns A new FormBuilder instance with the field type mapping applied
*
* @example
* ```tsx
* .typesMap({
* name: 'text',
* email: 'email',
* age: 'number',
* country: 'select'
* })
* ```
*/
typesMap<
TFieldType2 extends {
[K in keyof TFormState]: MatchFieldByFormDataType<TFormState[K]>['type']
}
>(
fieldType: TFieldType2
): FormBuilder<
TFormState,
TFieldType2,
TFieldsConfig,
TFinalFields,
TInitialData,
TOnSubmit
> {
return new FormBuilder({
...this,
fieldType
})
}
/**
* Configures the individual form fields with their properties and validation rules.
* This method processes the field configuration and merges it with the previously defined field types.
* Available config options for each field is inferred from the corresponding field type mapping defined
* in the `typesMap()` step.
*
* @param fields - Configuration object where keys are field names and values contain field properties
* @returns A new FormBuilder instance with processed field configuration
*
* @example
* ```tsx
* .setupFields({
* name: {
* label: 'Full Name',
* placeholder: 'Enter your full name',
* required: true
* },
* email: {
* label: 'Email Address',
* placeholder: 'you@example.com',
* required: true
* },
* age: {
* label: 'Age',
* required: true
* }
* })
* ```
*/
setupFields<TFields extends FieldsConfig<TFormState, TFieldType>>(
fields: TFields
): FormBuilder<
TFormState,
TFieldType,
TFields,
{
[K in keyof TFields]: TFields[K] & {
type: TFieldType[K extends keyof TFieldType ? K : never]
}
},
TInitialData,
TOnSubmit
> {
// Merge field types into field configurations
for (const key in fields) {
;(fields[key] as any).type = (this.fieldType as any)[key]
}
type TFinalFields2 = {
[K in keyof TFields]: TFields[K] & {
type: TFieldType[K extends keyof TFieldType ? K : never]
}
}
return new FormBuilder({
...this,
fields,
finalFields: fields as unknown as TFinalFields2
})
}
/**
* Sets the field that should be automatically focused when the form is opened.
* This is useful for improving user experience by focusing on the most relevant input.
*
* @param fieldId - The ID of the field to focus on
* @returns A new FormBuilder instance with the auto-focus field set
*
* @example
* ```tsx
* .autoFocusField('name')
* ```
*/
autoFocusField(
fieldId: InferAutoFocusableFieldIds<TFieldType>
): FormBuilder<
TFormState,
TFieldType,
TFieldsConfig,
TFinalFields,
TInitialData,
TOnSubmit
> {
return new FormBuilder({
uiConfig: this.uiConfig,
fieldType: this.fieldType,
fields: this.fields,
autoFocusableFieldId: fieldId as string,
conditionalFields: this._conditionalFields,
finalFields: this.finalFields,
initialData: this._initialData,
onSubmit: this._onSubmit
})
}
conditionalFields(fields: {
[K in keyof TFormState]?: (data: TFormState) => boolean
}): FormBuilder<
TFormState,
TFieldType,
TFieldsConfig,
TFinalFields,
TInitialData,
TOnSubmit
> {
return new FormBuilder({
uiConfig: this.uiConfig,
fieldType: this.fieldType,
fields: this.fields,
autoFocusableFieldId: this._autoFocusableFieldId,
conditionalFields: fields,
finalFields: this.finalFields,
initialData: this._initialData,
onSubmit: this._onSubmit
})
}
/**
* Sets the initial data for the form fields. This is optional and allows pre-populating fields.
*
* @param initialData - Partial object containing initial values for form fields
* @returns A new FormBuilder instance with initial data configured
*
* @example
* ```tsx
* .initialData({
* name: 'John Doe',
* email: 'john@example.com'
* // age field will remain empty
* })
* ```
*/
initialData(
initialData?: Partial<
InferFormState<NonNullable<TFieldType>, NonNullable<TFinalFields>>
>
): FormBuilder<
TFormState,
TFieldType,
TFieldsConfig,
TFinalFields,
typeof initialData,
TOnSubmit
> {
return new FormBuilder({
uiConfig: this.uiConfig,
fieldType: this.fieldType,
fields: this.fields,
finalFields: this.finalFields,
autoFocusableFieldId: this._autoFocusableFieldId,
conditionalFields: this._conditionalFields,
onSubmit: this._onSubmit,
initialData
})
}
/**
* Sets the form submission handler. This is a required step.
* The callback receives the final form data with proper typing.
*
* @param callback - Async function that handles form submission
* @returns A new FormBuilder instance with the submit handler configured
*
* @example
* ```tsx
* .onSubmit(async (data) => {
* // Do some preprocessing if needed before submission
*
* await mutation.mutateAsync(data)
* })
* ```
*/
onSubmit(
callback: (
data: InferFormFinalState<
NonNullable<TFieldType>,
NonNullable<TFinalFields>
>
) => Promise<void>
): FormBuilder<
TFormState,
TFieldType,
TFieldsConfig,
TFinalFields,
TInitialData,
typeof callback
> {
return new FormBuilder({
uiConfig: this.uiConfig,
fieldType: this.fieldType,
fields: this.fields,
finalFields: this.finalFields,
autoFocusableFieldId: this._autoFocusableFieldId,
conditionalFields: this._conditionalFields,
initialData: this._initialData,
onSubmit: callback
})
}
/**
* Builds the final FormModal configuration object.
* This method validates that all required steps have been completed and returns
* a configuration object that can be passed to the FormModal component.
*
* @returns Configuration object for FormModal component
* @throws Error if required configuration steps are missing
*
* @example
* ```tsx
* const formConfig = defineForm<UserForm>({
* title: 'Create User',
* icon: 'user-plus',
* onClose: () => setModalOpen(false),
* submitButton: 'tabler:plus'
* })
* .typesMap({ name: 'text', email: 'email' })
* .setupFields({ name: { label: 'Name' }, email: { label: 'Email' } })
* .onSubmit(async (data) => { await saveUser(data) })
* .build()
*
* // Use with FormModal
* <FormModal {...formConfig} isOpen={isOpen} onClose={onClose} />
* ```
*/
build(): {
formProps: React.ComponentProps<typeof FormModal>
formStateStore: UseBoundStore<StoreApi<TFormState>>
} {
const {
uiConfig,
fieldType,
finalFields,
_initialData,
_onSubmit,
_conditionalFields
} = this
// Validate that all required configuration steps are complete
if (!uiConfig || !fieldType || !finalFields || !_onSubmit) {
throw new Error('FormBuilder: Some required steps not complete')
}
const formStateStore = create(
() =>
getInitialData(
fieldType,
finalFields,
_initialData as any
) as TFormState
)
const memoizedValue = useMemo(() => {
return {
formProps: {
form: {
fieldTypes: fieldType,
fields: finalFields,
autoFocusableFieldId: this._autoFocusableFieldId as string,
conditionalFields: _conditionalFields as any,
onSubmit: _onSubmit as unknown as (
data: InferFormFinalState<any, any>
) => Promise<void>
},
ui: uiConfig,
dataStore: formStateStore
},
formStateStore
}
}, [formStateStore])
return memoizedValue
}
}
export class FormBuilderWithoutTypesMap<TFormState extends FormState> {
constructor(private uiConfig: React.ComponentProps<typeof FormModal>['ui']) {}
typesMap<
TFieldType2 extends {
[K in keyof TFormState]: MatchFieldByFormDataType<TFormState[K]>['type']
}
>(fieldType: TFieldType2): FormBuilder<TFormState, TFieldType2> {
return new FormBuilder({
...this,
uiConfig: this.uiConfig,
fieldType
})
}
}
/**
* Factory function to create a new type-safe form builder instance.
*
* This is the entry point for building forms with full TypeScript type safety.
* The generic parameter defines the shape of your form data, and the builder
* will ensure that all subsequent configuration steps are properly typed.
*
* @template T - The form state type defining field names and their data types
* @returns A new FormBuilder instance ready for configuration
*
* @example
* ```tsx
* // Define the shape of your form data
* type CreateUserForm = {
* name: string
* email: string
* age: number
* isActive: boolean
* }
*
* // Create a type-safe form builder
* const userFormConfig = defineForm<CreateUserForm>({
* title: 'Create New User',
* icon: 'user-plus',
* onClose: () => setModalOpen(false),
* submitButton: 'create'
* })
* .typesMap({
* name: 'text',
* email: 'email',
* age: 'number',
* isActive: 'checkbox'
* })
* .setupFields({
* name: {
* label: 'Full Name',
* placeholder: 'Enter full name',
* required: true
* },
* email: {
* label: 'Email Address',
* placeholder: 'user@example.com',
* required: true
* },
* age: {
* label: 'Age',
* required: true
* },
* isActive: {
* label: 'Active User'
* }
* })
* .initialData({
* name: '',
* email: '',
* age: 0,
* isActive: true
* })
* .onSubmit(async (data) => {
* // data is fully typed as CreateUserForm
* await createUser(data)
* })
* .build()
*
* // Use with FormModal component
* function UserCreationModal({ isOpen, onClose }: Props) {
* return (
* <FormModal
* {...userFormConfig}
* isOpen={isOpen}
* onClose={onClose}
* />
* )
* }
* ```
*/
export function defineForm<T extends FormState>(
uiConfig: React.ComponentProps<typeof FormModal>['ui']
) {
return new FormBuilderWithoutTypesMap<FlattenUnion<T>>(uiConfig)
}

View File

@@ -1,329 +0,0 @@
/* eslint-disable no-case-declarations */
import { loadIcon } from '@iconify/react'
import { stringToIcon, validateIconName } from '@iconify/utils'
import dayjs from 'dayjs'
import _ from 'lodash'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ZodType } from 'zod'
import type { StoreApi, UseBoundStore } from 'zustand'
import { usePromiseLoading } from '@lifeforge/shared'
import { LoadingScreen } from '@/components/feedback'
import { Flex } from '@/components/primitives'
import { type Button } from '../../../../inputs/Button'
import { ModalHeader } from '../../core/components/ModalHeader'
import { FormInputs } from './components/FormInputs'
import { SubmitButton } from './components/SubmitButton'
import type {
FormFieldPropsUnion,
FormState,
InferFormFinalState,
InferFormState
} from './typescript/form.types'
import { checkEmpty } from './utils/formUtils'
function validateField(
validator: ((value: any, formState: FormState) => boolean | string) | ZodType,
value: any,
formState: FormState
) {
if (typeof validator === 'function') {
return validator(value, formState)
}
const result = validator.safeParse(value)
return result.success ? true : result.error.issues[0].message
}
export function FormModal({
form: {
fields,
fieldTypes,
autoFocusableFieldId,
onSubmit,
onChange,
conditionalFields
},
ui: {
title,
icon,
namespace,
loading = false,
onClose,
submitButton,
actionButton
},
dataStore
}: {
/** Form data and field configs. See the [main documentation](https://docs.lifeforge.dev/developer-guide/forms) for more details. */
form: {
fields: Record<string, FormFieldPropsUnion>
fieldTypes: Record<string, FormFieldPropsUnion['type']>
autoFocusableFieldId?: string
onSubmit: (
data: InferFormFinalState<typeof fieldTypes, typeof fields>
) => Promise<void>
onChange?: (data: InferFormState<typeof fieldTypes, typeof fields>) => void
conditionalFields?: {
[K in keyof InferFormState<typeof fieldTypes, typeof fields>]?: (
data: InferFormState<typeof fieldTypes, typeof fields>
) => boolean
}
}
ui: {
title: string | React.ReactNode
icon: string
onClose: () => void
namespace?: string
loading?: boolean
submitButton: 'create' | 'update' | React.ComponentProps<typeof Button>
actionButton?: Omit<React.ComponentProps<typeof Button>, 'onClick'> & {
onClick?: (
data: InferFormState<typeof fieldTypes, typeof fields>,
setData: React.Dispatch<
React.SetStateAction<InferFormState<typeof fieldTypes, typeof fields>>
>
) => void
}
}
dataStore: UseBoundStore<
StoreApi<InferFormState<typeof fieldTypes, typeof fields>>
>
}) {
const { t } = useTranslation('common.misc')
const [data, setData] = useState(() => dataStore.getState())
useEffect(() => {
dataStore.setState(data)
const unsubscribe = dataStore.subscribe(data => {
setData(data)
})
return unsubscribe
}, [dataStore])
const [errorMsgs, setErrorMsgs] = useState<
Record<string, string | undefined>
>({})
async function handleSubmit(): Promise<void> {
const visibleFields = Object.entries(fields).filter(field => {
// Explicitly defined hidden state takes precedence
if (field[1].hidden) {
return false
}
const conditionalCall = conditionalFields?.[field[0]]?.(data)
if (typeof conditionalCall === 'boolean') {
return conditionalCall
}
return true
})
// Transform field values based on their types
const finalData = Object.fromEntries(
Object.entries(data).map(([key, value]) => {
const fieldType = fields[key]?.type
if (!fieldType) {
return [key, value]
}
let finalValue: unknown = value
switch (fieldType) {
case 'datetime':
finalValue = value
? dayjs(value as never).format('YYYY-MM-DDTHH:mm:ssZ')
: undefined
break
case 'location':
finalValue = value ?? undefined
break
case 'currency':
case 'number':
finalValue = Number(value)
break
case 'file':
finalValue = (value as any).file ?? undefined
break
case 'checkbox':
finalValue = Boolean(value)
break
case 'listbox':
const options =
typeof fields[key].options === 'function'
? fields[key].options(data)
: fields[key].options
if (Array.isArray(value)) {
finalValue = value.filter(v =>
options.find(option => option.value === v)
)
} else {
finalValue = options.find(option => option.value === value)
? value
: undefined
}
break
default:
finalValue = value
}
return [key, finalValue]
})
)
const validationResults: Record<string, string | undefined> = {}
for (const [key, field] of visibleFields) {
const value = finalData[key]
const isEmpty = checkEmpty(value)
// If the field is empty and required, set the validation message,
// otherwise skip the validation
if (isEmpty) {
validationResults[key] = field.required ? t('fieldRequired') : undefined
continue
}
// Validate color format
// Should be a valid hex color code
if (field.type === 'color' && !/^#[0-9A-F]{6}$/i.test(value as string)) {
validationResults[key] = t('invalidColor')
continue
}
if (field.type === 'icon') {
if (!validateIconName(stringToIcon(value as string))) {
validationResults[key] = t('invalidIcon')
continue
}
try {
await loadIcon(value as string)
} catch {
validationResults[key] = t('invalidIcon')
continue
}
}
const validator = (
field as FormFieldPropsUnion & {
validator?:
| ((value: any, formState: FormState) => boolean | string)
| ZodType
}
).validator
const result = validator
? validateField(validator, value as never, data)
: true
if (typeof result === 'string') {
validationResults[key] = result
continue
}
validationResults[key] = result === true ? undefined : 'Invalid field'
}
if (
!Object.values(validationResults).every(result => result === undefined)
) {
setErrorMsgs(validationResults)
return
}
// Call the onSubmit callback with the final data
// Close the modal on successful submission
if (onSubmit) {
try {
await onSubmit(
finalData as InferFormFinalState<typeof fieldTypes, typeof fields>
)
onClose()
} catch (error) {
// Leave the error handling to the parent component
console.log('Form submission error:', error)
}
}
}
const removeErrorMsg = (fieldId: string) => {
setErrorMsgs(prev => ({ ...prev, [fieldId]: undefined }))
}
const [submitButtonLoading, onSubmitButtonClick] =
usePromiseLoading(handleSubmit)
// Notify parent component of data changes
useEffect(() => {
onChange?.(data)
if (_.isEqual(data, dataStore.getState())) {
// If the data is shallow equal to the previous data,
// we can skip the update
return
}
dataStore.setState(data)
}, [data, onChange])
return (
<Flex direction="column" minWidth="50vw">
<ModalHeader
actionButtonProps={
actionButton && {
...actionButton,
onClick: () => actionButton?.onClick?.(data, setData)
}
}
icon={icon}
namespace={namespace ? namespace : undefined}
title={title}
onClose={onClose}
/>
{!loading ? (
<>
<FormInputs
autoFocusableFieldId={autoFocusableFieldId}
conditionalFields={conditionalFields}
data={data as FormState}
errorMsgs={errorMsgs}
fields={fields}
namespace={namespace}
removeErrorMsg={removeErrorMsg}
setData={setData}
/>
<SubmitButton
submitButton={submitButton}
submitLoading={submitButtonLoading}
onSubmitButtonClick={onSubmitButtonClick}
/>
</>
) : (
<Flex
align="center"
direction="column"
flex="1"
justify="center"
minHeight="24em"
>
<LoadingScreen />
</Flex>
)}
</Flex>
)
}

View File

@@ -1,224 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type React from 'react'
import type { ZodType } from 'zod'
import type { CheckboxFieldProps } from '../components/FormInputs/components/FormCheckboxInput'
import type { ColorFieldProps } from '../components/FormInputs/components/FormColorInput'
import type { CurrencyFieldProps } from '../components/FormInputs/components/FormCurrencyInput'
import type { DateFieldProps } from '../components/FormInputs/components/FormDateInput'
import type { FileFieldProps } from '../components/FormInputs/components/FormFileInput'
import type { IconFieldProps } from '../components/FormInputs/components/FormIconInput'
import type { ListboxFieldProps } from '../components/FormInputs/components/FormListboxInput'
import type { LocationFieldProps } from '../components/FormInputs/components/FormLocationInput'
import type { NumberFieldProps } from '../components/FormInputs/components/FormNumberInput'
import type { RRuleFieldProps } from '../components/FormInputs/components/FormRRuleInput'
import type { SliderFieldProps } from '../components/FormInputs/components/FormSliderInput'
import type { TextAreaFieldProps } from '../components/FormInputs/components/FormTextAreaInput'
import type { TextFieldProps } from '../components/FormInputs/components/FormTextInput'
export type BaseFieldProps<
TFormDataType,
TFinalDataType,
TAutoFocusable extends boolean = false
> = {
label: string
hidden?: boolean
required?: boolean
disabled?: boolean
disabledReason?: string | React.ReactElement
__formDataType: TFormDataType
__finalDataType: TFinalDataType
__autoFocusable?: TAutoFocusable
}
/** --------- Utility Types ----------- */
export type UnionToIntersection<U> = (
U extends any ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never
export type DistributiveOmit<T, K extends keyof any> = T extends any
? Omit<T, K>
: never
export type FieldsMap<T> = UnionToIntersection<
T extends { type: infer K }
? K extends string | number | symbol
? { [P in K]: Extract<T, { type: K }> }
: never
: never
>
export type FormFieldTypeMap = FieldsMap<FormFieldPropsUnion>
export type FormState = Record<string, any>
// Find field type by data type (usually for automatic inference)
export type MatchFieldByFormDataType<T> = Extract<
FormFieldPropsUnion,
{ __finalDataType: T extends { __type: 'media' } ? string | File : T }
>
export type MatchFieldByType<TType> = Extract<
FormFieldPropsUnion,
{ type: TType }
>
// Find props by field type (for customization/override)
export type MatchFieldByFieldType<TField> = TField extends {
type: infer FieldType
}
? MatchFieldByType<FieldType>
: never
// Single field inference
export type Field<TField extends FormFieldPropsUnion> = TField &
BaseFieldProps<TField['__formDataType'], TField['__finalDataType']>
// Props for component usage
export type FormInputProps<TField extends FormFieldPropsUnion> = {
field: Field<TField> & {
errorMsg?: string
}
value: TField['__formDataType']
autoFocus?: boolean
namespace?: string
options?: {
value: string
text: string
icon?: string
color?: string
}[]
handleChange: (value: TField['__formDataType']) => void
}
/** --------- Form Field Configuration (for developers) ----------- */
export type InferFinalFieldConfig<
TFieldType extends FormFieldPropsUnion['type'],
TFormState,
TAllFormState
> = TFieldType extends 'listbox'
? ListboxFieldProps<
TFormState extends Array<infer TOption> ? TOption : TFormState,
boolean,
TAllFormState
>
: TFieldType extends 'file'
? FileFieldProps<
TFormState extends { config: { optional: infer TOptional } }
? TOptional extends boolean
? TOptional
: false
: false
>
: FormFieldTypeMap[TFieldType]
export type FieldsConfig<
TFormState extends FormState = FormState,
TFieldType extends {
[K in keyof TFormState]: MatchFieldByFormDataType<TFormState[K]>['type']
} = {
[K in keyof TFormState]: MatchFieldByFormDataType<TFormState[K]>['type']
}
> = Partial<{
[K in keyof TFormState]: InferFinalFieldConfig<
TFieldType[K],
TFormState[K],
InferFormState<TFieldType, TFormState>
> extends infer TField
? DistributiveOmit<
TField,
'type' | '__finalDataType' | '__formDataType' | '__autoFocusable'
> & {
validator?:
| ((
value: InferFormFinalState<TFieldType, TFormState>[K],
formState: TFormState
) => boolean | string)
| ZodType
}
: never
}>
export type InferListboxOptions<TOption> = TOption extends {
value: infer TValue
text: string
}
? TValue
: never
export type ExtractOptionsArray<TOptions> =
TOptions extends Array<infer TOption>
? TOption
: TOptions extends (...args: any[]) => Array<infer TOption>
? TOption
: never
export type InferListBoxFormState<TField> = TField extends {
options: infer TOptions
}
? TField extends {
multiple: true
}
? InferListboxOptions<ExtractOptionsArray<TOptions>>[]
: InferListboxOptions<ExtractOptionsArray<TOptions>>
: never
export type InferFormState<TFieldTypes, TFields> = {
[K in keyof TFieldTypes]: TFieldTypes[K] extends infer TFieldType
? TFieldType extends 'listbox'
? InferListBoxFormState<TFields[K extends keyof TFields ? K : never]>
: MatchFieldByType<TFieldType>['__formDataType']
: never
}
export type InferListBoxFinalState<TField> = TField extends {
options: infer TOptions
}
? TField extends { multiple: true }
? InferListboxOptions<ExtractOptionsArray<TOptions>>[]
: TField extends { required: true }
? InferListboxOptions<ExtractOptionsArray<TOptions>>
: InferListboxOptions<ExtractOptionsArray<TOptions>> | undefined
: never
export type InferFormFinalState<
TFieldTypes,
TFieldProps extends FieldsConfig<any, any>
> = {
[K in keyof TFieldTypes]: TFieldTypes[K] extends infer TFieldType
? TFieldType extends 'listbox'
? InferListBoxFinalState<TFieldProps[K]>
: MatchFieldByType<TFieldType>['__finalDataType']
: never
}
export type IsAutoFocusable<TType> =
MatchFieldByType<TType> extends infer TField
? TField extends { __autoFocusable?: true }
? true
: false
: false
export type InferAutoFocusableFieldIds<TFieldTypes> = {
[K in keyof TFieldTypes]: IsAutoFocusable<TFieldTypes[K]> extends true
? K
: never
}[keyof TFieldTypes]
export type FormFieldPropsUnion =
| TextFieldProps
| NumberFieldProps
| CurrencyFieldProps
| TextAreaFieldProps
| DateFieldProps
| ListboxFieldProps
| ColorFieldProps
| IconFieldProps
| FileFieldProps
| LocationFieldProps
| CheckboxFieldProps
| RRuleFieldProps
| SliderFieldProps

View File

@@ -1,226 +0,0 @@
import dayjs from 'dayjs'
/**
* Transforms existing data based on field type to ensure proper format for form inputs.
*
* This function handles type-specific transformations when loading existing data into forms.
* Currently handles datetime fields by converting string dates to Date objects.
*
* @param fieldType - The type of the form field (e.g., 'text', 'number', 'datetime')
* @param value - The raw value to transform
* @returns The transformed value ready for form input, or the original value if no transformation needed
*
* @example
* ```typescript
* // Transform datetime string to Date object
* const dateValue = transformExistedData('datetime', '2023-12-25T10:30:00Z')
* // Returns: Date object
*
* // Non-datetime fields pass through unchanged
* const textValue = transformExistedData('text', 'Hello World')
* // Returns: 'Hello World'
* ```
*/
export const transformExistedData = (
fieldType: string,
value: unknown
): unknown => {
if (fieldType === 'datetime' && value) {
return dayjs(value as string).toDate()
}
return value
}
/**
* Checks if a form field value should be considered empty.
*
* This utility function provides comprehensive empty value detection across different data types
* commonly used in forms. It handles various edge cases including null objects, empty arrays,
* invalid dates, and custom object structures used by specific form components.
*
* @param value - The value to check for emptiness
* @returns `true` if the value is considered empty, `false` otherwise
*
* @example
* ```typescript
* // Basic empty checks
* checkEmpty(null) // true
* checkEmpty(undefined) // true
* checkEmpty('') // true
* checkEmpty(' ') // true
* checkEmpty([]) // true
* checkEmpty({}) // true
*
* // Date checks
* checkEmpty(new Date('invalid')) // true
* checkEmpty(new Date()) // false
*
* // File field checks
* checkEmpty({ file: null, preview: null }) // true
* checkEmpty({ file: 'removed', preview: '' }) // true
* checkEmpty({ file: fileObject, preview: 'data:image...' }) // false
*
* // Location field checks
* checkEmpty({ name: '', formattedAddress: '' }) // true
* checkEmpty({ name: 'Home', formattedAddress: '123 St' }) // false
*
* // Valid values
* checkEmpty('hello') // false
* checkEmpty(0) // false
* checkEmpty(false) // false
* checkEmpty(['item']) // false
* ```
*/
export const checkEmpty = (value: unknown): boolean => {
if (value === null || value === undefined) {
return true
}
if (typeof value === 'string' && value.trim() === '') {
return true
}
if (Array.isArray(value) && value.length === 0) {
return true
}
if (value instanceof Date) {
return isNaN(value.getTime())
}
if (value instanceof File) {
return false
}
if (typeof value === 'object') {
if (Object.keys(value).length === 0) {
return true
}
if ('file' in value && (!value.file || value.file === 'removed')) {
return true
}
if (
'name' in value &&
'formattedAddress' in value &&
!value.name &&
!value.formattedAddress
) {
return true
}
}
return false
}
/**
* Generates initial form data with appropriate default values based on field types.
*
* This function creates a complete initial state object for form fields, handling both
* existing data transformation and default value assignment. It ensures that each field
* has an appropriate initial value based on its type, preventing undefined states.
*
* @template TFormConfig - The form configuration type extending FieldsConfig
* @param fieldTypes - Object mapping field names to their input types
* @param fields - The complete field configuration object
* @param formExistedData - Optional existing data to populate fields with
* @returns Object with initial values for all form fields
*
* @example
* ```typescript
* // Define field types and configuration
* const fieldTypes = {
* name: 'text',
* age: 'number',
* birthDate: 'datetime',
* tags: 'listbox',
* isActive: 'checkbox',
* avatar: 'file'
* }
*
* const fields = {
* name: { label: 'Name', required: true },
* age: { label: 'Age', min: 0 },
* birthDate: { label: 'Birth Date' },
* tags: { label: 'Tags', multiple: true },
* isActive: { label: 'Active' },
* avatar: { label: 'Profile Picture' }
* }
*
* // Generate initial data without existing data
* const initialData = getInitialData(fieldTypes, fields)
* // Returns:
* // {
* // name: '',
* // age: 0,
* // birthDate: null,
* // tags: [], // multiple listbox
* // isActive: false,
* // avatar: { file: null, preview: null }
* // }
*
* // Generate initial data with existing data
* const existingData = {
* name: 'John Doe',
* age: 30,
* birthDate: '1993-01-15T00:00:00Z'
* }
*
* const initialDataWithExisting = getInitialData(fieldTypes, fields, existingData)
* // Returns:
* // {
* // name: 'John Doe',
* // age: 30,
* // birthDate: Date object (transformed from string),
* // tags: [], // uses default for missing fields
* // isActive: false,
* // avatar: { file: null, preview: null }
* // }
* ```
*/
export const getInitialData = (
fieldTypes: Record<string, any>,
fields: Record<string, any>,
initialData?: Record<string, any>
): Record<string, any> => {
return Object.fromEntries(
Object.entries(fieldTypes).map(([key, fieldType]) => {
// Use existing data if available and not empty
if (initialData && key in initialData && initialData[key]) {
return [key, transformExistedData(fieldType, initialData[key])]
}
// Set appropriate default value based on field type
let finalValue: unknown = ''
switch (fieldType) {
case 'number':
case 'currency':
finalValue = 0
break
case 'datetime':
case 'location':
finalValue = null
break
case 'listbox':
// Handle single vs multiple selection listboxes
finalValue = fields[key]?.multiple ? [] : null
break
case 'checkbox':
finalValue = false
break
case 'file':
// File fields need object structure for file and preview
finalValue = { file: null, preview: null }
break
default:
// Default to empty string for text-based inputs
finalValue = ''
}
return [key, finalValue]
})
)
}

View File

@@ -1,13 +1,9 @@
export * from './features/FormModal'
export * from './ModalHeader'
export * from './features/ConfirmationModal'
export * from './ModalWrapper'
export * from './features/ViewImageModal'
export * from './ModalManager'
export * from './core/components/ModalHeader'
export * from './ConfirmationModal'
export * from './core/components/ModalWrapper'
export * from './core/ModalManager'
export * from './features/FormModal/formBuilder'
export * from './ViewImageModal'

View File

@@ -1,28 +1,6 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
if (Math.random() < 0) {
// @ts-ignore
import('./styles/index.css')
}
export * from './providers'
export * from './components/primitives'
export * from './components/auth'
export * from './components/data-display'
export * from './components/inputs'
export * from './components/feedback'
export * from './components/inputs'
export * from './components/layout'
export * from './components/navigation'
export * from './components/overlays'
export * from './components/utilities'
export * from './components'
export {
TAILWIND_PALETTE,
@@ -37,5 +15,3 @@ export type {
OpacityValue,
ColorValue as ColorPropValue
} from './system'
export * from './providers'