diff --git a/package.json b/package.json index fe8309e2..1501fd64 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "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", @@ -91,6 +92,7 @@ "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", diff --git a/src/components/index.ts b/src/components/index.ts index a8b812c1..922774f9 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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' diff --git a/src/components/ui-plus-behavior/Animations/index.tsx b/src/components/ui-plus-behavior/Animations/index.tsx new file mode 100644 index 00000000..6821f7dc --- /dev/null +++ b/src/components/ui-plus-behavior/Animations/index.tsx @@ -0,0 +1,85 @@ +import { CSSProperties, FC, forwardRef, PropsWithChildren, SVGAttributes } from 'react' +import { motion } from 'framer-motion' + +type MotionDivProps = Parameters[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 & + SVGTransformAttributes & + // TransformProperties & + SVGPathProperties + +type Target = Omit + +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 + } else { + return
+ } +}) diff --git a/src/components/ui-plus-behavior/index.ts b/src/components/ui-plus-behavior/index.ts new file mode 100644 index 00000000..fa5a7e52 --- /dev/null +++ b/src/components/ui-plus-behavior/index.ts @@ -0,0 +1 @@ +export * from './tooltip' diff --git a/src/components/ui-plus-behavior/input/ActionButton.tsx b/src/components/ui-plus-behavior/input/ActionButton.tsx new file mode 100644 index 00000000..235b83c0 --- /dev/null +++ b/src/components/ui-plus-behavior/input/ActionButton.tsx @@ -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> = 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 ( + <> + + + + + + + + { + if (!open) onConfirmationDenied() + }} + > + + + + + + + + + + + + + + + + + ) +} diff --git a/src/components/ui-plus-behavior/input/BooleanInput.tsx b/src/components/ui-plus-behavior/input/BooleanInput.tsx new file mode 100644 index 00000000..0f83bc57 --- /dev/null +++ b/src/components/ui-plus-behavior/input/BooleanInput.tsx @@ -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 = props => { + const { id, description, label, value, setValue, allMessages, enabled, whyDisabled, preferredWidget } = + props + + const labelId = `${id}-label` + + return ( + + + +
+ {preferredWidget === 'checkbox' && ( + <> + + + + )} + {preferredWidget === 'switch' && ( + <> + + + + )} +
+
+
+
+ ) +} diff --git a/src/components/ui-plus-behavior/input/DateInput.tsx b/src/components/ui-plus-behavior/input/DateInput.tsx new file mode 100644 index 00000000..f3cbd1f7 --- /dev/null +++ b/src/components/ui-plus-behavior/input/DateInput.tsx @@ -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 = props => { + const { name, value, setValue, allMessages, enabled, whyDisabled } = props + const handleChange = useCallback( + (event: React.ChangeEvent) => setValue(new Date(event.target.value)), + [setValue] + ) + + const { hasError } = checkMessagesForProblems(allMessages.root) + + return ( + + + + + + + + + + ) +} diff --git a/src/components/ui-plus-behavior/input/ExecutionContext.ts b/src/components/ui-plus-behavior/input/ExecutionContext.ts new file mode 100644 index 00000000..0d02d004 --- /dev/null +++ b/src/components/ui-plus-behavior/input/ExecutionContext.ts @@ -0,0 +1,17 @@ +import { MarkdownCode } from '../../ui/markdown' + +export type Timeout = ReturnType + +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, +} diff --git a/src/components/ui-plus-behavior/input/FieldAndValidationMessage.tsx b/src/components/ui-plus-behavior/input/FieldAndValidationMessage.tsx new file mode 100644 index 00000000..bf720a6e --- /dev/null +++ b/src/components/ui-plus-behavior/input/FieldAndValidationMessage.tsx @@ -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, 'validationPending' | 'validationStatusMessage' | 'clearErrorMessage'> & { + messages: FieldMessage[] + } +> = props => { + const { validationPending, validationStatusMessage, messages, clearErrorMessage } = props + + return ( + <> + + + {!!validationStatusMessage && validationPending && ( + + + + )} + + + ) +} diff --git a/src/components/ui-plus-behavior/input/FieldMessageDisplay.tsx b/src/components/ui-plus-behavior/input/FieldMessageDisplay.tsx new file mode 100644 index 00000000..f781036e --- /dev/null +++ b/src/components/ui-plus-behavior/input/FieldMessageDisplay.tsx @@ -0,0 +1,52 @@ +import { FC, ForwardedRef, forwardRef } from 'react' +import { FieldMessage, FieldMessageType } from './util' +import { AnimatePresence } from 'framer-motion' +import { MotionDiv } from '../Animations' +import { MarkdownBlock } from '../../ui/markdown' +import { cn } from '../../../lib' + +const messageClass: Record = { + error: cn('pt-1', 'text-destructive'), + warning: cn('pt-1', 'text-warning'), + info: cn('pt-1'), +} + +type Remover = (id: string) => void + +export const FieldMessageDisplay: FC<{ + message: FieldMessage + onRemove: Remover +}> = forwardRef(({ message, onRemove }, ref: ForwardedRef) => { + // console.log('Displaying message', message) + return ( + onRemove(message.text as string)} + // initial={{ opacity: 0, y: '-50%' }} + // animate={{ opacity: 1, x: 0, y: 0 }} + // exit={{ opacity: 0, x: '+10%' }} + initial={{ opacity: 0, height: 0, x: 50 }} + animate={{ opacity: 1, height: 'auto', x: 0 }} + exit={{ opacity: 0, height: 0 }} + transition={{ duration: 0.2, ease: 'easeInOut' }} + role={`field-${message.type}`} + > + + + ) +}) + +export const FieldMessageList: FC<{ + messages: FieldMessage[] | undefined + onRemove: Remover +}> = ({ messages = [], onRemove }) => ( + + {messages.map(p => ( + + ))} + +) diff --git a/src/components/ui-plus-behavior/input/FieldStatusIndicator.tsx b/src/components/ui-plus-behavior/input/FieldStatusIndicator.tsx new file mode 100644 index 00000000..cf31ae4f --- /dev/null +++ b/src/components/ui-plus-behavior/input/FieldStatusIndicator.tsx @@ -0,0 +1,71 @@ +import { AnimatePresence } from 'framer-motion' +import { FC } from 'react' +import { checkMessagesForProblems, FieldMessage } from './util' +import { InputFieldControls } from './useInputField' +import { MotionDiv } from '../Animations' +import { CircleAlert, CircleCheck, LoaderCircle } from 'lucide-react' + +export const FieldStatusIndicators: FC< + Pick< + InputFieldControls, + 'indicateValidationPending' | 'indicateValidationSuccess' | 'validationPending' | 'isValidated' + > & { messages: FieldMessage[] } +> = props => { + const { indicateValidationPending, indicateValidationSuccess, validationPending, isValidated, messages } = + props + const { hasError, hasWarning } = checkMessagesForProblems(messages) + const hasNoProblems = !hasError && !hasWarning + const showSuccess = isValidated && indicateValidationSuccess && hasNoProblems + const showPending = validationPending && indicateValidationPending + const showError = hasError && !validationPending + + return ( + + {(showPending || showSuccess || showError) && ( + + {showPending && ( + + + + )} + {showSuccess && ( + + + + )} + {showError && ( + + + + )} + + )} + + ) +} diff --git a/src/components/ui-plus-behavior/input/InputField.tsx b/src/components/ui-plus-behavior/input/InputField.tsx new file mode 100644 index 00000000..b423f136 --- /dev/null +++ b/src/components/ui-plus-behavior/input/InputField.tsx @@ -0,0 +1,42 @@ +import { FC } from 'react' +import { InputFieldControls } from './useInputField' +import { BooleanInput } from './BooleanInput' +import { BooleanFieldControls } from './useBoolField' +import { LabelControls } from './useLabel.ts' +import { LabelOutput } from './LabelOutput.tsx' +import { ActionButton } from './ActionButton.tsx' +import { ActionControls } from './useAction.ts' +import { TextInput } from './TextInput.tsx' +import { TextFieldControls } from './useTextField.ts' +import { SelectInput } from './SelectInput.tsx' +import { OneOfFieldControls } from './useOneOfField.ts' +import { DateInput } from './DateInput.tsx' +import { DateFieldControls } from './useDateField.ts' + +export const InputField: FC<{ + controls: Pick, 'type' | 'name' | 'expandHorizontally'> +}> = ({ controls }) => { + switch (controls.type) { + case 'text': + return + // case 'text-array': + // return + case 'boolean': + return + case 'oneOf': + return + case 'label': + return )} /> + case 'date': + return + case 'action': + return )} /> + default: + console.log("Don't know how to edit field type", controls.type) + return ( +
+ Missing {controls.type} field for {controls.name} +
+ ) + } +} diff --git a/src/components/ui-plus-behavior/input/InputFieldGroup.tsx b/src/components/ui-plus-behavior/input/InputFieldGroup.tsx new file mode 100644 index 00000000..2488cacb --- /dev/null +++ b/src/components/ui-plus-behavior/input/InputFieldGroup.tsx @@ -0,0 +1,67 @@ +import { FieldArrayConfiguration, FieldMapConfiguration } from './validation' +import { FC } from 'react' +import { InputField } from './InputField' +import { InputFieldControls } from './useInputField' +import classes from './index.module.css' +import { WithVisibility } from './WithVisibility' +import { cn } from '../../../lib' + +type InputFieldGroupProps = { + /** + * The fields to display + */ + fields: Readonly | Readonly + + /** + * Should stuff be aligned to the right? + * (Default is to the left) + */ + alignRight?: boolean + + /** + * Should we expand to 100% of horizontal space? + * (Defaults to true) + */ + expandHorizontally?: boolean + + /** + * Extra classname to apply to field group + */ + className?: string +} + +export const InputFieldGroup: FC = ({ + fields, + alignRight, + expandHorizontally = true, + className, +}) => { + const realFields = Array.isArray(fields) ? fields : Object.values(fields) + return realFields.some(row => (Array.isArray(row) ? row.some(col => col.visible) : row.visible)) ? ( +
+ {realFields.map((row, index) => + Array.isArray(row) ? ( + controls.visible), + name: `group-${index}`, + containerClassName: cn( + alignRight ? classes.fieldRowRight : classes.fieldRow, + expandHorizontally ? 'w-full' : classes.fieldRowCompact + ), + expandHorizontally, + }} + padding={false} + > + {row.map(field => ( + + ))} + + ) : ( + } /> + ) + )} +
+ ) : undefined +} diff --git a/src/components/ui-plus-behavior/input/LabelOutput.tsx b/src/components/ui-plus-behavior/input/LabelOutput.tsx new file mode 100644 index 00000000..f4396017 --- /dev/null +++ b/src/components/ui-plus-behavior/input/LabelOutput.tsx @@ -0,0 +1,21 @@ +import { LabelControls } from './useLabel.ts' +import { FC } from 'react' + +import { WithVisibility } from './WithVisibility.tsx' +import { WithLabelAndDescription } from './WithLabelAndDescription.tsx' +import { WithValidation } from './WithValidation.tsx' +import { Label } from '../../ui/label.tsx' + +export const LabelOutput: FC> = props => { + const { allMessages, classnames, renderedContent } = props + + return ( + + + + + + + + ) +} diff --git a/src/components/ui-plus-behavior/input/README.md b/src/components/ui-plus-behavior/input/README.md new file mode 100644 index 00000000..4d4be515 --- /dev/null +++ b/src/components/ui-plus-behavior/input/README.md @@ -0,0 +1,167 @@ + +# Input utilities + +This library delivers some utilities for building forms in an easy but powerful +way. + +The basic idea is that you define your input fields using hooks, on the data +layer of your application, independently of the UI. +You can provide as little or as much configuration as you want; everything is +fully customizable, but there are also sensible default values. +The hooks take the configuration for the field as input, will handle the +internal logic and behavior of the field, and will return a control interface +to interact with the data. +When building the UI, we feed this control interface (returned by the hook) +to the UI component, configuring it automatically. +Therefore, the UI and the logic will always behave consistently, according to +the current configuration. +The hooks are different for each data type, but we can use the same common UI +component, which will automatically render the correct UI component for the +provided field (or fields). +The UI components use the widgets in the ui-library (whenever available), +configuring it according to the features and state described by the data +coming from the hooks. + +## Supported data types + +Currently, the following data types are supported: + +- Text +- Boolean +- OneOf (a.k.a. Select) +- Label (this is read-only, for displaying messages) +- Date / time +- Action (this is for buttons and doing stuff) + +## Supported features for individual fields + +### All data types + +- Every field can have a name and a description, which is available on the UI +- Markdown code is supported everywhere + (names, description, error messages, etc.) +- Fields can be dynamically shown or hidden, based on data +- Fields can be dynamically enabled or disabled, based on data. + When it's disabled, we explain the reason in a tooltip. +- Fields can be marked required +- `value` and `setValue()` are provided, to interact with the data +- Configurable default value, `reset()` function to revert +- Full type safety, both for basic types (string, boolean, etc), + also for specific sub-types, automatically detected using generics +- Optional data normalization, based on type +- Validation, using both built-in and user-provided validators +- Automatic displaying of validation state (warning, errors) +- Validation can be triggered by the user calling the `validate()` function + manually on the returned control interface, or by changes (if enabled), + or by applying validation to a group of fields together. +- State tracking for validation. + (`isEmpty`, `isValidated`, `validationPending`, etc.) +- Validators can return error(s) or warning(s). +- Validators can be sync or async +- Long-running validators can provide status updates about progress, + which is also channeled to the UI. +- Clashing and stale validation attempts are handled automatically + +## [Text input][1] + +[1]: https://pr-40.oasis-ui.pages.dev/?path=/docs/ui-plus-behavior-usetextfield-and-textinput--docs + +- Built-in validators for min and max length +- Support for hidden (password) input + +## [Boolean input][2] + +[2]: https://pr-40.oasis-ui.pages.dev/?path=/docs/ui-plus-behavior-usebooleanfield-and-booleaninput--docs + +- Supports rendering as both checkbox and switch + +## [OneOf (a.k.a. Select)][3] + +[3]: https://pr-40.oasis-ui.pages.dev/?path=/docs/ui-plus-behavior-useoneoffield-and-selectinput--docs + +- You need to provide a list of choices +- All choice can have a value, and label and description, can be dynamically + hidden or disabled, and can be rendered using a different classname. +- Choices can be provided as a simple list of strings, or as a list of objects + with detailed configuration, or a mixture of both. +- When configured accordingly, a strict enum type will be generated for `value`. +- Optional placeholder element (for no selection) + +## [Date][4] + +[4]: https://pr-40.oasis-ui.pages.dev/?path=/docs/ui-plus-behavior-usedatefield-and-dateinput--docs + +- Uses React's calendar, fow now +- Built-in validator for min and max date + +## [Labels][5] + +[5]: https://pr-40.oasis-ui.pages.dev/?path=/docs/ui-plus-behavior-uselabel-and-labeloutput--docs + +- These are useful for rendering some data or message as part of a form +- it accepts optional render functions for rendering arbitrary widgets based + on the data. (i.e. status indicators, links, etc.) + +## [Action][6] + +[6]: https://pr-40.oasis-ui.pages.dev/?path=/docs/ui-plus-behavior-useaction-and-actionbutton--docs + +- Actions can be defined by providing a name and a function +- They are rendered as buttons. +- Size, color, style, className can be customized +- Actions can be dynamically disabled based on the current state. When this is + the case, the button will be disabled, and the appropriate explanation will + be provided in a tooltip. +- The provided function can be sync or async +- Pending state will be shown then the action is running +- The functions can provide errors and warnings, which will be displayed + accordingly. +- The functions can also throw exceptions, which will be displayed accordingly. +- The functions can also provide status updates or log messages, which will be + displayed accordingly. +- Actions may require confirmation before execution. This well be handled using + dialogs. +- The control interface returned by the hook also provides a `isPending` flag + and an `execute()` call. + +## [Working with groups of fields][7] + +[7]: https://pr-40.oasis-ui.pages.dev/?path=/docs/ui-plus-behavior-validate-and-inputfieldgroup--docs + +There are multiple ways for building form with multiple fields layouts: + +- [Defining fields individually, and collecting them in an array later][8] + - Define the fields individually, + - Collect them in an array, + - Pass the array to `` for rendering. + - Pass the array to a `validate()` function for triggering validation + on all fields. + - Pass the array to `getFieldValues()` function for getting a map of the + values, where the keys of the map will the names for the field provided as + part of the field definition. There is TypeScript-level type safety here, + but you can also access the value at the individual fields, which is type + safe. +- [Defining fields using hooks in an array][9] + - You can also define the fields directly inside an array, without storing + them individually + - Then you can do rendering, validation, value extraction the same way as + above. + - The benefit of this method is that this results in an even more terse code, + at the cost of type safety +- [Defining fields using hooks in a map][10] + - You can also define fields in a map `{ admin: useBooleanField(...) }`. + - Rendering, validation and value extraction works the same as above. + - When using `getFieldValues()`, the keys of the returned data will be the + same keys you used in the initial map of fields, and they will have the + appropriate type. + +[8]: https://pr-40.oasis-ui.pages.dev/?path=/docs/ui-plus-behavior-validate-and-inputfieldgroup--docs#default-1 +[9]: https://pr-40.oasis-ui.pages.dev/?path=/docs/ui-plus-behavior-validate-and-inputfieldgroup--docs#minimal-array-form +[10]: https://pr-40.oasis-ui.pages.dev/?path=/docs/ui-plus-behavior-validate-and-inputfieldgroup--docs#type-safe-form + +All methods will result in identical behavior for common actions +(validation etc.) + +## Examples + +Please see the Storybook stories for examples. diff --git a/src/components/ui-plus-behavior/input/SelectInput.tsx b/src/components/ui-plus-behavior/input/SelectInput.tsx new file mode 100644 index 00000000..d855be73 --- /dev/null +++ b/src/components/ui-plus-behavior/input/SelectInput.tsx @@ -0,0 +1,66 @@ +import { FC, useState } from 'react' +import { OneOfFieldControls } from './useOneOfField' +import { checkMessagesForProblems, getReasonForDenial, getVerdict } from './util' +import { WithVisibility } from './WithVisibility.tsx' +import { WithLabelAndDescription } from './WithLabelAndDescription' +import { WithValidation } from './WithValidation' +import { WithTooltip } from '../tooltip' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '../../ui/select.tsx' +import { MarkdownBlock } from '../../ui/markdown.tsx' +import { Info } from 'lucide-react' + +export const SelectInput: FC> = props => { + const { choices, allMessages, renderValue, setRenderValue, enabled, whyDisabled } = props + const [isOpen, setIsOpen] = useState(false) + + const { hasError } = checkMessagesForProblems(allMessages.root) + + return ( + + + + + + + + + + ) +} diff --git a/src/components/ui-plus-behavior/input/TextInput.tsx b/src/components/ui-plus-behavior/input/TextInput.tsx new file mode 100644 index 00000000..4d823fa6 --- /dev/null +++ b/src/components/ui-plus-behavior/input/TextInput.tsx @@ -0,0 +1,71 @@ +import React, { FC, KeyboardEventHandler, useCallback } from 'react' +// import classes from './index.module.css' +import { TextFieldControls } from './useTextField' +import { WithValidation } from './WithValidation' +import { WithLabelAndDescription } from './WithLabelAndDescription' +import { WithVisibility } from './WithVisibility' +import { WithTooltip } from '../tooltip' +import { Input } from '../../ui/input' +import { checkMessagesForProblems } from './util' + +export const TextInput: FC = props => { + const { + id, + name, + value, + placeholder, + setValue, + allMessages, + enabled, + whyDisabled, + autoFocus, + onEnter, + inputType, + } = props + const handleChange = useCallback( + (event: React.ChangeEvent) => setValue(event.target.value), + [setValue] + ) + + const handleKeyPress: KeyboardEventHandler = useCallback( + event => { + if (event.key == 'Enter') { + if (onEnter) onEnter() + } + }, + [onEnter] + ) + + const { + // hasWarning, + hasError, + } = checkMessagesForProblems(allMessages.root) + + return ( + + + + + + + + + + ) +} diff --git a/src/components/ui-plus-behavior/input/WithDescription.tsx b/src/components/ui-plus-behavior/input/WithDescription.tsx new file mode 100644 index 00000000..40ebe99f --- /dev/null +++ b/src/components/ui-plus-behavior/input/WithDescription.tsx @@ -0,0 +1,18 @@ +import { FC, PropsWithChildren } from 'react' +import { InputFieldControls } from './useInputField.ts' +import { MarkdownBlock } from '../../ui/markdown.tsx' + +export const WithDescription: FC< + PropsWithChildren<{ field: Pick, 'description'> }> +> = props => { + const { field, children } = props + const { description } = field + return description ? ( + + ) : ( + children + ) +} diff --git a/src/components/ui-plus-behavior/input/WithLabelAndDescription.tsx b/src/components/ui-plus-behavior/input/WithLabelAndDescription.tsx new file mode 100644 index 00000000..785f87f9 --- /dev/null +++ b/src/components/ui-plus-behavior/input/WithLabelAndDescription.tsx @@ -0,0 +1,38 @@ +import { FC, PropsWithChildren } from 'react' +import { InputFieldControls } from './useInputField' +import classes from './index.module.css' +import { MarkdownBlock } from '../../ui/markdown' +import { Label } from '../../ui/label' +import { cn } from '../../../lib' + +export const WithLabelAndDescription: FC< + PropsWithChildren<{ + field: Pick, 'id' | 'label' | 'description' | 'compact'> + }> +> = ({ field: { id, label, description, compact }, children }) => + compact ? ( + description ? ( + + ) : ( + children + ) + ) : !!label || !!description ? ( +
+ {!!label && ( + + )} + {!!description && ( +
+ +
+ )} + {children} +
+ ) : ( + children + ) diff --git a/src/components/ui-plus-behavior/input/WithValidation.tsx b/src/components/ui-plus-behavior/input/WithValidation.tsx new file mode 100644 index 00000000..2c8d32da --- /dev/null +++ b/src/components/ui-plus-behavior/input/WithValidation.tsx @@ -0,0 +1,52 @@ +import { FC, PropsWithChildren, ReactNode } from 'react' +import classes from './index.module.css' +import { InputFieldControls } from './useInputField' +import { checkMessagesForProblems, FieldMessage } from './util' +import { FieldAndValidationMessage } from './FieldAndValidationMessage' +import { FieldStatusIndicators } from './FieldStatusIndicator' +import { cn } from '../../../lib' +import { MarkdownBlock } from '../../ui/markdown.tsx' +import { Label } from '../../ui/label.tsx' + +export const WithValidation: FC< + PropsWithChildren<{ + field: Pick< + InputFieldControls, + | 'indicateValidationPending' + | 'indicateValidationSuccess' + | 'validationPending' + | 'isValidated' + | 'validationStatusMessage' + | 'clearErrorMessage' + | 'compact' + | 'label' + > + fieldClasses?: string[] + messages: FieldMessage[] | undefined + extraWidget?: ReactNode | undefined + }> +> = props => { + const { field, fieldClasses = [], messages = [], children, extraWidget } = props + const { hasWarning, hasError } = checkMessagesForProblems(messages) + const { compact, label } = field + return ( +
+
+ {compact && ( + + )} + {children} + + {extraWidget} +
+ +
+ ) +} diff --git a/src/components/ui-plus-behavior/input/WithVisibility.tsx b/src/components/ui-plus-behavior/input/WithVisibility.tsx new file mode 100644 index 00000000..c88da91a --- /dev/null +++ b/src/components/ui-plus-behavior/input/WithVisibility.tsx @@ -0,0 +1,38 @@ +import { FC, PropsWithChildren } from 'react' +import { AnimatePresence } from 'framer-motion' +import { InputFieldControls } from './useInputField.ts' +import { MotionDiv } from '../Animations' +import { cn } from '../../../lib' + +export const WithVisibility: FC< + PropsWithChildren<{ + field: Pick, 'visible' | 'containerClassName' | 'expandHorizontally' | 'name'> + padding?: boolean + }> +> = props => { + const { field, children, padding = true } = props + const { visible, containerClassName, expandHorizontally } = field + + return ( + + {visible && ( + + {children} + {padding &&
} + + )} + + ) +} diff --git a/src/components/ui-plus-behavior/input/fieldValues.ts b/src/components/ui-plus-behavior/input/fieldValues.ts new file mode 100644 index 00000000..6fb77070 --- /dev/null +++ b/src/components/ui-plus-behavior/input/fieldValues.ts @@ -0,0 +1,34 @@ +import { FieldArrayConfiguration, FieldMapConfiguration } from './validation.ts' +import { decapitalizeFirstLetter, getAsArray } from './util' + +const getFieldArrayValues = (fields: Readonly) => { + // Get a flattened list of fields + const allFields = fields.flatMap(config => getAsArray(config)) + + const results: Record = {} + + for (const field of allFields) { + if (field.type === 'label') continue + results[decapitalizeFirstLetter(field.name)] = field.value + } + return results +} + +function getFieldMapValues(fields: DataForm): { [Key in keyof DataForm]: DataForm[Key]['value'] } { + const results: Record = {} + Object.keys(fields).forEach(key => { + const field = fields[key] + const { type, value } = field + if (type === 'label') return + results[key] = value + }) + return results as { [Key in keyof DataForm]: DataForm[Key]['value'] } +} + +export function getFieldValues(fields: Readonly) : Record + +export function getFieldValues(fields: DataForm): { [Key in keyof DataForm]: DataForm[Key]['value'] } + +export function getFieldValues(fields: DataForm | Readonly) { + return Array.isArray(fields) ? getFieldArrayValues(fields) : getFieldMapValues(fields as DataForm) +} \ No newline at end of file diff --git a/src/components/ui-plus-behavior/input/index.module.css b/src/components/ui-plus-behavior/input/index.module.css new file mode 100644 index 00000000..73002a5d --- /dev/null +++ b/src/components/ui-plus-behavior/input/index.module.css @@ -0,0 +1,115 @@ +.fieldRow, +.fieldRowRight { + + display: flex; + align-content: center; + /*align-items: center;*/ + overflow: hidden; + gap: 32px; + +} + +.fieldRow { + flex-direction: row; +} + +.fieldRowRight { + flex-direction: row-reverse; +} + +.fieldRowCompact { + justify-content: center; +} + +.fieldLabelTag { + width: 100%; + display: flex; + flex-direction: column; + align-items: start; + gap: 4px; +} + +.fieldLabel { + font-style: normal; + font-weight: 700; + font-size: 13px; + line-height: 20px; +} + +.validationMessage { + display: flex; + flex-direction: row; + align-content: center; + align-items: center; + gap: 8px; + padding-top: 4px; +} + +.textValue { + width: 99%; + margin-left: 1px; + + input { + padding: 12px 16px; + height: 48px; + + background: #ffffff; + border: 1px solid #80848e; + border-radius: 8px; + } +} + + +.fieldWithWarning { + input { + border: 1px solid chocolate !important; + } + + button { + border: 1px solid chocolate !important; + } +} + +.textArrayValue { + width: 100%; + + display: flex; + flex-direction: column; + gap: 16px; + + input { + padding: 12px 16px; + height: 48px; + + background: #ffffff; + border: 1px solid #80848e; + border-radius: 8px; + } +} + +.removeIcon { + padding-right: 8px; + padding-left: 8px; + cursor: pointer; +} + +.addIcon { + color: #130fff; + cursor: pointer; +} + +.addIconDisabled { + color: gray; + cursor: not-allowed; +} + +.pointer { + cursor: pointer; +} + +.fieldGroup { + display: flex; + flex-direction: column; + gap: 0; + overflow-x: hidden; +} diff --git a/src/components/ui-plus-behavior/input/index.tsx b/src/components/ui-plus-behavior/input/index.tsx new file mode 100644 index 00000000..0a078753 --- /dev/null +++ b/src/components/ui-plus-behavior/input/index.tsx @@ -0,0 +1,20 @@ +// export * from './useInputField' +export * from './useTextField' +// export * from './useTextArrayField' +export * from './useOneOfField' +// export * from './validation' +export * from './BooleanInput' +export * from './TextInput' +// export * from './TextArrayInput' +export * from './SelectInput' +export * from './util' +export * from './useBoolField' +export * from './useDateField' +// export * from './InputField' +export * from './InputFieldGroup' +export * from './useLabel' +export * from './LabelOutput.tsx' +export * from './useAction' +export * from './ActionButton' +export * from './ExecutionContext' +export * from './DateInput.tsx' diff --git a/src/components/ui-plus-behavior/input/useAction.ts b/src/components/ui-plus-behavior/input/useAction.ts new file mode 100644 index 00000000..4c8cb650 --- /dev/null +++ b/src/components/ui-plus-behavior/input/useAction.ts @@ -0,0 +1,167 @@ +import { InputFieldControls, InputFieldProps, noType, useInputFieldInternal } from './useInputField.ts' +import { useState } from 'react' +import { FieldLike } from './validation' +import { MarkdownCode } from '../../ui/markdown' +import { ExecutionContext } from './ExecutionContext' +import { Button } from '../../ui/button.tsx' +import { camelToTitleCase } from './util' + +export type FullConfirmationRequest = { + title: MarkdownCode + description: MarkdownCode + okLabel: MarkdownCode + cancelLabel: MarkdownCode +} & Partial[0], 'variant'>> + +type ConfirmationRequest = boolean | MarkdownCode | Partial + +export type ActionProps = Omit< + InputFieldProps, + | 'compact' + | 'placeholder' + | 'initialValue' + | 'cleanUp' + | 'required' + | 'validatorsGenerator' + | 'validators' + | 'validateOnChange' + | 'showValidationPending' + | 'showValidationSuccess' + | 'onValueChange' +> & + Partial[0], 'variant' | 'size' | 'color'>> & { + className?: string + pendingLabel?: MarkdownCode + confirmationNeeded?: ConfirmationRequest + action: (context: ExecutionContext) => ReturnData + } + +export type ActionControls = FieldLike & + Omit< + InputFieldControls, + 'value' | 'setValue' | 'reset' | 'hasProblems' | 'validate' | 'validatorProgress' + > & + Pick & { + isPending: boolean + confirmationNeeded: FullConfirmationRequest | undefined + onConfirmationProvided: () => void + onConfirmationDenied: () => void + + execute: () => Promise + } + +export function useAction(props: ActionProps): ActionControls { + const { color, variant, size, action, pendingLabel, name, label, confirmationNeeded } = props + const controls = useInputFieldInternal( + 'action', + { ...props, initialValue: undefined, showValidationPending: false }, + noType + ) + + const [isPending, setIsPending] = useState(false) + const [statusMessage, setStatusMessage] = useState() + const [isConfirming, setIsConfirming] = useState(false) + const [pendingExecution, setPendingExecution] = useState<{ + resolve: (value: ReturnType | PromiseLike) => void + reject: (reason?: unknown) => void + }>() + + const getFullConfirmationRequest = (): FullConfirmationRequest | undefined => { + if (confirmationNeeded === undefined || confirmationNeeded === false) return undefined + const defaultRequest: FullConfirmationRequest = { + title: label ?? camelToTitleCase(name), + description: 'Are you sure?', + okLabel: 'Continue', + cancelLabel: 'Cancel', + } + if (confirmationNeeded === true) return defaultRequest + + if (typeof confirmationNeeded === 'object') { + return { + ...defaultRequest, + ...confirmationNeeded, + } + } else { + return { + ...defaultRequest, + description: confirmationNeeded as MarkdownCode, + } + } + } + + const doExecute = async (): Promise => { + setIsPending(true) + const context: ExecutionContext = { + setStatus: message => { + setStatusMessage(message) + }, + log: (message, ...optionalParams) => + controls.addMessage({ + text: [message, ...optionalParams].join(' '), + type: 'info', + location: 'root', + }), + warn: (message, ...optionalParams) => + controls.addMessage({ + text: [message, ...optionalParams].join(' '), + type: 'warning', + location: 'root', + }), + error: (message, ...optionalParams) => + controls.addMessage({ + text: [message, ...optionalParams].join(' '), + type: 'error', + location: 'root', + }), + } + try { + controls.clearAllMessages('Execute action') + return await action(context) + } catch (error: unknown) { + context.error(error) + throw error + } finally { + setIsPending(false) + } + } + + const execute = async (): Promise => { + if (isPending) { + throw new Error(`Action ${props.name} is already running!`) + } + if (confirmationNeeded) { + setIsConfirming(true) + return new Promise((resolve, reject) => { + setPendingExecution({ resolve, reject }) + }) + } else { + return await doExecute() + } + } + + const onConfirmationProvided = async () => { + setIsConfirming(false) + const result = await doExecute() + pendingExecution?.resolve(result) + } + + const onConfirmationDenied = async () => { + setIsConfirming(false) + pendingExecution?.reject('User cancelled the action') + } + + return { + ...controls, + color, + variant, + size, + label: isPending ? (pendingLabel ?? label ?? camelToTitleCase(name)) : (label ?? camelToTitleCase(name)), + isPending, + validationPending: isPending, + validationStatusMessage: statusMessage, + execute, + confirmationNeeded: isConfirming ? getFullConfirmationRequest() : undefined, + onConfirmationProvided, + onConfirmationDenied, + } +} diff --git a/src/components/ui-plus-behavior/input/useBoolField.ts b/src/components/ui-plus-behavior/input/useBoolField.ts new file mode 100644 index 00000000..f381b6c6 --- /dev/null +++ b/src/components/ui-plus-behavior/input/useBoolField.ts @@ -0,0 +1,41 @@ +import { InputFieldControls, InputFieldProps, useInputField } from './useInputField.ts' +import { MarkdownCode } from '../../ui/markdown.tsx' + +export type FullBoolFieldProps = Omit< + InputFieldProps, + 'initialValue' | 'placeholder' | 'compact' +> & { + initialValue?: boolean + preferredWidget?: 'checkbox' | 'switch' +} + +export type BooleanFieldControls = InputFieldControls & + Pick & {} + +export type BoolFieldProps = FullBoolFieldProps | string + +export function useBooleanField(name: string, description?: MarkdownCode): BooleanFieldControls + +export function useBooleanField(props: FullBoolFieldProps): BooleanFieldControls + +export function useBooleanField(rawProps: BoolFieldProps, description?: MarkdownCode): BooleanFieldControls { + const props = typeof rawProps === 'string' ? { name: rawProps, description } : rawProps + const { initialValue = false, preferredWidget = 'checkbox' } = props + + const controls = useInputField( + 'boolean', + { + ...props, + initialValue, + }, + { + isEmpty: () => false, + isEqual: (a, b) => a === b, + } + ) + + return { + ...controls, + preferredWidget, + } +} diff --git a/src/components/ui-plus-behavior/input/useDateField.ts b/src/components/ui-plus-behavior/input/useDateField.ts new file mode 100644 index 00000000..7e8970e9 --- /dev/null +++ b/src/components/ui-plus-behavior/input/useDateField.ts @@ -0,0 +1,93 @@ +import { InputFieldControls, InputFieldProps, useInputField } from './useInputField' +import { CoupledData, DateMessageTemplate, expandCoupledData, getAsArray, getDateMessage } from './util' + +type DateType = 'datetime-local' | 'data' | 'time' + +type DateFieldProps = Omit, 'initialValue' | 'placeholder'> & { + /** + * What time of input do we want here? + */ + type?: DateType + + /** + * Initial date + * + * If not set, we will use "now" + */ + initialValue?: Date + + /** + * Minimum date + * + * You can specify this as a Date, or as an array, + * the date first and then the error message, + * which you can provide as a string, or as a function that + * returns a string, including the specified minimum date. + */ + minDate?: CoupledData + + /** + * Maximum date + * + * You can specify this as a Date, or as an array, + * the Date first and then the error message, + * which you can provide as a string, or as a function that + * returns a string, including the specified maximum date. + */ + maxDate?: CoupledData +} + +export type DateFieldControls = Omit, 'placeholder'> & { + minDate: Date | undefined + maxDate: Date | undefined + type: DateType +} + +export function useDateField(props: DateFieldProps): DateFieldControls { + const { initialValue = new Date(), validators, type = 'datetime-local' } = props + + const [minDate, tooEarlyMessage] = expandCoupledData(props.minDate, [ + undefined, + minDate => `Please use a date after ${minDate.toLocaleString()}`, + ]) + + const [maxDate, tooLateMessage] = expandCoupledData(props.maxDate, [ + undefined, + maxDate => `Please use a date before ${maxDate.toLocaleString()}`, + ]) + + const controls = useInputField( + 'date', + { + ...props, + initialValue, + validators: [ + // Check minimum date if configured + value => + !!minDate && value.getTime() < minDate.getTime() + ? getDateMessage(tooEarlyMessage, minDate) + : undefined, + + // Check maximum date if configured + value => + !!maxDate && value.getTime() > maxDate.getTime() + ? getDateMessage(tooLateMessage, maxDate) + : undefined, + + // Any custom validators + ...getAsArray(validators), + ], + }, + { + isEmpty: value => value === undefined, + isEqual: (a, b) => !!a && !!b && a.getTime() === b.getTime(), + } + ) + + return { + ...controls, + minDate, + maxDate, + type, + } +} diff --git a/src/components/ui-plus-behavior/input/useInputField.ts b/src/components/ui-plus-behavior/input/useInputField.ts new file mode 100644 index 00000000..7c361635 --- /dev/null +++ b/src/components/ui-plus-behavior/input/useInputField.ts @@ -0,0 +1,609 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { + AllMessages, + ValidatorControls, + CoupledData, + Decision, + expandCoupledData, + getAsArray, + getReason, + getVerdict, + invertDecision, + MessageAtLocation, + SingleOrArray, + ValidatorFunction, + wrapValidatorOutput, + checkMessagesForProblems, + camelToTitleCase, + MessageMaybeAtLocation, + SimpleValidatorOutput, +} from './util' +import { MarkdownCode } from '../../ui/markdown' + +const VALIDATION_DEBUG_MODE = false + +export type ValidatorBundle = SingleOrArray> + +/** + * Data type for describing a field + */ +export type InputFieldProps = { + /** + * The name of this field. Use camelCase. + * + * User for automatically generated labels, and also for debugging. + */ + name: string + + /** + * Optional description of this field. + */ + description?: MarkdownCode + + /** + * Optional label to use for this field. + */ + label?: MarkdownCode + + /** + * Do we normally want to have the label on the same line as the value? + */ + compact?: boolean + + placeholder?: string + initialValue: DataType + + /** + * Optional function to normalize the value + */ + cleanUp?: (value: DataType) => DataType + + /** + * Is this field required? + * + * Optionally, you can also specify the corresponding error message. + */ + required?: CoupledData + + /** + * Validators to apply to values + */ + validators?: ValidatorBundle + + /** + * Should this field be shown? + * + * Default is true. + * + * You can also use the "hidden" field for the same effect, + * just avoid contradictory values. + */ + visible?: boolean + + /** + * Should this field be hidden? + * + * Default is false. + * + * You can also use the "visible" field for the same effect, + * just avoid contradictory values. + */ + hidden?: boolean + + /** + * Is this field enabled, that is, editable? + * + * Default is true. + * + * Optionally, you can also specify why it's currently disabled. + * + * You can also use the "disabled" field for the same effect, + * just avoid contradictory values. + */ + enabled?: Decision + + /** + * Is this field disabled, that is, read only? + * + * Default is false. + * + * Optionally, you can also specify why is it disabled. + * + * You can also use the "enabled" field for the same effect, + * just avoid contradictory values. + */ + disabled?: Decision + + /** + * Extra classes to apply to the container div + */ + containerClassName?: string + + /** + * Should this field expand horizontally to take all available space? + * + * (Defaults to true) + */ + expandHorizontally?: boolean + + /** + * Should this field be validated after every change? + * + * Default is false. + */ + validateOnChange?: boolean + + /** + * Should field be validated value on change even if it is empty? + * + * Default is false + */ + validateEmptyOnChange?: boolean + + /** + * Should we indicate when validation is running? + * + * Default is true. + */ + showValidationPending?: boolean + + /** + * Besides errors, should we also indicate successful validation status? + * + * Default is false. + */ + showValidationSuccess?: boolean + + /** + * Effects to run after the value changed + */ + onValueChange?: (value: DataType, isStillFresh: () => boolean) => void +} + +export type ValidationReason = 'change' | 'submit' +export type ValidationParams = { + forceChange?: boolean + reason: ValidationReason + // A way to check if this validation request is still valid, or is it now stale (because of changed value) + isStillFresh?: () => boolean +} + +/** + * Data type passed from the field controller to the field UI widget + */ +export type InputFieldControls = Pick< + InputFieldProps, + 'label' | 'compact' | 'description' | 'placeholder' | 'name' +> & { + id: string + type: string + visible: boolean + enabled: boolean + whyDisabled?: MarkdownCode + containerClassName?: string + expandHorizontally: boolean + value: DataType + cleanValue: DataType + isEmpty: boolean + setValue: (value: DataType) => void + reset: () => void + allMessages: AllMessages + hasProblems: boolean + isValidated: boolean + + /** + * Run validation on this field. + * + * Will update field messages. + * Returns true if there is an error. + */ + validate: (params: ValidationParams) => Promise + validationPending: boolean + validationStatusMessage: MarkdownCode | undefined + validatorProgress: number | undefined + indicateValidationPending: boolean + indicateValidationSuccess: boolean + clearErrorMessage: (id: string) => void + clearMessagesAt: (location: string) => void + clearAllMessages: (reason: string) => void +} + +export type InputFieldControlsInternal = InputFieldControls & { + /** + * This is for internal use only. Don't use from an application. + */ + addMessage: (message: MessageAtLocation) => void +} + +export type IsEqualFunction = (data1: DataType, data2: DataType) => boolean +export type DataTypeTools = { + isEmpty: (data: DataType) => boolean + isEqual: IsEqualFunction +} + +export const noType: DataTypeTools = { + isEmpty: () => true, + isEqual: () => true, +} + +const calculateVisible = ( + controls: Pick, 'name' | 'hidden' | 'visible'> +): boolean => { + const { name, hidden, visible } = controls + if (visible === undefined) { + if (hidden === undefined) { + return true + } else { + return !hidden + } + } else { + if (hidden === undefined) { + return visible + } else { + if (visible !== hidden) { + return visible + } else { + throw new Error( + `On field ${name}, props "hidden" and "visible" have been set to contradictory values!` + ) + } + } + } +} + +export const calculateEnabled = ( + controls: Pick, 'name' | 'enabled' | 'disabled'> +): Decision => { + const { name, enabled, disabled } = controls + if (enabled === undefined) { + if (disabled === undefined) { + return true + } else { + return invertDecision(disabled) + } + } else { + if (disabled === undefined) { + return enabled + } else { + if (getVerdict(enabled, false) !== getVerdict(disabled, false)) { + return { + verdict: getVerdict(enabled, false), + reason: getReason(disabled) ?? getReason(enabled), + } + } else { + throw new Error( + `On field ${name}, props "enabled" and "disabled" have been set to contradictory values!` + ) + } + } + } +} + +const noValidators: ValidatorBundle = [] + +export function useInputFieldInternal( + type: string, + props: InputFieldProps, + dataTypeControl: DataTypeTools +): InputFieldControlsInternal { + const { + name, + label, + compact, + placeholder, + description, + initialValue, + cleanUp, + validators = noValidators, + containerClassName, + expandHorizontally = true, + validateOnChange = false, + validateEmptyOnChange = false, + showValidationPending = true, + showValidationSuccess = false, + onValueChange, + } = props + const id = useId() + const debugLog = useCallback( + (message: unknown, ...more: unknown[]) => { + if (!VALIDATION_DEBUG_MODE) return + console.log(`[${id}] "${name}"`, message, ...more) + }, + [id, name] + ) + + const [required, requiredMessage] = expandCoupledData(props.required, [false, 'This field is required']) + + const [value, setValue] = useState(initialValue) + const cleanValue = cleanUp ? cleanUp(value) : value + const [messages, setMessages] = useState([]) + + const addMessage = useCallback( + (message: MessageAtLocation) => { + debugLog('Updating messages in addMessage') + setMessages(messages => [...messages, message]) + }, + [debugLog] + ) + + const allMessages = useMemo(() => { + const messageTree: AllMessages = {} + messages.forEach(message => { + const { location } = message + let bucket = messageTree[location] + if (!bucket) bucket = messageTree[location] = [] + const localMessage: MessageMaybeAtLocation = { ...message } + delete localMessage.location + bucket.push(localMessage) + }) + return messageTree + }, [messages]) + const hasProblems = Object.keys(allMessages).some( + key => checkMessagesForProblems(allMessages[key]).hasError + ) + const validationCounterRef = useRef(0) + const isValidatedRef = useRef(false) + const [isValidated, setIsValidated] = useState(false) + const lastValidatedValueRef = useRef(undefined) + const lastSeenValueRef = useRef(undefined) + const validationPendingRef = useRef() + const [validationPending, setValidationPending] = useState(false) + + const { isEmpty: isValueEmpty, isEqual: isEqualRaw } = dataTypeControl + + const isValueEqual: IsEqualFunction = useCallback( + (a, b) => + (a === undefined && b === undefined) || (a !== undefined && b !== undefined && isEqualRaw(a, b)), + [isEqualRaw] + ) + + const visible = calculateVisible(props) + const enabled = calculateEnabled(props) + + const isEnabled = getVerdict(enabled, true) + + const [validatorProgress, setValidatorProgress] = useState() + const [validationStatusMessage, setValidationStatusMessage] = useState() + + const isEmpty = isValueEmpty(cleanValue) + + const clearErrorMessage = useCallback( + (message: string) => { + debugLog(`Updating messages in clearErrorMessage('${message}')`) + setMessages(messages => messages.filter(p => p.text !== message || p.type === 'info')) + isValidatedRef.current = false + setIsValidated(false) + }, + [debugLog] + ) + + const clearMessagesAt = useCallback( + (location: string) => { + debugLog(`Updating messages in clearMessagesAt('${location}')`) + setMessages(messages => messages.filter(p => p.location !== location)) + isValidatedRef.current = false + setIsValidated(false) + }, + [debugLog] + ) + + const clearAllMessages = useCallback( + (reason: string) => { + debugLog('Updating messages in clearAllMessages()', reason) + setMessages([]) + isValidatedRef.current = false + setIsValidated(false) + }, + [debugLog] + ) + + const validate = useCallback( + async (valueToValidate: DataType, params: ValidationParams): Promise => { + if (!visible) { + // We don't care about hidden fields + return false + } + const { forceChange = false, reason, isStillFresh: clientStillInterested } = params + const wasOK = isValidatedRef.current && !hasProblems + + const validationSessionId = ++validationCounterRef.current + debugLog('Validating', valueToValidate, 'because', reason, `==> validation #${validationSessionId}`) + + const isStillFresh = () => + (!clientStillInterested || clientStillInterested()) && + validationPendingRef.current === validationSessionId + + const cleanValue = cleanUp ? cleanUp(valueToValidate) : valueToValidate + const isEmpty = isValueEmpty(cleanValue) + validationPendingRef.current = validationSessionId + setValidationPending(true) + isValidatedRef.current = false + setIsValidated(false) + setValidationStatusMessage(undefined) + setValidatorProgress(undefined) + + // Clean up the value + const different = !isValueEqual(cleanValue, valueToValidate) + if (different && reason !== 'change') { + setValue(cleanValue) + } + + // Let's start to collect the new messages + const currentMessages: MessageAtLocation[] = [] + let hasError = false + + // If it's required but empty, that's already an error + if (required && isEmpty && (reason !== 'change' || validateEmptyOnChange)) { + currentMessages.push(wrapValidatorOutput(requiredMessage, 'root', 'error')!) + hasError = true + } + + const validatorControls: Pick = { + updateStatus: props => { + if (isStillFresh && !isStillFresh()) { + // This session is obsolete, ignore any output + // debugLog('Ignoring status update from obsolete session') + return + } + if (typeof props === 'string') { + setValidationStatusMessage(props) + } else { + const { progress, message } = props + if (progress) setValidatorProgress(progress) + if (message) setValidationStatusMessage(message) + } + }, + } + + const validatorsToUse = [...getAsArray(validators)] + + for (let i = 0; i < validatorsToUse.length; i++) { + const validator = validatorsToUse[i] + if (!validator) continue + try { + const validatorReport = + hasError || + (isStillFresh && !isStillFresh()) || + (!forceChange && wasOK && isValueEqual(lastValidatedValueRef.current, cleanValue)) + ? [] // If we already have an error, don't even bother with any more validators + : await validator(cleanValue, { ...validatorControls, isStillFresh }, params.reason) // Execute the current validators + + // Maybe we have a single report, maybe an array. Receive it as an array. + const reports = getAsArray(validatorReport) + + // Handle the recursive reports + reports + .filter((report): report is ValidatorFunction => typeof report === 'function') + .forEach(validator => validatorsToUse.push(validator)) + + // Handle the simple reports + reports + .filter((report): report is SimpleValidatorOutput => typeof report !== 'function') + .map(report => wrapValidatorOutput(report, 'root', 'error')) // Wrap single strings to proper reports + .forEach(message => { + // Go through all the reports + if (!message) return + if (message.type === 'error') hasError = true + currentMessages.push(message) + }) + } catch (validatorError) { + console.log('Error while running validator', validatorError) + currentMessages.push( + wrapValidatorOutput(`Error while checking: ${validatorError}`, 'root', 'error')! + ) + } + } + + if (!isStillFresh || isStillFresh()) { + debugLog(`Updating messages after finished validation #${validationSessionId}`, currentMessages) + debugLog('isStillFresh', isStillFresh) + setMessages(currentMessages) + validationPendingRef.current = undefined + setValidationPending(false) + isValidatedRef.current = true + setIsValidated(true) + lastValidatedValueRef.current = cleanValue + + // Do we have any actual errors? + return currentMessages.some(message => message.type === 'error') + } else { + debugLog(`Validation #${validationSessionId} cancelled`) + return false + } + }, + [ + debugLog, + hasProblems, + cleanUp, + isValueEmpty, + isValueEqual, + required, + requiredMessage, + validateEmptyOnChange, + validators, + visible, + ] + ) + + const cleanValueString = JSON.stringify(cleanValue) + + // Sometimes, when the value changes, we are supposed to validate + useEffect(() => { + if (isValueEqual(value, lastSeenValueRef.current)) return + debugLog('Change', lastSeenValueRef.current, '=>', value) + lastSeenValueRef.current = value + const isStillFresh = () => isValueEqual(lastSeenValueRef.current, value) + if (onValueChange) { + onValueChange(value, isStillFresh) + } + if (visible) { + if (validateOnChange && (!isEmpty || validateEmptyOnChange)) { + // Yes, we are supposed to validate + void validate(value, { reason: 'change', isStillFresh }) + } else { + // No need to validate, but we still want to clear out any error messages, + // because they are no longer relevant to the new value + clearAllMessages('value change effect') + isValidatedRef.current = false + setValidationPending(false) + validationPendingRef.current = undefined + setIsValidated(false) + } + } + }, [ + visible, + cleanValueString, + validateOnChange, + validateEmptyOnChange, + isEmpty, + isValueEqual, + clearAllMessages, + onValueChange, + validate, + value, + debugLog, + ]) + + const reset = () => setValue(initialValue) + + return { + id, + type, + name, + description, + label: label ?? camelToTitleCase(name), + compact, + placeholder, + value, + cleanValue, + isEmpty, + setValue, + reset, + allMessages, + hasProblems, + isValidated, + clearErrorMessage, + clearMessagesAt, + clearAllMessages, + indicateValidationSuccess: showValidationSuccess, + indicateValidationPending: showValidationPending, + validate: params => validate(value, params), + validationPending: showValidationPending && validationPending, + validationStatusMessage, + validatorProgress, + visible, + enabled: isEnabled, + whyDisabled: isEnabled ? undefined : getReason(enabled), + containerClassName, + expandHorizontally, + addMessage, + } +} + +export function useInputField( + type: string, + props: InputFieldProps, + dataTypeControl: DataTypeTools +): InputFieldControls { + return useInputFieldInternal(type, props, dataTypeControl) +} diff --git a/src/components/ui-plus-behavior/input/useLabel.ts b/src/components/ui-plus-behavior/input/useLabel.ts new file mode 100644 index 00000000..6c3ec221 --- /dev/null +++ b/src/components/ui-plus-behavior/input/useLabel.ts @@ -0,0 +1,105 @@ +import { InputFieldControls, InputFieldProps, useInputField } from './useInputField.ts' +import { getAsArray, SingleOrArray } from './util' +import { ReactNode, useId } from 'react' +import { renderMarkdown, TagName, MarkdownCode } from '../../ui/markdown.tsx' +import { HasToString } from './useOneOfField.ts' + +export type RendererFunction = (value: DataType, tagName: string) => ReactNode + +export type LabelProps = Pick< + InputFieldProps, + | 'name' + | 'label' + | 'compact' + | 'description' + | 'visible' + | 'hidden' + | 'containerClassName' + | 'expandHorizontally' + | 'validators' + | 'validateOnChange' + | 'showValidationSuccess' +> & { + /** + * Which HTML tag should contain this label? + * + * The default is + */ + tagName?: TagName + + /** + * What extra classes should we apply to the field's div? + */ + classnames?: SingleOrArray + + /** + * Optional render function to use to get the HTML content from the (formatted) string. + * + * My default, we render as MarkDown. If markdown rendering is not appropriate + * (for example. you want images) please provide a render function. + */ + renderer?: RendererFunction + + /** + * The current value to display + */ + value: DataType +} + +export type LabelControls = Omit< + InputFieldControls, + 'placeholder' | 'enabled' | 'whyDisabled' | 'setValue' | 'initialValue' | 'reset' +> & { + classnames: string[] + renderedContent: ReactNode +} + +export function useLabel(text: MarkdownCode): LabelControls + +export function useLabel( + props: LabelProps +): LabelControls + +export function useLabel( + rawProps: LabelProps | string +): LabelControls { + const autoId = useId() + const props = ( + typeof rawProps === 'string' ? { name: autoId, value: rawProps } : rawProps + ) as LabelProps + const { + classnames = [], + // formatter, + tagName = 'span', + label = '', + value, + } = props + const { renderer = (value, tagName: TagName) => renderMarkdown(value.toString(), tagName) } = props + + const controls = useInputField( + 'label', + { + ...props, + initialValue: value, + label, + }, + { + isEmpty: value => !value, + isEqual: (a, b) => a === b, + } + ) + + const renderedContent = renderer(value, tagName) + const visible = controls.visible && value.toString() !== '' + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { placeholder, enabled, whyDisabled, setValue, reset, ...otherControls } = controls + + return { + ...otherControls, + value, + classnames: getAsArray(classnames), + renderedContent, + visible, + } +} diff --git a/src/components/ui-plus-behavior/input/useOneOfField.ts b/src/components/ui-plus-behavior/input/useOneOfField.ts new file mode 100644 index 00000000..0eb72fcd --- /dev/null +++ b/src/components/ui-plus-behavior/input/useOneOfField.ts @@ -0,0 +1,221 @@ +import { + calculateEnabled, + DataTypeTools, + InputFieldControls, + InputFieldProps, + IsEqualFunction, + useInputField, + ValidatorBundle, +} from './useInputField' +import { andDecisions, camelToTitleCase, Decision, deny, expandCoupledData, getVerdict } from './util' +import { MarkdownCode } from '../../ui/markdown.tsx' + +export interface HasToString { + toString: () => string +} + +export type Choice = { + value: DataType + label?: MarkdownCode + description?: string + enabled?: Decision + hidden?: boolean + className?: string +} + +type OneOfFieldProps = Omit< + InputFieldProps, + 'initialValue' | 'placeholder' | 'cleanUp' | 'validateEmptyOnChange' +> & { + initialValue?: DataType + readonly choices: readonly (Choice | DataType)[] + hideDisabledChoices?: boolean + disableIfOnlyOneVisibleChoice?: boolean +} + +export type NonNullableOneOfFieldProps = OneOfFieldProps & {} + +function expandChoice(choice: DataType | Choice): Choice { + if (typeof choice === 'object') { + const fullChoice = choice as Choice + return { + ...fullChoice, + label: fullChoice.label ?? camelToTitleCase(fullChoice.value.toString().trim()), + } + } else { + return { + value: choice as DataType, + label: camelToTitleCase((choice as DataType).toString().trim()), + } + } +} + +export type OneOfFieldControls = InputFieldControls & { + choices: readonly Choice[] + renderValue: string + setRenderValue: (value: string) => void +} + +const simpleTypeTools: DataTypeTools = { + isEmpty: () => false, + isEqual: (a, b) => a === b, +} + +/** + * Hook for defining a nun-nullable OneOf field + */ +export function useNonNullableOneOfField( + props: NonNullableOneOfFieldProps, + typeTools: DataTypeTools = simpleTypeTools +): OneOfFieldControls { + const { choices, hideDisabledChoices, disableIfOnlyOneVisibleChoice } = props + const visibleChoices = choices + .map(expandChoice) + .filter(choice => !choice.hidden && (!hideDisabledChoices || getVerdict(choice.enabled, true))) + const availableChoices = visibleChoices.filter( + choice => choice.value.toString() === PLEASE_SELECT || getVerdict(choice.enabled, true) + ) + const initialValue = props.initialValue ?? (availableChoices[0] ?? visibleChoices[0]).value + + const originallyEnabled = calculateEnabled(props) + const canSeeAlternatives = + disableIfOnlyOneVisibleChoice && + visibleChoices.length <= 1 && + visibleChoices[0]?.value.toString() !== PLEASE_SELECT + ? deny('Currently no other choice is available.') + : true + + const controls = useInputField( + 'oneOf', + { + ...props, + enabled: andDecisions(originallyEnabled, canSeeAlternatives), + disabled: undefined, + initialValue, + // required: [true, requiredMessage], + validateEmptyOnChange: true, + cleanUp: v => v, + }, + typeTools + ) + + return { + ...controls, + renderValue: controls.value.toString(), + setRenderValue: value => controls.setValue(value as unknown as DataType), + choices: visibleChoices as unknown as Choice[], + } +} + +export type NullableOneOfFieldProps = OneOfFieldProps & { + placeholder: string | boolean + required?: boolean + canSelectPlaceholder?: boolean +} + +const PLEASE_SELECT = '__PLEASE_SELECT__' + +type InternalDataType = DataType | typeof PLEASE_SELECT + +/** + * Hook for defining a nullable OneOf field + */ +export function useNullableOneOfField( + props: NullableOneOfFieldProps, + typeTools: DataTypeTools = simpleTypeTools +): OneOfFieldControls { + const { + placeholder, + canSelectPlaceholder = true, + validators, + onValueChange, + choices: realChoices, + ...rest + } = props + + const toInternal = (value: DataType | undefined): InternalDataType => + value === undefined ? PLEASE_SELECT : value + const toExternal = (value: InternalDataType): DataType | undefined => + value === PLEASE_SELECT ? undefined : value + + const choices: Choice>[] = [ + { + value: PLEASE_SELECT, + label: placeholder === true ? 'Please select!' : (placeholder as MarkdownCode), + enabled: canSelectPlaceholder, + className: 'text-muted-foreground', + }, + ...(realChoices as Choice>[]), + ] + + const { isEmpty, isEqual } = typeTools + + const controls = useNonNullableOneOfField>( + { + ...rest, + choices, + onValueChange: (value, isStillFresh) => { + if (onValueChange) { + onValueChange(toExternal(value), isStillFresh) + } + }, + required: expandCoupledData(props.required, [false, 'Please select an option!']), + validators: validators as ValidatorBundle>, + }, + { + isEmpty: v => v === PLEASE_SELECT || isEmpty(v as DataType), + isEqual: isEqual as IsEqualFunction>, + } + ) + + return { + ...controls, + renderValue: controls.value.toString(), + value: toExternal(controls.value), + cleanValue: toExternal(controls.cleanValue), + setValue: (value: DataType | undefined) => controls.setValue(toInternal(value)), + } +} + +// Signature for the nullable use case +export function useOneOfField( + props: NullableOneOfFieldProps +): OneOfFieldControls + +// Signature for the non-nullable use case +export function useOneOfField( + props: NonNullableOneOfFieldProps +): OneOfFieldControls + +export function useOneOfField( + name: string, + choices: readonly (Choice | DataType)[] +): OneOfFieldControls + +export function useOneOfField( + name: string, + choices: readonly (Choice | DataType)[], + placeholder: string +): OneOfFieldControls + +// Common implementation +export function useOneOfField( + props: NonNullableOneOfFieldProps | NullableOneOfFieldProps | string, + choices?: readonly (Choice | DataType)[], + placeholder?: string +) { + return typeof props === 'string' + ? placeholder + ? // eslint-disable-next-line react-hooks/rules-of-hooks + useNullableOneOfField({ name: props, choices: choices!, placeholder }) + : // eslint-disable-next-line react-hooks/rules-of-hooks + useNonNullableOneOfField({ + name: props, + choices: choices!, + }) + : 'placeholder' in props + ? // eslint-disable-next-line react-hooks/rules-of-hooks + useNullableOneOfField(props as NullableOneOfFieldProps) + : // eslint-disable-next-line react-hooks/rules-of-hooks + useNonNullableOneOfField(props as NonNullableOneOfFieldProps) +} diff --git a/src/components/ui-plus-behavior/input/useTextField.ts b/src/components/ui-plus-behavior/input/useTextField.ts new file mode 100644 index 00000000..600f9c83 --- /dev/null +++ b/src/components/ui-plus-behavior/input/useTextField.ts @@ -0,0 +1,116 @@ +import { InputFieldControls, InputFieldProps, useInputField } from './useInputField' +import { CoupledData, expandCoupledData, getAsArray, getNumberMessage, NumberMessageTemplate } from './util' +import { useCallback } from 'react' +import { MarkdownCode } from '../../ui/markdown.tsx' + +export type TextFieldProps = Omit, 'initialValue'> & { + initialValue?: string + + /** + * Minimum length of text + * + * You can specify this as a number, or as an array, + * the number first and then the error message, + * which you can provide a string, or a function that + * returns a string, including the specified minimum length. + * + * Examples: + * 5 + * [5, "This is too short"] + * [10, l => `Please use at least %{l} characters!`] + */ + minLength?: CoupledData + + /** + * Maximum length of each item + * + * You can specify this as a number, or as an array, + * the number first and then the error message, + * which you can provide a string, or a function that + * returns a string, including the specified maximum length. + * + * Examples: + * 100 + * [40, "This is too long"] + * [50, l => `Please use at most %{l} characters!`]* + */ + maxLength?: CoupledData + + autoFocus?: boolean + onEnter?: (value: string) => void + hideInput?: boolean +} + +export type TextFieldControls = InputFieldControls & { + autoFocus: boolean + inputType: string + onEnter: (() => void) | undefined +} + +export function useTextField(props: TextFieldProps): TextFieldControls + +export function useTextField( + name: string, + description?: MarkdownCode | undefined, + required?: boolean +): TextFieldControls + +export function useTextField( + rawProps: TextFieldProps | string, + description?: MarkdownCode | undefined, + required?: boolean +): TextFieldControls { + const props = ( + typeof rawProps === 'string' ? { name: rawProps, description, required } : rawProps + ) as TextFieldProps + const { initialValue = '', validators, autoFocus = false, onEnter, hideInput } = props + + const [minLength, tooShortMessage] = expandCoupledData(props.minLength, [ + 1, + minLength => `Please specify at least ${minLength} characters!`, + ]) + + const [maxLength, tooLongMessage] = expandCoupledData(props.maxLength, [ + 1000, + maxLength => `Please specify at most ${maxLength} characters!`, + ]) + + const controls = useInputField( + 'text', + { + ...props, + initialValue, + cleanUp: props.cleanUp ?? ((s: string) => s.trim()), + validators: [ + // Check minimum length, if configured + value => + !!minLength && value !== '' && value.length < minLength! + ? `tooShort: ${getNumberMessage(tooShortMessage, minLength)} (Currently: ${value.length})` + : undefined, + + // Check maximum length, if configured + value => + !!maxLength && value !== '' && value.length > maxLength! + ? `tooLong: ${getNumberMessage(tooLongMessage, maxLength)} (Currently: ${value.length})` + : undefined, + + ...getAsArray(validators), + ], + }, + { + isEmpty: text => !text, + isEqual: (a, b) => a === b, + } + ) + + const enterHandler = useCallback(() => { + if (onEnter) onEnter(controls.value) + }, [controls.value, onEnter]) + + return { + ...controls, + inputType: hideInput ? 'password' : 'text', + autoFocus, + onEnter: enterHandler, + } +} diff --git a/src/components/ui-plus-behavior/input/util/arrays.ts b/src/components/ui-plus-behavior/input/util/arrays.ts new file mode 100644 index 00000000..7cd7685f --- /dev/null +++ b/src/components/ui-plus-behavior/input/util/arrays.ts @@ -0,0 +1,46 @@ +/** + * Flatten a multi-dimensional array + */ +export function flatten(array: Data[][]): Data[] { + const result: Data[] = [] + array.forEach(a => a.forEach(i => result.push(i))) + return result +} + +/** + * Get the indices of duplicate elements in an array + */ +export const findDuplicates = ( + values: string[], + normalize?: (value: string) => string +): [number[], number[]] => { + const matches: Record = {} + const hasDuplicates = new Set() + const duplicates = new Set() + values.forEach((value, index) => { + const key = normalize ? normalize(value) : value + if (matches[key] !== undefined) { + hasDuplicates.add(matches[value]) + duplicates.add(index) + } else { + matches[key] = index + } + }) + return [Array.from(hasDuplicates.values()), Array.from(duplicates.values())] +} + +/** + * Sometimes we want to support providing data + * either as a single data or as an array, but + * we still want to access them in a uniform format. + * + * SingleOrArray makes this possible. + */ +export type SingleOrArray = Data | Data[] + +/** + * Potentially wrap a value into an array + */ +export function getAsArray(data: SingleOrArray): Data[] { + return Array.isArray(data) ? data : [data] +} diff --git a/src/components/ui-plus-behavior/input/util/coupledData.ts b/src/components/ui-plus-behavior/input/util/coupledData.ts new file mode 100644 index 00000000..d9d25377 --- /dev/null +++ b/src/components/ui-plus-behavior/input/util/coupledData.ts @@ -0,0 +1,22 @@ +/** + * Sometimes we want to support providing some data in + * either a simple or an extended form, and access them + * in a consistent format. + * + * CoupledData supports makes this possible. + */ + +export type CoupledData = [FirstType, SecondType] | FirstType + +export function expandCoupledData( + value: CoupledData | undefined, + fallback: [FirstType, SecondType] +): [FirstType, SecondType] { + if (value === undefined) { + return fallback + } else if (Array.isArray(value)) { + return value + } else { + return [value, fallback[1]] + } +} diff --git a/src/components/ui-plus-behavior/input/util/decisions.ts b/src/components/ui-plus-behavior/input/util/decisions.ts new file mode 100644 index 00000000..8f0d4111 --- /dev/null +++ b/src/components/ui-plus-behavior/input/util/decisions.ts @@ -0,0 +1,80 @@ +/** + * Sometimes we want to store a boolean decision + * along with the reason why is it true/false. + * + * Like, this functionality is currently not available, + * because ...reasons... + */ + +import { MarkdownCode } from '../../../ui/markdown' + +type SimpleDecision = boolean +type FullDecision = { verdict: boolean; reason?: MarkdownCode | undefined } +export type Decision = SimpleDecision | FullDecision +type FullPositiveDecision = { verdict: true; reason: MarkdownCode } +type FullNegativeDecision = { verdict: false; reason: MarkdownCode } +export type DecisionWithReason = true | FullPositiveDecision | FullNegativeDecision +export const expandDecision = (decision: Decision): FullDecision => + typeof decision === 'boolean' ? { verdict: decision } : decision + +/** + * Ways to read decisions + */ +export const getVerdict = (decision: Decision | undefined, defaultVerdict: boolean): boolean => + decision === undefined ? defaultVerdict : typeof decision === 'boolean' ? decision : decision.verdict + +export const getReason = (decision: Decision | undefined): MarkdownCode | undefined => + decision === undefined ? undefined : typeof decision === 'boolean' ? undefined : decision.reason + +export const getReasonForDenial = (decision: Decision | undefined): MarkdownCode | undefined => + decision === undefined + ? undefined + : typeof decision === 'boolean' || decision.verdict + ? undefined + : decision.reason + +export const getReasonForAllowing = (decision: Decision | undefined): MarkdownCode | undefined => + decision === undefined + ? undefined + : typeof decision === 'boolean' || !decision.verdict + ? undefined + : decision.reason + +/** + * Ways to declare the decision + */ +export const allow = (reason?: MarkdownCode | undefined): Decision => ({ verdict: true, reason: reason }) +export const deny = (reason?: MarkdownCode | undefined): Decision => ({ verdict: false, reason: reason }) +export const denyWithReason = (reason: MarkdownCode): FullNegativeDecision => ({ + verdict: false, + reason: reason, +}) + +/** + * Boolean operations with decisions + */ + +export const invertDecision = (decision: Decision): Decision => { + const { verdict, reason } = expandDecision(decision) + return { + verdict: !verdict, + reason, + } +} + +export const andDecisions = (a: Decision, b: Decision): Decision => { + const aVerdict = getVerdict(a, false) + const bVerdict = getVerdict(b, false) + if (aVerdict) { + if (bVerdict) { + return { + verdict: true, + reason: `${getReason(a) as string}; ${getReason(b) as string}`, + } + } else { + return b + } + } else { + return a + } +} diff --git a/src/components/ui-plus-behavior/input/util/english.ts b/src/components/ui-plus-behavior/input/util/english.ts new file mode 100644 index 00000000..a1b18f92 --- /dev/null +++ b/src/components/ui-plus-behavior/input/util/english.ts @@ -0,0 +1,48 @@ +export const thereIsOnly = (amount: number) => { + if (!amount) { + return 'there is none' + } else if (amount == 1) { + return 'there is only one' + } else { + return `there are only ${amount}` + } +} + +export const atLeastXItems = (amount: number): string => { + if (amount > 1) { + return `at least ${amount} items` + } else if (amount === 1) { + return `at least one item` + } else { + throw new Error(`What do you mean by 'at least ${amount} items??'`) + } +} + +export const capitalizeFirstLetter = (input: string) => + input.length > 0 && /[a-zA-Z]/.test(input[0]) ? input[0].toUpperCase() + input.slice(1) : input + +export const decapitalizeFirstLetter = (input: string) => + input.length > 0 && /[a-zA-Z]/.test(input[0]) ? input[0].toLowerCase() + input.slice(1) : input + +export const camelToTitleCase = (camelStr: string): string => { + // Handle empty or invalid input + if (!camelStr) { + return '' + } + + // Split camelCase into words + const words = camelStr + // Add space before any uppercase letter + .replace(/([A-Z])/g, ' $1') + // Split by spaces and filter out empty strings + .split(' ') + .filter(word => word.length > 0) + + // Capitalize first word, lowercase others + return words + .map((word, index) => { + const lowerWord = word.toLowerCase() + return index === 0 ? lowerWord.charAt(0).toUpperCase() + lowerWord.slice(1) : lowerWord + }) + .join(' ') +} diff --git a/src/components/ui-plus-behavior/input/util/index.ts b/src/components/ui-plus-behavior/input/util/index.ts new file mode 100644 index 00000000..b773c5b3 --- /dev/null +++ b/src/components/ui-plus-behavior/input/util/index.ts @@ -0,0 +1,8 @@ +export * from './arrays' +export * from './types' +export * from './coupledData' +export * from './decisions' +export * from './english' +export * from './validationMessages' + +export const sleep = (time: number) => new Promise(resolve => setTimeout(() => resolve(''), time)) diff --git a/src/components/ui-plus-behavior/input/util/types.ts b/src/components/ui-plus-behavior/input/util/types.ts new file mode 100644 index 00000000..b50def6f --- /dev/null +++ b/src/components/ui-plus-behavior/input/util/types.ts @@ -0,0 +1,8 @@ +export const getTypeDescription = (data: Record) => + Object.keys(data).reduce( + (acc, key) => { + acc[key] = typeof data[key] + return acc + }, + {} as Record + ) diff --git a/src/components/ui-plus-behavior/input/util/validationMessages.ts b/src/components/ui-plus-behavior/input/util/validationMessages.ts new file mode 100644 index 00000000..f9f5528b --- /dev/null +++ b/src/components/ui-plus-behavior/input/util/validationMessages.ts @@ -0,0 +1,155 @@ +import { MarkdownCode } from '../../../ui/markdown' +import { SingleOrArray } from './arrays' + +export type FieldMessageType = 'info' | 'warning' | 'error' + +export type FieldMessage = { + signature?: string + text: MarkdownCode + type?: FieldMessageType +} + +// Check all messages for problems +export const checkMessagesForProblems = (messages: FieldMessage[] = []) => ({ + hasWarning: messages.some(message => message.type === 'warning'), + hasError: messages.some(message => message.type === 'error'), +}) + +export type MessageMaybeAtLocation = FieldMessage & { location?: string } +export type MessageAtLocation = FieldMessage & { location: string } + +/** + * Validators can return their findings on various detail levels + */ +export type SimpleValidatorOutput = MessageMaybeAtLocation | MarkdownCode | undefined + +/** + * Validators can return their findings on various detail levels + */ +export type RecursiveValidatorOutput = ValidatorFunction + +/** + * Flush out validator messages with all details + */ +export const wrapValidatorOutput = ( + output: SimpleValidatorOutput, + defaultLocation: string, + defaultLevel: FieldMessageType +): MessageAtLocation | undefined => { + if (output === undefined || output === '') return undefined + if (typeof output === 'string') { + const cutPos = output.indexOf(':') + if (cutPos !== -1) { + const signature = output.substring(0, cutPos) + if (!signature.includes(' ')) { + return { + signature, + text: output.substring(cutPos + 1).trim(), + type: defaultLevel, + location: defaultLocation, + } + } + } + return { + text: output, + type: defaultLevel, + location: defaultLocation, + } + } else { + const report = output as MessageMaybeAtLocation + return { + ...report, + type: report.type ?? 'error', + location: report.location ?? defaultLocation, + } + } +} + +/** + * Messages for all different parts of input data + */ +export type AllMessages = Record + +/** + * Interface for long-running validator functions + */ +export type ValidatorControls = { + isStillFresh?: () => boolean + updateStatus: (status: { message?: MarkdownCode; progress?: number } | string) => void +} + +type NumberStringFunction = (amount: number) => string +export type NumberMessageTemplate = string | NumberStringFunction + +export const getNumberMessage = (template: NumberMessageTemplate, amount: number): string => + typeof template === 'string' ? (template as string) : (template as NumberStringFunction)(amount) + +type DateStringFunction = (date: Date) => string +export type DateMessageTemplate = string | DateStringFunction + +export const getDateMessage = (template: DateMessageTemplate, date: Date): string => + typeof template === 'string' ? (template as string) : (template as DateStringFunction)(date) + +/** + * Types for validator functions + */ + +/** + * A synchronous validator function + * + * It should return undefined if everything is fine, + * or return the found issues. + */ +export type SyncSimpleValidatorFunction = ( + value: DataType, + controls: ValidatorControls, + reason: string +) => SingleOrArray + +/** + * An asynchronous validator function + * + * It should return undefined if everything is fine, + * or return the found issues. + */ +export type AsyncSimpleValidatorFunction = ( + value: DataType, + controls: ValidatorControls, + reason: string +) => Promise> + +export type SimpleValidatorFunction = + | SyncSimpleValidatorFunction + | AsyncSimpleValidatorFunction + +/** + * A synchronous validator function + * + * It should return undefined if everything is fine, + * or return the found issues. + */ +export type SyncRecursiveValidatorFunction = ( + value: DataType, + controls: ValidatorControls, + reason: string +) => SingleOrArray> + +/** + * An asynchronous validator function + * + * It should return undefined if everything is fine, + * or return the found issues. + */ +export type AsyncRecursiveValidatorFunction = ( + value: DataType, + controls: ValidatorControls, + reason: string +) => Promise>> + +export type RecursiveValidatorFunction = + | SyncRecursiveValidatorFunction + | AsyncRecursiveValidatorFunction + +export type ValidatorFunction = + | SimpleValidatorFunction + | RecursiveValidatorFunction diff --git a/src/components/ui-plus-behavior/input/validation.ts b/src/components/ui-plus-behavior/input/validation.ts new file mode 100644 index 00000000..60a54896 --- /dev/null +++ b/src/components/ui-plus-behavior/input/validation.ts @@ -0,0 +1,75 @@ +import { InputFieldControls, ValidationReason } from './useInputField' +import { AsyncSimpleValidatorFunction, getAsArray, SingleOrArray, sleep } from './util' +import { LabelProps } from './useLabel' + +export type FieldLike = Readonly< + Pick< + Readonly>, + 'name' | 'type' | 'visible' | 'validate' | 'hasProblems' | 'value' + > +> + +export type FieldArrayConfiguration = SingleOrArray>[] + +// export interface TypeAndValue { +// type: string +// value: DataType +// } + +export type FieldMapConfiguration = { [name: string]: FieldLike } + +/** + * Go through a group of fields, and do full validation. + * + * Returns true if there was an error. + */ +export const validateFields = async ( + fields: FieldArrayConfiguration | FieldMapConfiguration, + /** + * Why are we doing this? + * + * Behavior will be different depending on the reason. + * For example, we will clean values on form submission, but not when validating on change. + */ + reason: ValidationReason = 'submit', + + /** + * Tester for value freshness. + * + * Validation will be interrupted if the returned value changes to false. + */ + isStillFresh?: () => boolean +): Promise => { + // Get a flattened list of fields + const allFields = Array.isArray(fields) + ? fields.flatMap(config => getAsArray(config)) + : Object.values(fields) + let hasError = false + for (const field of allFields) { + const isFieldProblematic = await field.validate({ reason, isStillFresh }) + hasError = hasError || isFieldProblematic + } + return hasError +} + +/** + * Check whether any of these fields has an error + */ +export const doFieldsHaveAnError = (fields: FieldArrayConfiguration): boolean => + fields + .flatMap(config => getAsArray(config)) + .filter(field => field.visible) + .some(field => field.hasProblems) + +const mockValidator: AsyncSimpleValidatorFunction = async (_value, controls) => { + const { isStillFresh } = controls + if (isStillFresh && !isStillFresh()) return undefined + await sleep(500) + return undefined +} + +export const addMockValidation: Partial = { + showValidationSuccess: true, + validators: mockValidator, + validateOnChange: true, +} diff --git a/src/components/ui-plus-behavior/tooltip/WithTooltip.tsx b/src/components/ui-plus-behavior/tooltip/WithTooltip.tsx new file mode 100644 index 00000000..a542913c --- /dev/null +++ b/src/components/ui-plus-behavior/tooltip/WithTooltip.tsx @@ -0,0 +1,29 @@ +import { FC, ReactNode } from 'react' +import { MarkdownCode, MarkdownBlock } from '../../ui/markdown' +import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip' + +type Content = Exclude> + +type TooltipProps = Pick[0], 'side'> & + Pick[0], 'open' | 'delayDuration'> & { + overlay: MarkdownCode | undefined + children: Content + className?: string + } + +export const WithTooltip: FC = props => { + const { overlay, className } = props + const { open, delayDuration } = props // props for Tooltip + const { side } = props /// Props for TooltipContent + + return overlay ? ( + + {props.children} + + + + + ) : ( + props.children + ) +} diff --git a/src/components/ui-plus-behavior/tooltip/index.ts b/src/components/ui-plus-behavior/tooltip/index.ts new file mode 100644 index 00000000..ab03fa37 --- /dev/null +++ b/src/components/ui-plus-behavior/tooltip/index.ts @@ -0,0 +1 @@ +export * from './WithTooltip.tsx' diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index a7a8288e..3a4af8d5 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -39,10 +39,11 @@ export interface ButtonProps asChild?: boolean /** Defaults to type="button" */ type?: 'submit' | 'reset' | 'button' + testId?: string } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, type = 'button', ...props }, ref) => { + ({ className, variant, size, asChild = false, type = 'button', testId, ...props }, ref) => { const Comp = asChild ? Slot : "button" return ( ( type={type} ref={ref} {...props} + data-testid={testId} /> ) } diff --git a/src/components/ui/markdown.tsx b/src/components/ui/markdown.tsx new file mode 100644 index 00000000..c856bde1 --- /dev/null +++ b/src/components/ui/markdown.tsx @@ -0,0 +1,56 @@ +import { FC, MouseEventHandler } from 'react' +import Markdown from 'react-markdown' +import type { Components } from 'react-markdown' +import { JSX } from 'react/jsx-runtime' +import IntrinsicElements = JSX.IntrinsicElements + +/** + * Markdown text + * + * This is basically just normal string with Markdown code. + * We are defining a type in order to avoid accidentally + * passing Markdown to a component that accepts string and is + * not equipped to handle markdown. + * + * So use this type to mark strings that can hold Markdown. + * Just use "as string" if you need the actual value. + */ +export type MarkdownCode = string | symbol + +const renderComponents: Components = { + // Always set up links so that they open on a new tab + a: ({ children, href }) => { + const handleClick: MouseEventHandler = event => event.stopPropagation() + + return ( + + {children} + + ) + }, +} + +export type TagName = keyof IntrinsicElements + +type MarkdownBlockProps = { + code: MarkdownCode | undefined + mainTag?: TagName + className?: string +} + +/** + * A component to render markdown + */ +export const MarkdownBlock: FC = ({ code, mainTag, className }) => { + if (!code) return undefined + const text = ( + + {code as string} + + ) + return className ? {text} : text +} + +export const renderMarkdown = (markdown: MarkdownCode | undefined, tagName: TagName = 'span') => ( + +) diff --git a/src/stories/FormBuilding/ActionButton.stories.tsx b/src/stories/FormBuilding/ActionButton.stories.tsx new file mode 100644 index 00000000..67a14419 --- /dev/null +++ b/src/stories/FormBuilding/ActionButton.stories.tsx @@ -0,0 +1,349 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, fn, userEvent, waitFor } from 'storybook/test' +import { + ActionButton, + ActionProps, + camelToTitleCase, + deny, + FullConfirmationRequest, + sleep, + useAction, +} from '../../components/ui-plus-behavior/input' +import { FC } from 'react' + +import { screen } from 'storybook/test' + +const ActionButtonTest: FC = props => + +const meta: Meta = { + title: 'ui-plus-behavior/useAction() and ', + component: ActionButtonTest, + args: { + action: fn(), + }, + parameters: { + docs: { + description: { + component: 'A button with a coupled action, with optional description, pending and error statuses.', + }, + }, + layout: 'centered', + }, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + name: 'testAction', + }, + + play: async ({ args, canvas }) => { + const button = canvas.getAllByRole('button', { name: 'Test action' })[0] + await expect(button).toBeInTheDocument() + await userEvent.click(button) + await expect(args.action).toBeCalled() + }, +} + +export const CustomizedButton: Story = { + args: { + name: 'customizedAction', + label: 'Customized **action button**', + variant: 'destructive', + size: 'lg', + }, + + play: async ({ canvas }) => { + const button = canvas.getAllByRole('button', { name: 'Customized action button' })[0] + await expect(button).toContainHTML('action button') + }, +} + +export const WithDescription: Story = { + args: { + name: 'actionWithDescription', + description: 'There is a _description_ to this', + }, + parameters: { + docs: { + description: { + story: 'Try hovering the pointer', + }, + }, + }, + play: async ({ canvas, canvasElement }) => { + const button = canvas.getAllByRole('button', { name: 'Action with description' })[0] + await expect(button).toBeInTheDocument() + + // Should show an info icon + const infoIcon = canvasElement.querySelector('.lucide-info') + await expect(infoIcon).toBeInTheDocument() + + // Hovering should bring up the tooltip, with the formatted markdown + await userEvent.hover(button, { delay: 500 }) + const tooltip = screen.getByRole('tooltip') + await expect(tooltip).toContainHTML('There is a description to this') + }, +} +export const WithConfirmation: Story = { + args: { + name: 'doSomethingWild', + confirmationNeeded: true, + }, + + play: async ({ args, canvas }) => { + const button = canvas.getAllByRole('button', { name: 'Do something wild' })[0] + await expect(button).toBeInTheDocument() + + // When clicked, dialog is shown + await userEvent.click(button, { delay: 500 }) + + let dialog = screen.getByRole('dialog') + await expect(dialog).toBeVisible() + + // We can cancel it, dialog disappears, no action + const cancelButton = screen.getByTestId('cancel') + await expect(cancelButton).toBeInTheDocument() + await userEvent.click(cancelButton, { delay: 500 }) + await expect(dialog).not.toBeVisible() + await expect(args.action).not.toBeCalled() + + // When clicked again, dialog is shown again + await userEvent.click(button, { delay: 500 }) + dialog = screen.getByRole('dialog') + await expect(dialog).toBeVisible() + + // We can confirm it, dialog disappears, action is executed + const continueButton = screen.getByTestId('confirm') + await expect(continueButton).toBeInTheDocument() + await userEvent.click(continueButton, { delay: 500 }) + await expect(dialog).not.toBeVisible() + await expect(args.action).toBeCalled() + }, +} + +export const WithCustomizedConfirmationQuestion: Story = { + args: { + name: 'doSomethingWild', + confirmationNeeded: 'Have you considered the consequences?', + }, + + play: async ({ args, canvas }) => { + const button = canvas.getAllByRole('button', { name: 'Do something wild' })[0] + await expect(button).toBeInTheDocument() + + // When clicked, dialog is shown + await userEvent.click(button, { delay: 500 }) + + const dialog = screen.getByRole('dialog') + await expect(dialog).toBeVisible() + + await expect(dialog).toContainHTML(args.confirmationNeeded as string) + }, +} + +export const WithCustomizedConfirmationDialog: Story = { + args: { + name: 'doSomethingWild', + confirmationNeeded: { + title: 'Attention', + description: 'Do you **really** want to do this?', + cancelLabel: 'I would rather not', + okLabel: 'LFG', + variant: 'destructive', + }, + }, + + play: async ({ args, canvas }) => { + const request = args.confirmationNeeded as FullConfirmationRequest + + const button = canvas.getAllByRole('button', { name: 'Do something wild' })[0] + await expect(button).toBeInTheDocument() + + // When clicked, dialog is shown + await userEvent.click(button, { delay: 500 }) + const dialog = screen.getByRole('dialog') + await expect(dialog).toBeVisible() + + // The title, description and button labels are customized + const title = document.querySelector('[data-slot="dialog-title"]') + await expect(title).toHaveTextContent(request.title.toString()) + + const description = document.querySelector('[data-slot="dialog-description"]') + // Note the rendered Markdown formatting + await expect(description).toContainHTML('Do you really want to do this?') + + const cancelButton = screen.getByTestId('cancel') + await expect(cancelButton).toHaveTextContent(request.cancelLabel.toString()) + + const confirmButton = screen.getByTestId('confirm') + await expect(confirmButton).toHaveTextContent(request.okLabel.toString()) + }, +} + +const EXCUSE = "I'm sorry Dave, I'm afraid I can't do that." + +export const Disabled: Story = { + args: { + name: 'disabledAction', + label: 'Open the bay doors', + enabled: deny(EXCUSE), + }, + play: async ({ args, canvas }) => { + // We get a button + const button = canvas.getAllByRole('button', { name: args.label!.toString() })[0] + await expect(button).toBeInTheDocument() + + // Hovering should bring up the tooltip, + // which should have the description, formatted as MarkDown + await userEvent.hover(button, { delay: 500 }) + const tooltip = screen.getByRole('tooltip') + await expect(tooltip).toHaveTextContent(EXCUSE) + + // Clicking does nothing + await userEvent.click(button) + await expect(args.action).not.toBeCalled() + }, +} + +export const LongAction: Story = { + args: { + name: 'calculateTheAnswer', + pendingLabel: 'Calculating', + action: fn(async () => await sleep(1000)), + }, + play: async ({ args, canvas, canvasElement }) => { + // We get a button + const button = canvas.getByRole('button') + await expect(button).toBeInTheDocument() + + // Clicking will execute the action + await userEvent.click(button) + + // Pending state will be indicated with spinner + let spinner = canvasElement.querySelector('.lucide-loader-circle') + await expect(spinner).toBeInTheDocument() + + // label will be replaced with pending label + await expect(button).toHaveTextContent(args.pendingLabel!.toString()) + + // Wait for the action to finish, and spinner to disappear + await waitFor(() => { + spinner = canvasElement.querySelector('.lucide-loader-circle') + expect(spinner).toBeNull() + }) + + // label will be restored to original content + await expect(button).toHaveTextContent(camelToTitleCase(args.name)) + }, +} + +export const WithExecutionStatus: Story = { + args: { + name: 'calculateTheAnswer', + action: fn(async ({ setStatus }) => { + setStatus('Phase one..') + await sleep(500) + setStatus('Phase two..') + await sleep(500) + }), + }, + + play: async ({ canvas }) => { + // We get a button + const button = canvas.getByRole('button') + await expect(button).toBeInTheDocument() + + // Clicking will start the process, and first phase message is shown + await userEvent.click(button) + const status = canvas.getByRole('status-message') + await expect(status).toHaveTextContent('Phase one') + + // Wait for phase 1 to finish + await waitFor(() => expect(status).toHaveTextContent('Phase two')) + + // Wait for phase 2 to finish, and the message to disappear + await waitFor(() => expect(status).not.toBeInTheDocument()) + }, +} + +const WARNING = 'I have a bad feeling about this.' + +export const WithWarning: Story = { + args: { + name: 'longAction', + label: 'Just do it', + pendingLabel: 'Doing it', + action: fn(({ warn }) => warn(WARNING)), + }, + play: async ({ canvas }) => { + // We get a button + const button = canvas.getByRole('button') + await expect(button).toBeInTheDocument() + + // Clicking will execute the action + await userEvent.click(button) + + // Warning message will be visible + const warning = canvas.getByRole('field-warning') + await expect(warning).toHaveTextContent(WARNING) + }, +} + +const FAILURE = "I don't know the answer." + +export const WithError: Story = { + args: { + name: 'calculateTheAnswer', + action: fn(async () => { + await sleep(500) + throw new Error(FAILURE) + }), + }, + play: async ({ canvas }) => { + // We get a button + const button = canvas.getByRole('button') + await expect(button).toBeInTheDocument() + + // Clicking will execute the action + await userEvent.click(button) + + // Wait for error message to appear + await waitFor(async () => { + const error = canvas.getByRole('field-error') + await expect(error).toHaveTextContent(FAILURE) + }) + }, +} + +export const WithLogMessages: Story = { + args: { + name: 'longAction', + label: 'Calculate the answer', + action: async ({ log }) => { + await sleep(500) + log('Output from the action.') + await sleep(500) + log('More output from the action.') + await sleep(500) + }, + }, + play: async ({ canvas }) => { + // We get a button + const button = canvas.getByRole('button') + await expect(button).toBeInTheDocument() + + // Clicking will execute the action + await userEvent.click(button) + + // Wait for log messages to appear + await waitFor(async () => { + const messages = canvas.getAllByRole('field-info') + await expect(messages[0]).toHaveTextContent('Output from the action') + await expect(messages[1]).toHaveTextContent('More output from the action') + }) + }, +} diff --git a/src/stories/FormBuilding/BooleanInput.stories.tsx b/src/stories/FormBuilding/BooleanInput.stories.tsx new file mode 100644 index 00000000..9c426542 --- /dev/null +++ b/src/stories/FormBuilding/BooleanInput.stories.tsx @@ -0,0 +1,175 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, userEvent } from 'storybook/test' +import { BooleanInput, deny, useBooleanField } from '../../components/ui-plus-behavior/input' +import { FC } from 'react' + +import { screen } from 'storybook/test' + +const BooleanInputTest: FC[0]> = props => { + const controls = useBooleanField(props) + return +} + +const meta: Meta = { + title: 'ui-plus-behavior/useBooleanField() and ', + component: BooleanInputTest, + parameters: { + docs: { + description: { + component: 'A controlled boolean input field with built-in behaviors.', + }, + }, + layout: 'centered', + }, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + name: 'testBooleanInput', + }, + + play: async ({ canvas, canvasElement }) => { + // Checkbox is rendered, with label and button + const label = canvasElement.querySelector('[data-slot="label"]') + await expect(label).toBeInTheDocument() + const button = canvas.getByLabelText('Test boolean input') + await expect(button).toBeInTheDocument() + + // We should be able to find the button based on the label + // await expect(canvas.getByLabelText('Test boolean input', { selector: 'input' })).toBe(button) + // Unfortunately, this doesn't work, because button is a non-labelable component per specification/ + // Still shadcn (radix ui) insists on using it this way. Clicking works, but accessibility doesn't + // I have also added aria-labelledby to mitigate, but it's still not enough for this test case to work. + + // Checkbox is not checked by default + await expect(button).not.toBeChecked() + + // We can check and uncheck it by clicking on the button + await userEvent.click(button, { delay: 100 }) + await expect(button).toBeChecked() + await userEvent.click(button, { delay: 100 }) + await expect(button).not.toBeChecked() + + // We can also check and uncheck it by clicking on the label + await userEvent.click(label!, { delay: 100 }) + await expect(button).toBeChecked() + await userEvent.click(label!, { delay: 100 }) + await expect(button).not.toBeChecked() + }, +} + +export const OnByDefault: Story = { + args: { + name: 'testBooleanInput', + initialValue: true, + }, + + play: async ({ canvas, canvasElement }) => { + // Checkbox is rendered, with label and button + const label = canvasElement.querySelector('[data-slot="label"]') + await expect(label).toBeInTheDocument() + const button = canvas.getByLabelText('Test boolean input') + await expect(button).toBeInTheDocument() + + // Checkbox is checked by default + await expect(button).toBeChecked() + }, +} + +export const WithDescription: Story = { + args: { + name: 'testBooleanInput', + description: 'For testing, you know', + }, + + play: async ({ canvas, canvasElement }) => { + // Checkbox is rendered, with label and button + const label = canvasElement.querySelector('[data-slot="label"]') + await expect(label).toBeInTheDocument() + const button = canvas.getByLabelText('Test boolean input') + await expect(button).toBeInTheDocument() + + // Should show an info icon + const infoIcon = canvasElement.querySelector('.lucide-info') + await expect(infoIcon).toBeInTheDocument() + + // Hovering should bring up the tooltip, with the formatted markdown + await userEvent.hover(label!, { delay: 500 }) + const tooltip = screen.getByRole('tooltip') + await expect(tooltip).toHaveTextContent('For testing, you know') + }, +} + +export const Disabled: Story = { + args: { + name: 'testBooleanInput', + enabled: deny("You can't check this for reasons"), + }, + + play: async ({ canvas, canvasElement }) => { + // Checkbox is rendered, with label and button + const label = canvasElement.querySelector('[data-slot="label"]') + await expect(label).toBeInTheDocument() + const button = canvas.getByLabelText('Test boolean input') + await expect(button).toBeInTheDocument() + + // Should show an info icon + const infoIcon = canvasElement.querySelector('.lucide-info') + await expect(infoIcon).toBeInTheDocument() + + // Hovering should bring up the tooltip, with the formatted markdown + await userEvent.hover(label!, { delay: 250 }) + const tooltip = screen.getByRole('tooltip') + await expect(tooltip).toHaveTextContent("You can't check this for reasons") + + // Clicking won't check it + await expect(button).not.toBeChecked() + await userEvent.click(button, { delay: 250 }) + await expect(button).not.toBeChecked() + await userEvent.click(label!, { delay: 250 }) + await expect(button).not.toBeChecked() + }, +} + +export const DefaultSwitch: Story = { + args: { + name: 'testBooleanInput', + preferredWidget: 'switch', + }, + + play: Default.play, +} + +export const OnByDefaultSwitch: Story = { + args: { + name: 'testBooleanInput', + initialValue: true, + preferredWidget: 'switch', + }, + + play: OnByDefault.play, +} + +export const SwitchWithDescription: Story = { + args: { + name: 'testBooleanInput', + description: 'For testing, you know', + preferredWidget: 'switch', + }, + + play: WithDescription.play, +} + +export const DisabledSwitch: Story = { + args: { + name: 'testBooleanInput', + enabled: deny("You can't check this for reasons"), + preferredWidget: 'switch', + }, + + play: Disabled.play, +} diff --git a/src/stories/FormBuilding/DateInput.stories.tsx b/src/stories/FormBuilding/DateInput.stories.tsx new file mode 100644 index 00000000..ab764a8f --- /dev/null +++ b/src/stories/FormBuilding/DateInput.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { DateInput, useDateField } from '../../components/ui-plus-behavior/input' + +const meta: Meta = { + title: 'ui-plus-behavior/useDateField() and ', + component: DateInput, + parameters: { + docs: { + description: { + component: 'A controlled date input field with built-in behaviors.', + }, + }, + layout: 'centered', + }, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: function Example() { + // Model for the boolean input + const date = useDateField({ + name: 'testDateInput', + label: 'Test date', + description: 'Some interesting date', + // validators: () => 'oops', + validateOnChange: true, + }) + + return ( +
+ +
+ ) + }, +} diff --git a/src/stories/FormBuilding/LabelOutput.stories.tsx b/src/stories/FormBuilding/LabelOutput.stories.tsx new file mode 100644 index 00000000..097131e2 --- /dev/null +++ b/src/stories/FormBuilding/LabelOutput.stories.tsx @@ -0,0 +1,115 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, waitFor } from 'storybook/test' +import { LabelOutput, LabelProps, useLabel } from '../../components/ui-plus-behavior/input' +import { FC } from 'react' + +const TestLabel: FC = props => { + const controls = useLabel(props) + return +} + +const meta: Meta = { + title: 'ui-plus-behavior/useLabel() and ', + component: TestLabel, + parameters: { + docs: { + description: { + component: + 'A label, with the same rendering, validation and visibility configuration as all other form elements.', + }, + }, + layout: 'centered', + }, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + name: 'testLabel', + value: 'Test label (with _formatted_ content)', + }, + + play: async ({ canvasElement }) => { + const label = canvasElement.querySelector('[data-slot="label"]') + await expect(label).toBeInTheDocument() + await expect(label).toContainHTML('Test label (with formatted content') + }, +} + +export const WithDescription: Story = { + args: { + name: 'testLabel', + value: Default.args!.value, + description: 'Description (_also formatted_)', + }, + + play: async ({ canvasElement }) => { + // We should see the label normally + const label = canvasElement.querySelector('[data-slot="label"]') + await expect(label).toBeInTheDocument() + await expect(label).toContainHTML('Test label (with formatted content') + + // Also check the description + const desc = canvasElement.querySelector('[data-slot="description"]') + await expect(desc).toContainHTML('Description (also formatted)') + }, +} + +export const WithError: Story = { + args: { + name: 'testLabel', + value: 'Test label (which can be _formatted_)', + validateOnChange: true, + validators: () => 'There is a problem', + }, + play: async ({ canvas }) => { + // Wait for error message to appear + await waitFor(async () => { + const error = canvas.getByRole('field-error') + await expect(error).toHaveTextContent('There is a problem') + }) + }, +} + +export const WithWarning: Story = { + args: { + name: 'testLabel', + value: 'Test label (which can be _formatted_)', + + validateOnChange: true, + validators: () => ({ type: 'warning', text: 'There might be a problem' }), + }, + play: async ({ canvas }) => { + // Wait for warning message to appear + await waitFor(async () => { + const error = canvas.getByRole('field-warning') + await expect(error).toHaveTextContent('There might be a problem') + }) + }, +} + +export const WithCustomRenderer: Story = { + args: { + name: 'textLabel', + value: 'apple', + renderer: value => + value === 'apple' ? ( + + Yes, we do like apple pie. + + ) : ( + + Well I wanted an apple. I don't know about this. + + ), + }, + + play: async ({ canvasElement }) => { + const label = canvasElement.querySelector('[data-slot="label"]') + await expect(label).toBeInTheDocument() + await expect(label).toContainHTML('Yes, we do like apple') + }, +} diff --git a/src/stories/FormBuilding/SelectInput.stories.tsx b/src/stories/FormBuilding/SelectInput.stories.tsx new file mode 100644 index 00000000..2c69b204 --- /dev/null +++ b/src/stories/FormBuilding/SelectInput.stories.tsx @@ -0,0 +1,234 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { + SelectInput, + useOneOfField, + HasToString, + NonNullableOneOfFieldProps, + NullableOneOfFieldProps, + deny, + useNullableOneOfField, + useNonNullableOneOfField, +} from '../../components/ui-plus-behavior/input' +import { ReactElement } from 'react' + +function SelectInputTest( + props: NonNullableOneOfFieldProps +): ReactElement + +function SelectInputTest(props: NullableOneOfFieldProps): ReactElement + +function SelectInputTest( + props: NullableOneOfFieldProps | NonNullableOneOfFieldProps +): ReactElement { + const controls = + 'placeholder' in props + ? // eslint-disable-next-line react-hooks/rules-of-hooks + useNullableOneOfField(props as NullableOneOfFieldProps) + : // eslint-disable-next-line react-hooks/rules-of-hooks + useNonNullableOneOfField(props as NonNullableOneOfFieldProps) + + return +} + +const meta: Meta = { + title: 'ui-plus-behavior/useOneOfField() and ', + component: SelectInputTest, + args: { + compact: true, + }, + parameters: { + docs: { + description: { + component: 'A controlled select input with various builtin behaviors.', + }, + }, + layout: 'centered', + }, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + name: 'testSelect', + description: 'What do we have _here_?', + choices: [ + { + value: 'one', + label: '**First** option', + }, + { + value: 'two', + label: '_Second_ option', + }, + { + value: 'three', + label: 'Third option', + }, + ] as const, + }, +} + +export const Disabled: Story = { + args: { + name: 'testSelect', + choices: ['one', 'two', 'three'], + enabled: deny("Don't touch this!"), + }, +} + +export const AutoGeneratedLabels: Story = { + args: { + name: 'testSelect', + choices: ['one', 'two', 'three'], + }, +} + +export const WithChoicesWithDescription: Story = { + args: { + name: 'testSelect', + choices: [ + { + value: 'one', + label: '**One**', + description: 'Well, we have to start somewhere, right?', + }, + { + value: 'two', + label: '_Two_', + }, + { + value: 'three', + label: 'Three', + }, + ], + }, +} + +export const WithDisabledAndHiddenOptions: Story = { + args: { + name: 'testSelect', + description: 'What do we have _here_?', + choices: [ + 'one', + 'two', + { + value: 'three', + enabled: deny("That's _way_ **too much**!"), + }, + { + value: 'four', + hidden: true, + }, + ], + }, +} + +export const WithOnlyOneOption: Story = { + args: { + name: 'testSelect', + description: 'What do we have _here_?', + disableIfOnlyOneVisibleChoice: true, // This is an optional behavior + choices: ['revolution'], + }, +} + +export const WithError: Story = { + args: { + name: 'testSelect', + choices: ['one', 'two', 'three'] as const, + // Fot better type safety, see the test cases below + validators: c => (c !== 'two' ? "Let's odd numbers!" : undefined), + validateOnChange: true, + }, +} + +export const WithWarning: Story = { + args: { + name: 'testSelect', + choices: ['one', 'two', 'three'], + validators: c => (c === 'two' ? { type: 'warning', text: "I don't like even numbers" } : undefined), + validateOnChange: true, + }, +} + +export const WithPlaceholder: Story = { + args: { + name: 'testSelect', + label: 'Test **select**', + placeholder: 'Which one do you want?', + choices: ['one', 'two', 'three'] as const, + }, +} + +export const WithUnselectablePlaceholder: Story = { + args: { + name: 'testSelect', + label: 'Test **select**', + placeholder: 'Which one do you want?', + canSelectPlaceholder: false, + choices: ['one', 'two', 'three'] as const, + }, +} + +export const WithTypeSafety: Story = { + render: () => ( + // To achieve stricter type safety, we need to use the hook and the UI component separately, + // So we will do that here, unlike in the rest of this file + { + // Inferred type: (value: 'one' | 'two' | 'three') + switch (value) { + case 'two': + return 'Oh no, not two!' + /** + * Uncommenting the next line would produce a Compile-time TypeScript error, + * since the compiler knows that no such choice is available + */ + // case 'four': + // break + } + return undefined + }, + validateOnChange: true, + })} + /> + ), +} + +export const WithPlaceholderAndTypeSafety: Story = { + render: () => ( + // To achieve stricter type safety, we need to use the hook and the UI component separately, + // So we will do that here, unlike in the rest of this file + { + // Inferred type: (value: 'one' | 'two' | 'three' | undefined) + // The undefined option is included, because placeholder is enabled. + switch (value) { + case 'two': + return 'Oh no, not two!' + /** + * Uncommenting the next line would produce a Compile-time TypeScript error, + * since the compiler knows that no such choice is available + */ + // case 'four': + // break + } + return undefined + }, + validateOnChange: true, + })} + /> + ), +} diff --git a/src/stories/FormBuilding/TextInput.stories.tsx b/src/stories/FormBuilding/TextInput.stories.tsx new file mode 100644 index 00000000..efa93543 --- /dev/null +++ b/src/stories/FormBuilding/TextInput.stories.tsx @@ -0,0 +1,248 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { useTextField, TextInput, TextFieldProps } from '../../components/ui-plus-behavior/input' +import { FC } from 'react' +import { expect, fn, userEvent, waitFor } from 'storybook/test' + +const TextInputTest: FC = props => { + const controls = useTextField(props) + return +} + +const meta: Meta = { + title: 'ui-plus-behavior/useTextField() and ', + component: TextInputTest, + parameters: { + docs: { + description: { + component: 'A controlled text input field with various builtin behaviors.', + }, + }, + layout: 'centered', + }, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + name: 'testInput', + description: 'What do we have _here_?', + placeholder: 'Type whatever', + onValueChange: fn(), + onEnter: fn(), + }, + play: async ({ args, canvas, canvasElement }) => { + // The label should be visible + const label = canvasElement.querySelector('[data-slot="label"]') + await expect(label).toBeInTheDocument() + await expect(label).toHaveTextContent('Test input') + + // We should be able to find the input field based on the label + const input = canvas.getByLabelText('Test input', { selector: 'input' }) + await expect(input).toBeInTheDocument() + + // The description should also be displayed + const desc = canvasElement.querySelector('[data-slot="description"]') + await expect(desc).toContainHTML('What do we have here?') + + // Placeholder should be displayed + await expect(input).toHaveProperty('placeholder', 'Type whatever') + + // We should be able to type + await userEvent.type(input, 'test data{enter}', { delay: 10 }) + + // onValueChange should be called with the first half of the value + await waitFor(() => expect(args.onValueChange).toHaveBeenCalledWith('test', expect.anything())) + + // onValueChange should be called with the full value + await waitFor(() => expect(args.onValueChange).toHaveBeenCalledWith('test data', expect.anything())) + + // onEnter should be called with the full value + await waitFor(() => expect(args.onEnter).toHaveBeenCalledWith('test data')) + }, +} + +export const CompactMode: Story = { + args: { + name: 'input', + placeholder: 'Type whatever', + compact: true, + }, +} + +export const WithAutoFocus: Story = { + args: { + name: 'defaultInput', + autoFocus: true, + }, + play: async ({ canvas }) => { + const input = canvas.getByLabelText('Default input', { selector: 'input' }) + await expect(input).toBeInTheDocument() + await expect(input).toHaveFocus() + }, +} + +export const WithWarning: Story = { + args: { + name: 'testInput', + description: 'Type anything to get a warning', + label: 'Test **input**', + validateOnChange: true, + validators: () => ({ type: 'warning', text: 'This _might_ be wrong.' }), + }, + play: async ({ canvas }) => { + // We should be able to find the input field based on the label + const input = canvas.getByLabelText('Test input', { selector: 'input' }) + await expect(input).toBeInTheDocument() + + // There should not be any warning message by default + await expect(canvas.queryByRole('field-warning')).toBeNull() + + // We should be able to type + await userEvent.type(input, 'anything', { delay: 10 }) + + // Wait for warning message to appear + await waitFor(async () => { + const error = canvas.getByRole('field-warning') + await expect(error).toHaveTextContent('This might be wrong') + }) + }, +} + +export const WithMinimumLength: Story = { + args: { + name: 'testInput', + description: 'Has a minimum length', + validateOnChange: true, + minLength: 5, + }, + + play: async ({ canvas }) => { + // We should be able to find the input field based on the label + const input = canvas.getByLabelText('Test input', { + selector: 'input', + }) + await expect(input).toBeInTheDocument() + + // There should not be any warning message by default + await expect(canvas.queryByRole('field-error')).toBeNull() + + // We should be able to type + await userEvent.type(input, 'cats', { delay: 10 }) + + // Wait for the error message to appear + await waitFor(() => + expect(canvas.getByRole('field-error')).toHaveTextContent( + 'Please specify at least 5 characters! (Currently: 4)' + ) + ) + + // We should be able to type + await userEvent.type(input, ' and dogs', { delay: 10 }) + + // The error message should disappear + await waitFor(() => expect(canvas.queryByRole('field-error')).toBeNull()) + }, +} + +export const WithMinimumLengthWithCustomizedMessage: Story = { + args: { + name: 'testInput', + description: 'Has a minimum length', + validateOnChange: true, + minLength: [5, min => `too short, need at least ${min}`], + }, + play: async ({ canvas }) => { + // We should be able to find the input field based on the label + const input = canvas.getByLabelText('Test input', { + selector: 'input', + }) + + // We should be able to type + await userEvent.type(input, 'cats', { delay: 10 }) + + // Wait for the error message to appear + await waitFor(() => + expect(canvas.getByRole('field-error')).toHaveTextContent('too short, need at least 5 (Currently: 4)') + ) + }, +} + +export const WithMaximumLength: Story = { + args: { + name: 'testInput', + validateOnChange: true, + maxLength: 10, + // initialValue: 'too long text', + }, + + play: async ({ canvas }) => { + // We should be able to find the input field based on the label + const input = canvas.getByLabelText('Test input', { + selector: 'input', + }) + await expect(input).toBeInTheDocument() + + // There should not be any warning message by default + await expect(canvas.queryByRole('field-error')).toBeNull() + + // We should be able to type + await userEvent.type(input, 'Something long', { delay: 10 }) + + // Wait for the error message to appear + await waitFor(async () => + expect(canvas.getByRole('field-error')).toHaveTextContent( + 'Please specify at most 10 characters! (Currently: 14)' + ) + ) + + // We should be able to type + await userEvent.type(input, '{Backspace}{Backspace}{Backspace}{Backspace}', { delay: 50 }) + + // The error message should disappear + await waitFor(() => expect(canvas.queryByRole('field-error')).toBeNull()) + }, +} + +export const WithCustomValidators: Story = { + args: { + name: 'testInput', + description: 'Try words separated by spaces or commas', + validateOnChange: true, + validators: [ + value => (value.includes(' ') ? 'No spaces, please' : undefined), + value => (value.includes(',') ? 'No commas, please' : undefined), + ], + }, + + play: async ({ canvas }) => { + // We should be able to find the input field based on the label + const input = canvas.getByLabelText('Test input', { + selector: 'input', + }) + await expect(input).toBeInTheDocument() + + // There should not be any error message by default + await expect(canvas.queryByRole('field-error')).toBeNull() + + // Let's input a test string with a space + await userEvent.type(input, 'final solution', { delay: 10 }) + + // Wait for the error message to appear + await waitFor(() => expect(canvas.getByRole('field-error')).toHaveTextContent('No spaces, please')) + + // Delete old output + await userEvent.type(input, '{Backspace}'.repeat(20)) + + // Wait for the error message to disappear + await waitFor(() => expect(canvas.queryByRole('field-error')).toBeNull()) + + // Add new input + await userEvent.type(input, 'One,Two') + + // Wait for the error message to appear + await waitFor(() => expect(canvas.getByRole('field-error')).toHaveTextContent('No commas, please')) + }, +} diff --git a/src/stories/FormBuilding/forms.stories.tsx b/src/stories/FormBuilding/forms.stories.tsx new file mode 100644 index 00000000..76e9a5b6 --- /dev/null +++ b/src/stories/FormBuilding/forms.stories.tsx @@ -0,0 +1,219 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { + ActionButton, + deny, + getTypeDescription, + InputFieldGroup, + sleep, + useAction, + useBooleanField, + useLabel, + useOneOfField, + useTextField, +} from '../../components/ui-plus-behavior/input' +import { validateFields } from '../../components/ui-plus-behavior/input/validation.ts' +import { useState } from 'react' +import { getFieldValues } from '../../components/ui-plus-behavior/input/fieldValues.ts' + +const meta: Meta = { + title: 'ui-plus-behavior/validate() and ', + component: InputFieldGroup, + parameters: { + docs: { + description: { + component: 'Tools for handling multiple fields together.', + }, + }, + layout: 'centered', + }, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: function Example() { + const [currentUser, setCurrentUser] = useState() + + const userLabel = useLabel({ + visible: !!currentUser, + name: 'userlabel', + value: `Currently logged in as ${currentUser}.`, + }) + + const username = useTextField({ + name: 'Username', + visible: !currentUser, + autoFocus: true, + required: true, + minLength: [3, n => `Name should be at least ${n} long!`], + validators: n => (!n.includes('@') ? "This doesn't look like an email address" : undefined), + }) + + const password = useTextField({ + name: 'Password', + visible: !currentUser, + hideInput: true, + required: true, + onEnter: () => login.execute(), + }) + + const mode = useOneOfField({ + name: 'mode', + visible: !currentUser, + // placeholder: 'Please select mode', + // required: true, + choices: ['easy', 'normal', 'hard'] as const, + }) + + const loginFormFields = [username, password, mode, userLabel] + + const login = useAction({ + name: 'Log in', + visible: !currentUser, + action: async context => { + const hasFieldErrors = await validateFields(loginFormFields, 'submit') + if (!hasFieldErrors) { + context.setStatus('Searching for user...') + await sleep(1000) + context.setStatus('Checking password...') + await sleep(1000) + if (password.value.length < 3) throw new Error('Invalid credentials') + setCurrentUser(username.value) + } + }, + }) + + const logout = useAction({ + name: 'Log out', + enabled: currentUser ? true : deny("Can't log out when not logged in!"), + action: () => setCurrentUser(undefined), + }) + + const register = useAction({ + name: 'register', + visible: !currentUser, + enabled: deny('Not yet implemented'), + action: () => {}, + }) + + const actions = [login, register, logout] + + return ( +
+ + +
+ ) + }, +} + +export const MinimalArrayForm: Story = { + render: function Example() { + const [values, setValues] = useState>() + const form = [ + useLabel('Please tell us about your preferences!'), + [ + // We can place the fields side by side + // if we wrap them in an array + useTextField('Animal', 'What is your favorite animal?', true), + useTextField('Color', 'What is your favorite color?'), + ], + [ + // This will be in a row, too + useOneOfField({ name: 'sex', choices: ['male', 'female'] as const, compact: true }), + useBooleanField({ name: 'coder', description: 'I know TypeScript', preferredWidget: 'switch' }), + ], + ] + + const apply = useAction({ + name: 'apply', + action: async () => { + if (await validateFields(form)) return + const values = getFieldValues(form) + switch (values.sex) { + case 'male': + // console.log("It's a boy") + break + case 'female': + // console.log("It's a girl") + break + // case 'other': + // console.log("It's complicated") + // break + } + setValues(values) + }, + }) + + return ( +
+ + + {values && ( + <> + Values:
{JSON.stringify(values, null, '  ')}
+ + )} +
+ ) + }, +} + +export const TypeSafeForm: Story = { + render: function Example() { + const [values, setValues] = useState>() + const [types, setTypes] = useState>() + + const form = { + label: useLabel('Please tell us about your preferences!'), + animal: useTextField('Animal', 'What is your favorite animal?', true), + color: useTextField('Color', 'What is your favorite color?'), + coder: useBooleanField('coder', 'I know TypeScript'), + sex: useOneOfField({ + name: 'sex', + choices: ['male', 'female'] as const, + compact: true, + }), + } as const + + const apply = useAction({ + name: 'apply', + action: async () => { + if (await validateFields(form)) return + const values = getFieldValues(form) + switch (values.sex) { + case 'male': + // console.log("It's a boy") + break + case 'female': + // console.log("It's a girl") + break + // case 'other': + // console.log("It's complicated") + // break + } + setValues(values) + setTypes(getTypeDescription(values)) + }, + }) + + return ( +
+ + + {values && ( + <> + Values:
{JSON.stringify(values, null, '  ')}
+ + )} + {types && ( + <> + Types:
{JSON.stringify(types, null, '  ')}
+ + )} +
+ ) + }, +} diff --git a/src/stories/WithTooltip/WithTooltip.stories.tsx b/src/stories/WithTooltip/WithTooltip.stories.tsx new file mode 100644 index 00000000..92d91b40 --- /dev/null +++ b/src/stories/WithTooltip/WithTooltip.stories.tsx @@ -0,0 +1,86 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Button } from '../../components' +import { expect, userEvent, screen } from 'storybook/test' +import { WithTooltip } from '../../components' + +const meta: Meta = { + title: 'ui-plus-behavior/WithTooltip', + component: WithTooltip, + parameters: { + docs: { + description: { + component: + 'Simplifying wrapper around Tooltip. Uses a Tooltip when additional information is available, no Tooltip otherwise. Markdown is supported for the overlay content.', + }, + }, + layout: 'centered', + }, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + overlay: '_Tooltip_ **content**', + children: , + }, + play: async ({ canvas }) => { + const button = canvas.getAllByRole('button', { name: 'Hover me' })[0] + await expect(button).toBeInTheDocument() + + // There should be no tooltip shown + // await expect(screen.getByRole('tooltip')).toThrow() // TODO this exception escapes somehow + + // Hovering should bring up a tooltip + await userEvent.hover(button, { delay: 250 }) + const tooltip = screen.getByRole('tooltip') + await expect(tooltip).toContainHTML('Tooltip content') + }, +} + +export const NoTooltip: Story = { + args: { + overlay: '', + children: , + }, + play: async ({ canvas }) => { + const button = canvas.getAllByRole('button', { name: 'No use hovering me' })[0] + await expect(button).toBeInTheDocument() + + await userEvent.hover(button, { delay: 250 }) + // There should be no tooltip shown + // await expect(screen.getByRole('tooltip')).toThrow() // TODO this exception escapes somehow + }, +} + +export const Variants: Story = { + args: { + children: ( +
+
+ + + +
+ +
+ + + + + + + +
+ +
+ + + +
+
+ ), + }, +} diff --git a/src/styles/global.css b/src/styles/global.css index 3d896a0f..43192eed 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -145,3 +145,24 @@ cursor: pointer; } } + +.text-success { + color: var(--success); +} + +.text-warning { + color: var(--warning); +} + +.rotating { + animation: rotating 1s linear infinite; +} + +@keyframes rotating { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/yarn.lock b/yarn.lock index 3467bb52..e1b1d944 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2545,6 +2545,18 @@ resolved "https://registry.yarnpkg.com/@types/doctrine/-/doctrine-0.0.9.tgz#d86a5f452a15e3e3113b99e39616a9baa0f9863f" integrity sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA== +"@types/estree-jsx@^1.0.0": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz#858a88ea20f34fe65111f005a689fa1ebf70dc18" + integrity sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg== + dependencies: + "@types/estree" "*" + +"@types/estree@*": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + "@types/estree@1.0.7", "@types/estree@^1.0.0", "@types/estree@^1.0.6": version "1.0.7" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" @@ -2557,6 +2569,13 @@ dependencies: "@types/node" "*" +"@types/hast@^3.0.0": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa" + integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ== + dependencies: + "@types/unist" "*" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" @@ -2586,6 +2605,13 @@ resolved "https://registry.yarnpkg.com/@types/katex/-/katex-0.16.7.tgz#03ab680ab4fa4fbc6cb46ecf987ecad5d8019868" integrity sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ== +"@types/mdast@^4.0.0": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6" + integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA== + dependencies: + "@types/unist" "*" + "@types/mdx@^2.0.0": version "2.0.13" resolved "https://registry.yarnpkg.com/@types/mdx/-/mdx-2.0.13.tgz#68f6877043d377092890ff5b298152b0a21671bd" @@ -2648,6 +2674,11 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== +"@types/unist@*", "@types/unist@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" + integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q== + "@types/unist@^2.0.0": version "2.0.11" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4" @@ -2798,6 +2829,11 @@ "@typescript-eslint/types" "8.32.0" eslint-visitor-keys "^4.2.0" +"@ungap/structured-clone@^1.0.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" + integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== + "@vitejs/plugin-react@^4.4.1": version "4.4.1" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz#d7d1e9c9616d7536b0953637edfee7c6cbe2fe0f" @@ -3279,6 +3315,11 @@ babel-preset-jest@^29.6.3: babel-plugin-jest-hoist "^29.6.3" babel-preset-current-node-syntax "^1.0.0" +bail@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/bail/-/bail-2.0.2.tgz#d26f5cd8fe5d6f832a31517b9f7c356040ba6d5d" + integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw== + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -3428,6 +3469,11 @@ caniuse-lite@^1.0.30001716: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001716.tgz#39220dfbc58c85d9d4519e7090b656aa11ca4b85" integrity sha512-49/c1+x3Kwz7ZIWt+4DvK3aMJy9oYXXG6/97JKsnjdCk/6n9vVyWL8NAwVt95Lwt9eigI10Hl782kDfZUUlRXw== +ccount@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" + integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== + chai@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/chai/-/chai-5.2.0.tgz#1358ee106763624114addf84ab02697e411c9c05" @@ -3479,6 +3525,11 @@ char-regex@^2.0.0: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-2.0.2.tgz#81385bb071af4df774bff8721d0ca15ef29ea0bb" integrity sha512-cbGOjAptfM2LVmWhwRFHEKTPkLwNddVmuqYZQt895yXwAsWsXObCG+YN4DGQ/JBtT4GP1a1lPPdio2z413LmTg== +character-entities-html4@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b" + integrity sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA== + character-entities-legacy@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz#76bc83a90738901d7bc223a9e93759fdd560125b" @@ -3622,6 +3673,11 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +comma-separated-tokens@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" + integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg== + commander@^10.0.0: version "10.0.1" resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" @@ -3953,7 +4009,7 @@ detect-node-es@^1.1.0: resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== -devlop@^1.0.0: +devlop@^1.0.0, devlop@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018" integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA== @@ -4369,6 +4425,11 @@ estraverse@^5.1.0, estraverse@^5.2.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +estree-util-is-identifier-name@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz#0b5ef4c4ff13508b34dcd01ecfa945f61fce5dbd" + integrity sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg== + estree-walker@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" @@ -4514,6 +4575,11 @@ exsolve@^1.0.1: resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.5.tgz#1f5b6b4fe82ad6b28a173ccb955a635d77859dcf" integrity sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg== +extend@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -4708,6 +4774,15 @@ forwarded@0.2.0: resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== +framer-motion@^12.17.0: + version "12.17.0" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.17.0.tgz#1f4c68e396f5f28860a84e102d92f28db44a85de" + integrity sha512-2hISKgDk49yCLStwG1wf4Kdy/D6eBw9/eRNaWFIYoI9vMQ/Mqd1Fz+gzVlEtxJmtQ9y4IWnXm19/+UXD3dAYAA== + dependencies: + motion-dom "^12.17.0" + motion-utils "^12.12.1" + tslib "^2.4.0" + fresh@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" @@ -4946,6 +5021,34 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hast-util-to-jsx-runtime@^2.0.0: + version "2.3.6" + resolved "https://registry.yarnpkg.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz#ff31897aae59f62232e21594eac7ef6b63333e98" + integrity sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg== + dependencies: + "@types/estree" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + comma-separated-tokens "^2.0.0" + devlop "^1.0.0" + estree-util-is-identifier-name "^3.0.0" + hast-util-whitespace "^3.0.0" + mdast-util-mdx-expression "^2.0.0" + mdast-util-mdx-jsx "^3.0.0" + mdast-util-mdxjs-esm "^2.0.0" + property-information "^7.0.0" + space-separated-tokens "^2.0.0" + style-to-js "^1.0.0" + unist-util-position "^5.0.0" + vfile-message "^4.0.0" + +hast-util-whitespace@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz#7778ed9d3c92dd9e8c5c8f648a49c21fc51cb621" + integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw== + dependencies: + "@types/hast" "^3.0.0" + he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" @@ -4968,6 +5071,11 @@ html-escaper@^2.0.0: resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== +html-url-attributes@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz#83b052cd5e437071b756cd74ae70f708870c2d87" + integrity sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ== + htmlparser2@^3.9.2: version "3.10.1" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" @@ -5085,6 +5193,11 @@ ini@~4.1.0: resolved "https://registry.yarnpkg.com/ini/-/ini-4.1.3.tgz#4c359675a6071a46985eb39b14e4a2c0ec98a795" integrity sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg== +inline-style-parser@0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.4.tgz#f4af5fe72e612839fcd453d989a586566d695f22" + integrity sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q== + input-otp@^1.4.2: version "1.4.2" resolved "https://registry.yarnpkg.com/input-otp/-/input-otp-1.4.2.tgz#f4d3d587d0f641729e55029b3b8c4870847f4f07" @@ -5182,6 +5295,11 @@ is-obj@^3.0.0: resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-3.0.0.tgz#b0889f1f9f8cb87e87df53a8d1230a2250f8b9be" integrity sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ== +is-plain-obj@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" + integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== + is-promise@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" @@ -6050,6 +6168,11 @@ loglevel@^1.9.2: resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.9.2.tgz#c2e028d6c757720107df4e64508530db6621ba08" integrity sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg== +longest-streak@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4" + integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g== + loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -6181,6 +6304,111 @@ math-intrinsics@^1.1.0: resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== +mdast-util-from-markdown@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz#4850390ca7cf17413a9b9a0fbefcd1bc0eb4160a" + integrity sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA== + dependencies: + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + mdast-util-to-string "^4.0.0" + micromark "^4.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-decode-string "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + unist-util-stringify-position "^4.0.0" + +mdast-util-mdx-expression@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz#43f0abac9adc756e2086f63822a38c8d3c3a5096" + integrity sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ== + dependencies: + "@types/estree-jsx" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-mdx-jsx@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz#fd04c67a2a7499efb905a8a5c578dddc9fdada0d" + integrity sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q== + dependencies: + "@types/estree-jsx" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + ccount "^2.0.0" + devlop "^1.1.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + parse-entities "^4.0.0" + stringify-entities "^4.0.0" + unist-util-stringify-position "^4.0.0" + vfile-message "^4.0.0" + +mdast-util-mdxjs-esm@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz#019cfbe757ad62dd557db35a695e7314bcc9fa97" + integrity sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg== + dependencies: + "@types/estree-jsx" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-phrasing@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz#7cc0a8dec30eaf04b7b1a9661a92adb3382aa6e3" + integrity sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w== + dependencies: + "@types/mdast" "^4.0.0" + unist-util-is "^6.0.0" + +mdast-util-to-hast@^13.0.0: + version "13.2.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz#5ca58e5b921cc0a3ded1bc02eed79a4fe4fe41f4" + integrity sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@ungap/structured-clone" "^1.0.0" + devlop "^1.0.0" + micromark-util-sanitize-uri "^2.0.0" + trim-lines "^3.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + +mdast-util-to-markdown@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz#f910ffe60897f04bb4b7e7ee434486f76288361b" + integrity sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA== + dependencies: + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + longest-streak "^3.0.0" + mdast-util-phrasing "^4.0.0" + mdast-util-to-string "^4.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-decode-string "^2.0.0" + unist-util-visit "^5.0.0" + zwitch "^2.0.0" + +mdast-util-to-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz#7a5121475556a04e7eddeb67b264aae79d312814" + integrity sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg== + dependencies: + "@types/mdast" "^4.0.0" + mdurl@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0" @@ -6375,6 +6603,16 @@ micromark-util-decode-numeric-character-reference@^2.0.0: dependencies: micromark-util-symbol "^2.0.0" +micromark-util-decode-string@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz#6cb99582e5d271e84efca8e61a807994d7161eb2" + integrity sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ== + dependencies: + decode-named-character-reference "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-encode@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8" @@ -6428,7 +6666,7 @@ micromark-util-types@2.0.2, micromark-util-types@^2.0.0: resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e" integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA== -micromark@4.0.2: +micromark@4.0.2, micromark@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/micromark/-/micromark-4.0.2.tgz#91395a3e1884a198e62116e33c9c568e39936fdb" integrity sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA== @@ -6563,6 +6801,18 @@ mlly@^1.7.4: pkg-types "^1.3.0" ufo "^1.5.4" +motion-dom@^12.17.0: + version "12.17.0" + resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.17.0.tgz#69661708d0a0859042c3003cddf31d544c46d9fd" + integrity sha512-FA6/c70R9NKs3g41XDVONzmUUrEmyaifLVGCWtAmHP0usDnX9W+RN/tmbC4EUl0w6yLGvMTOwnWCFVgA5luhRg== + dependencies: + motion-utils "^12.12.1" + +motion-utils@^12.12.1: + version "12.12.1" + resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.12.1.tgz#63e28751325cb9d1cd684f3c273a570022b0010e" + integrity sha512-f9qiqUHm7hWSLlNW8gS9pisnsN7CRFRD58vNjptKdsqFLpkVnX00TNeD6Q0d27V9KzT7ySFyK1TZ/DShfVOv6w== + mrmime@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc" @@ -7085,6 +7335,11 @@ prop-types@^15.6.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +property-information@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d" + integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ== + proxy-addr@^2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -7218,6 +7473,23 @@ react-is@^18.0.0, react-is@^18.3.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== +react-markdown@^10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-10.1.0.tgz#e22bc20faddbc07605c15284255653c0f3bad5ca" + integrity sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + hast-util-to-jsx-runtime "^2.0.0" + html-url-attributes "^3.0.0" + mdast-util-to-hast "^13.0.0" + remark-parse "^11.0.0" + remark-rehype "^11.0.0" + unified "^11.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + react-refresh@^0.17.0: version "0.17.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.17.0.tgz#b7e579c3657f23d04eccbe4ad2e58a8ed51e7e53" @@ -7342,6 +7614,27 @@ release-zalgo@^1.0.0: dependencies: es6-error "^4.0.1" +remark-parse@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-11.0.0.tgz#aa60743fcb37ebf6b069204eb4da304e40db45a1" + integrity sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-from-markdown "^2.0.0" + micromark-util-types "^2.0.0" + unified "^11.0.0" + +remark-rehype@^11.0.0: + version "11.1.2" + resolved "https://registry.yarnpkg.com/remark-rehype/-/remark-rehype-11.1.2.tgz#2addaadda80ca9bd9aa0da763e74d16327683b37" + integrity sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + mdast-util-to-hast "^13.0.0" + unified "^11.0.0" + vfile "^6.0.0" + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -7709,6 +8002,11 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +space-separated-tokens@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f" + integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q== + spawn-wrap@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/spawn-wrap/-/spawn-wrap-2.0.0.tgz#103685b8b8f9b79771318827aa78650a610d457e" @@ -7842,6 +8140,14 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" +stringify-entities@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-4.0.4.tgz#b3b79ef5f277cc4ac73caeb0236c5ba939b3a4f3" + integrity sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg== + dependencies: + character-entities-html4 "^2.0.0" + character-entities-legacy "^3.0.0" + stringify-object@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-5.0.0.tgz#d5b05649fedaf8860640471641f70906fea7f351" @@ -7911,6 +8217,20 @@ strip-json-comments@^3.1.1, strip-json-comments@~3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +style-to-js@^1.0.0: + version "1.1.16" + resolved "https://registry.yarnpkg.com/style-to-js/-/style-to-js-1.1.16.tgz#e6bd6cd29e250bcf8fa5e6591d07ced7575dbe7a" + integrity sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw== + dependencies: + style-to-object "1.0.8" + +style-to-object@1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-1.0.8.tgz#67a29bca47eaa587db18118d68f9d95955e81292" + integrity sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g== + dependencies: + inline-style-parser "0.2.4" + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -8045,6 +8365,16 @@ tree-kill@^1.2.2: resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== +trim-lines@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" + integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== + +trough@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f" + integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw== + ts-api-utils@^2.0.1, ts-api-utils@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" @@ -8164,6 +8494,57 @@ undici-types@~6.21.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== +unified@^11.0.0: + version "11.0.5" + resolved "https://registry.yarnpkg.com/unified/-/unified-11.0.5.tgz#f66677610a5c0a9ee90cab2b8d4d66037026d9e1" + integrity sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA== + dependencies: + "@types/unist" "^3.0.0" + bail "^2.0.0" + devlop "^1.0.0" + extend "^3.0.0" + is-plain-obj "^4.0.0" + trough "^2.0.0" + vfile "^6.0.0" + +unist-util-is@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.0.tgz#b775956486aff107a9ded971d996c173374be424" + integrity sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-position@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-5.0.0.tgz#678f20ab5ca1207a97d7ea8a388373c9cf896be4" + integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-stringify-position@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2" + integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-visit-parents@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz#4d5f85755c3b8f0dc69e21eca5d6d82d22162815" + integrity sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + +unist-util-visit@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.0.0.tgz#a7de1f31f72ffd3519ea71814cccf5fd6a9217d6" + integrity sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + unist-util-visit-parents "^6.0.0" + universalify@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" @@ -8261,6 +8642,22 @@ vaul@^1.1.2: dependencies: "@radix-ui/react-dialog" "^1.1.1" +vfile-message@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.2.tgz#c883c9f677c72c166362fd635f21fc165a7d1181" + integrity sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-stringify-position "^4.0.0" + +vfile@^6.0.0: + version "6.0.3" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-6.0.3.tgz#3652ab1c496531852bf55a6bac57af981ebc38ab" + integrity sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q== + dependencies: + "@types/unist" "^3.0.0" + vfile-message "^4.0.0" + victory-vendor@^36.6.8: version "36.9.2" resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-36.9.2.tgz#668b02a448fa4ea0f788dbf4228b7e64669ff801" @@ -8599,3 +8996,8 @@ zod@^3.23.8, zod@^3.24.2: version "3.24.4" resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.4.tgz#e2e2cca5faaa012d76e527d0d36622e0a90c315f" integrity sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg== + +zwitch@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" + integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==