Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
1b8af72
Add react-markdown dependency
csillag Jun 11, 2025
ae4ca3e
Add a helper component for displaying MarkDown
csillag Jun 11, 2025
71c3e35
Add MaybeWithTooltip component
csillag Jun 11, 2025
188e648
Add framer-motion dependency
csillag Jun 11, 2025
1c1d719
Add utility class for configurable animations
csillag Jun 12, 2025
ee928fc
Add a bunch of generic utility functions
csillag Jun 12, 2025
d8914ac
Add BooleanInput
csillag Jun 12, 2025
fe181fc
Add spinner animation
csillag Jun 12, 2025
10a75c1
Add Label and useLabel
csillag Jun 12, 2025
c74c715
Add ActionButton and useAction()
csillag Jun 12, 2025
1445f7e
Add TextInput and useTextField()
csillag Jun 13, 2025
38c21ef
Add SelectInput and useOneOfField()
csillag Jun 14, 2025
c3deab7
Markdown: add support for applying className
csillag Jun 16, 2025
67eb729
useInputField enhancements
csillag Jun 16, 2025
1d4ca28
useOneOfField: add support for nullable selects
csillag Jun 16, 2025
ce88598
add useDateField() and <DateInput>
csillag Jun 16, 2025
85113a0
Text input: add support for password mode
csillag Jun 17, 2025
68d8ef4
useAction(): improve autogenerated names
csillag Jun 17, 2025
8625eec
All fields: auto-generated labels (except on label)
csillag Jun 17, 2025
2233b61
Validation: make stillRefresh() optional
csillag Jun 17, 2025
b062a95
Add StoryBook page for validation
csillag Jun 17, 2025
245a344
Boolean input: enable auto-label
csillag Jun 17, 2025
c54e7ff
Boolean input: enable markdown support for label
csillag Jun 17, 2025
f16aaa2
Simplify storybook examples
csillag Jun 17, 2025
e24ce67
Support shorter form for test and bool fields
csillag Jun 17, 2025
2b0ea61
Add function for collecting form values
csillag Jun 17, 2025
6e6a11a
StoryBook: more form examples
csillag Jun 17, 2025
131c78e
Linting fixes
csillag Jun 17, 2025
0a5ae1c
Tighten up type definitions
csillag Jun 17, 2025
2767176
Add more terse format for defining select fields
csillag Jun 17, 2025
ca20dfe
Add more terse format for defining boolean fields
csillag Jun 17, 2025
50b6bea
Add more terse format for defining labels
csillag Jun 17, 2025
aac59da
Add more terse format for defining text fields
csillag Jun 17, 2025
22d42cf
Add support for defining fors in a map-like syntax
csillag Jun 17, 2025
ce544a5
useTextField: support required on the short form
csillag Jun 17, 2025
eeb8740
Support validating form groups also in the map format
csillag Jun 17, 2025
e361b92
Fix a message race condition
csillag Jun 17, 2025
edf74d5
Improve storybook example
csillag Jun 17, 2025
f6860a2
Improve validation internals
csillag Jun 18, 2025
2a8637d
Improve SelectInput story
csillag Jun 18, 2025
3ee3cd2
More linting fixes
csillag Jun 18, 2025
4e12fc4
Improve styling
csillag Jun 18, 2025
11e5f9a
Enable animations by default
csillag Jun 18, 2025
9c1fbbe
Improve form story
csillag Jun 18, 2025
c62f520
CSS fixes
csillag Jun 18, 2025
a0b453a
Improve field group functions
csillag Jun 18, 2025
0507f23
Add some documentation
csillag Jun 18, 2025
b3d1594
Improve docs
csillag Jun 18, 2025
0203e23
Improve docs
csillag Jun 18, 2025
8a1d603
doc linting
csillag Jun 19, 2025
0b6690c
Button: Add optional testId arg
csillag Jun 19, 2025
8b3cd7b
Small improvements, mostly for testability
csillag Jun 19, 2025
d183888
Clean up storybook for ActionButton
csillag Jun 19, 2025
c634e03
Clean up BooleanInput stories
csillag Jun 19, 2025
63e0e5b
Rename MaybeWithTooltip -> WithTooltip
csillag Jun 19, 2025
c35fcde
Clean up WithTooltip storybook
csillag Jun 19, 2025
dcca3fb
Avoid displaying empty labels/description
csillag Jun 20, 2025
ae48e31
Remove fake storybook tests
csillag Jun 20, 2025
fafa62a
Simplify some StoryBook test cases
csillag Jun 20, 2025
8caf6ee
Clean up labeloutput storybook stories
csillag Jun 20, 2025
026ce3c
Linting fixes
csillag Jun 20, 2025
f7530ba
Fix failing test cases
csillag Jun 20, 2025
419fdd1
Fix failing test cases
csillag Jun 20, 2025
f360c1d
Improve association between input and label
csillag Jun 20, 2025
9725179
Add proper test cases for TextInput
csillag Jun 20, 2025
1580ce0
Add proper test cases for TextInput
csillag Jun 20, 2025
bc2d6b1
Improve SelectInput stories
csillag Jun 20, 2025
8920e35
Improve SelectInput stories
csillag Jun 20, 2025
f5583e3
Improve select story
csillag Jun 20, 2025
1c97547
Improve select story
csillag Jun 20, 2025
3485b8a
Get rid of the ValidatorGenerators concept
csillag Jun 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,15 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.17.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.507.0",
"next-themes": "^0.4.6",
"react": "^18.2.0",
"react-day-picker": "8.10.1",
"react-dom": "^18.2.0",
"react-hook-form": "^7.56.2",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.1",
"recharts": "^2.15.3",
"sonner": "^2.0.3",
Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ export * from './ui/toggle'
export * from './ui/toggle-group'
export * from './ui/tooltip'
export * from './ui/layout'
export * from './ui-plus-behavior'
85 changes: 85 additions & 0 deletions src/components/ui-plus-behavior/Animations/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { CSSProperties, FC, forwardRef, PropsWithChildren, SVGAttributes } from 'react'
import { motion } from 'framer-motion'

type MotionDivProps = Parameters<typeof motion.div>[0]

type CSSPropertiesWithoutTransitionOrSingleTransforms = Omit<
CSSProperties,
'transition' | 'rotate' | 'scale' | 'perspective'
>
type SVGTransformAttributes = {
attrX?: number
attrY?: number
attrScale?: number
}

interface SVGPathProperties {
pathLength?: number
pathOffset?: number
pathSpacing?: number
}

type TargetProperties = CSSPropertiesWithoutTransitionOrSingleTransforms &
SVGAttributes<SVGElement> &
SVGTransformAttributes &
// TransformProperties &
SVGPathProperties

type Target = Omit<TargetProperties, 'rotate'>

type AnimationPolicy =
| { id: 'allowAll' }
| { id: 'denyAll' }
| { id: 'allow'; allow: string[] }
| { id: 'deny'; deny: string[] }

let policy: AnimationPolicy = {
// id: 'denyAll',
id: 'allowAll',
}
export const setAnimationPolicy = (newPolicy: AnimationPolicy) => (policy = newPolicy)

export const shouldAnimate = (reason: string | undefined): boolean => {
switch (policy.id) {
case 'allowAll':
return true
case 'denyAll':
return false
case 'allow':
return !!reason && policy.allow.includes(reason)
case 'deny':
return !reason || !policy.deny.includes(reason)
default:
console.log('Warning: unknown animation policy', policy)
return false
}
}

export const MotionDiv: FC<
PropsWithChildren<
Omit<
MotionDivProps,
| 'style'
| 'children'
| 'onDrag'
| 'onDragStart'
| 'onDragEnd'
| 'onAnimationStart'
| 'initial'
| 'animate'
>
> & {
reason?: string | undefined
initial?: Target
animate?: Target
}
> = forwardRef((props, ref) => {
const { reason, ...motionProps } = props
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { layout, initial, animate, exit, ...divProps } = motionProps
if (shouldAnimate(reason)) {
return <motion.div {...motionProps} ref={ref} />
} else {
return <div {...divProps} style={{ ...(initial ?? {}), ...(animate ?? {}) }} />
}
})
1 change: 1 addition & 0 deletions src/components/ui-plus-behavior/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './tooltip'
107 changes: 107 additions & 0 deletions src/components/ui-plus-behavior/input/ActionButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { FC, MouseEventHandler } from 'react'
import { ActionControls } from './useAction'
import { WithVisibility } from './WithVisibility'
import { WithValidation } from './WithValidation'

import { WithTooltip } from '../tooltip'
import { Button } from '../../ui/button.tsx'
import { MarkdownBlock } from '../../ui/markdown.tsx'
import { Info, LoaderCircle } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../../ui/dialog.tsx'
import { cn } from '../../../lib'

export const ActionButton: FC<ActionControls<unknown>> = props => {
const {
name,
allMessages,
size,
color,
variant,
label,
execute,
enabled,
whyDisabled,
description,
isPending,
confirmationNeeded,
onConfirmationProvided,
onConfirmationDenied,
className,
expandHorizontally,
} = props
const handleClick: MouseEventHandler = event => {
event.stopPropagation()
execute().then(
result => {
if (result !== undefined) {
console.log('Result on action button', name, ':', typeof result, result)
}
},
error => {
if (error.message === 'User canceled action') {
// User didn't confirm, not an issue
} else {
console.log('Error on action button', name, ':', error)
}
}
)
}
return (
<>
<WithVisibility field={props}>
<WithValidation field={props} messages={allMessages.root}>
<WithTooltip
overlay={whyDisabled ?? description}
side={'top'}
className={expandHorizontally ? 'w-full' : undefined}
>
<Button
className={cn(className, expandHorizontally ? 'w-full' : undefined)}
variant={variant}
size={size}
color={color}
onClick={handleClick}
disabled={!enabled || isPending}
>
{isPending && <LoaderCircle size="1em" className="rotating" />}
<MarkdownBlock code={label} mainTag={'span'} />
{description && <Info />}
</Button>
</WithTooltip>
</WithValidation>
</WithVisibility>
<Dialog
open={!!confirmationNeeded}
onOpenChange={open => {
if (!open) onConfirmationDenied()
}}
>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
<MarkdownBlock code={confirmationNeeded?.title} />
</DialogTitle>
<DialogDescription>
<MarkdownBlock code={confirmationNeeded?.description} />
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onConfirmationDenied} testId={'cancel'}>
<MarkdownBlock code={confirmationNeeded?.cancelLabel} mainTag={'span'} />
</Button>
<Button onClick={onConfirmationProvided} variant={confirmationNeeded?.variant} testId={'confirm'}>
<MarkdownBlock code={confirmationNeeded?.okLabel} mainTag={'span'} />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
70 changes: 70 additions & 0 deletions src/components/ui-plus-behavior/input/BooleanInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { FC } from 'react'
import { BooleanFieldControls } from './useBoolField'

import { WithVisibility } from './WithVisibility'
import { WithValidation } from './WithValidation'
import { WithTooltip } from '../tooltip'
import { cn } from '../../../lib'
import { Checkbox } from '../../ui/checkbox'
import { Label } from '../../ui/label'
import { Info } from 'lucide-react'
import { Switch } from '../../ui/switch.tsx'
import { MarkdownBlock } from '../../ui/markdown.tsx'

export const BooleanInput: FC<BooleanFieldControls> = props => {
const { id, description, label, value, setValue, allMessages, enabled, whyDisabled, preferredWidget } =
props

const labelId = `${id}-label`

return (
<WithVisibility field={props}>
<WithValidation field={props} messages={allMessages.root}>
<WithTooltip overlay={whyDisabled ?? description}>
<div
className={cn(
'flex items-center space-x-2'
// enabled ? classes.pointer : classes.disabled
)}
>
{preferredWidget === 'checkbox' && (
<>
<Checkbox
id={id}
aria-labelledby={labelId}
aria-label={label?.toString()}
checked={value}
onCheckedChange={setValue}
disabled={!enabled}
/>
<Label htmlFor={id} id={labelId} className={enabled ? undefined : 'text-muted-foreground'}>
<MarkdownBlock code={label} mainTag={'span'} />
{(description || !enabled) && <Info size="1em" />}
</Label>
</>
)}
{preferredWidget === 'switch' && (
<>
<Label
htmlFor={id}
id={`${id}-label`}
className={enabled ? undefined : 'text-muted-foreground'}
>
<MarkdownBlock code={label} mainTag={'span'} />
{(description || !enabled) && <Info size="1em" />}
</Label>
<Switch
id={id}
aria-labelledby={labelId}
checked={value}
onCheckedChange={setValue}
disabled={!enabled}
/>
</>
)}
</div>
</WithTooltip>
</WithValidation>
</WithVisibility>
)
}
48 changes: 48 additions & 0 deletions src/components/ui-plus-behavior/input/DateInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React, { FC, useCallback } from 'react'
import { DateFieldControls } from './useDateField'

import { WithVisibility } from './WithVisibility'
import { WithLabelAndDescription } from './WithLabelAndDescription'
import { WithValidation } from './WithValidation'
import { WithTooltip } from '../tooltip'
import { Input } from '../../ui/input.tsx'
import { checkMessagesForProblems } from './util'

const convertToDateTimeLocalString = (date: Date) => {
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')

return `${year}-${month}-${day}T${hours}:${minutes}`
}

export const DateInput: FC<DateFieldControls> = props => {
const { name, value, setValue, allMessages, enabled, whyDisabled } = props
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => setValue(new Date(event.target.value)),
[setValue]
)

const { hasError } = checkMessagesForProblems(allMessages.root)

return (
<WithVisibility field={props}>
<WithLabelAndDescription field={props}>
<WithValidation field={props} messages={allMessages.root}>
<WithTooltip overlay={whyDisabled}>
<Input
aria-invalid={hasError}
name={name}
type={'datetime-local'}
value={value ? convertToDateTimeLocalString(value) : undefined}
onChange={handleChange}
disabled={!enabled}
/>
</WithTooltip>
</WithValidation>
</WithLabelAndDescription>
</WithVisibility>
)
}
17 changes: 17 additions & 0 deletions src/components/ui-plus-behavior/input/ExecutionContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { MarkdownCode } from '../../ui/markdown'

export type Timeout = ReturnType<typeof setTimeout>

export type ExecutionContext = {
setStatus: (message: MarkdownCode | undefined) => void
log: (message: MarkdownCode | unknown, ...optionalParams: unknown[]) => void
warn: (message: MarkdownCode | unknown, ...optionalParams: unknown[]) => void
error: (message: MarkdownCode | unknown, ...optionalParams: unknown[]) => void
}

export const basicExecutionContext: ExecutionContext = {
setStatus: () => undefined,
log: console.log,
warn: console.warn,
error: console.error,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { AnimatePresence } from 'framer-motion'
import { FC } from 'react'
import { FieldMessageList } from './FieldMessageDisplay'
import { FieldMessage } from './util'
import { InputFieldControls } from './useInputField'
import { MotionDiv } from '../Animations'
import { MarkdownBlock } from '../../ui/markdown'

export const FieldAndValidationMessage: FC<
Pick<InputFieldControls<unknown>, 'validationPending' | 'validationStatusMessage' | 'clearErrorMessage'> & {
messages: FieldMessage[]
}
> = props => {
const { validationPending, validationStatusMessage, messages, clearErrorMessage } = props

return (
<>
<FieldMessageList messages={messages} onRemove={clearErrorMessage} />
<AnimatePresence mode={'wait'}>
{!!validationStatusMessage && validationPending && (
<MotionDiv
reason={'fieldValidationErrors'}
layout
key={'problems-and-validation-messages'}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, delay: 0 }}
role={'status-message'}
>
<MarkdownBlock code={validationStatusMessage} />
</MotionDiv>
)}
</AnimatePresence>
</>
)
}
Loading
Loading