1
0
Fork 0
freesewing/packages/react/components/Input/index.mjs

680 lines
20 KiB
JavaScript
Raw Normal View History

2024-12-10 12:08:44 +01:00
// Dependencies
import {
cloudflareImageUrl,
measurementAsMm,
measurementAsUnits,
distanceAsMm,
} from '@freesewing/utils'
import { collection } from '@freesewing/collection'
2025-01-05 17:58:31 +01:00
import { measurements as measurementsTranslations } from '@freesewing/i18n'
2024-12-10 12:08:44 +01:00
// Context
import { ModalContext } from '@freesewing/react/context/Modal'
import { LoadingStatusContext } from '@freesewing/react/context/LoadingStatus'
// Hooks
import React, { useState, useCallback, useContext } from 'react'
import { useDropzone } from 'react-dropzone'
import { useBackend } from '@freesewing/react/hooks/useBackend'
// Components
import { ResetIcon, UploadIcon } from '@freesewing/react/components/Icon'
2024-12-10 12:08:44 +01:00
import { ModalWrapper } from '@freesewing/react/components/Modal'
import { isDegreeMeasurement } from '@freesewing/config'
import { Tabs, Tab } from '@freesewing/react/components/Tab'
import Markdown from 'react-markdown'
2024-12-10 12:08:44 +01:00
/*
* Helper component to display a tab heading
*/
export const _Tab = ({
id, // The tab ID
label, // A label for the tab, if not set we'll use the ID
activeTab, // Which tab (id) is active
setActiveTab, // Method to set the active tab
}) => (
<button
2024-12-25 16:54:06 +01:00
className={`tw-text-lg tw-font-bold tw-capitalize tw-daisy-tab tw-daisy-tab-bordered tw-grow
2025-01-02 15:35:51 +01:00
${activeTab === id ? 'tw-daisy-tab-active' : ''}`}
2024-12-10 12:08:44 +01:00
onClick={() => setActiveTab(id)}
>
{label ? label : id}
</button>
)
/*
* Helper component to wrap a form control with a label
*/
export const FormControl = ({
label, // the (top-left) label
children, // Children to go inside the form control
labelBL = false, // Optional bottom-left label
labelBR = false, // Optional bottom-right label
forId = false, // ID of the for element we are wrapping
}) => {
if (labelBR && !labelBL) labelBL = <span></span>
const topLabelChildren = (
2024-12-25 16:54:06 +01:00
<span className="tw-daisy-label-text tw-text-sm lg:tw-text-base tw-font-bold tw-mb-1 tw-text-inherit">
{label}
</span>
2024-12-10 12:08:44 +01:00
)
const bottomLabelChildren = (
<>
2024-12-25 16:54:06 +01:00
{labelBL ? <span className="tw-daisy-label-text-alt">{labelBL}</span> : null}
{labelBR ? <span className="tw-daisy-label-text-alt">{labelBR}</span> : null}
2024-12-10 12:08:44 +01:00
</>
)
return (
2024-12-25 16:54:06 +01:00
<div className="tw-daisy-form-control tw-w-full tw-mt-2">
2024-12-10 12:08:44 +01:00
{forId ? (
2024-12-25 16:54:06 +01:00
<label className="tw-daisy-label tw-pb-0" htmlFor={forId}>
2024-12-10 12:08:44 +01:00
{topLabelChildren}
</label>
2025-01-25 17:56:15 +01:00
) : label ? (
2024-12-25 16:54:06 +01:00
<div className="tw-daisy-label tw-pb-0">{topLabelChildren}</div>
2025-01-25 17:56:15 +01:00
) : null}
2024-12-10 12:08:44 +01:00
{children}
{labelBL || labelBR ? (
forId ? (
2024-12-25 16:54:06 +01:00
<label className="tw-daisy-label" htmlFor={forId}>
2024-12-10 12:08:44 +01:00
{bottomLabelChildren}
</label>
) : (
2024-12-25 16:54:06 +01:00
<div className="tw-daisy-label">{bottomLabelChildren}</div>
2024-12-10 12:08:44 +01:00
)
) : null}
</div>
)
}
/*
* Helper method to wrap content in a button
*/
export const ButtonFrame = ({
children, // Children of the button
onClick, // onClick handler
active, // Whether or not to render the button as active/selected
accordion = false, // Set this to true to not set a background color when active
dense = false, // Use less padding
}) => (
<button
className={`
2025-01-26 17:22:24 +01:00
tw-daisy-btn tw-daisy-btn-ghost tw-daisy-btn-secondary tw-h-fit
2025-01-25 17:56:15 +01:00
tw-w-full ${dense ? 'tw-mt-1 tw-daisy-btn-sm tw-font-light' : 'tw-mt-2 tw-py-4 tw-h-auto tw-content-start'}
2024-12-25 16:54:06 +01:00
tw-border-2 tw-border-secondary tw-text-left tw-bg-opacity-20
${accordion ? 'hover:tw-bg-transparent' : 'hover:tw-bg-secondary hover:tw-bg-opacity-10'}
hover:tw-border-secondary hover:tw-border-solid hover:tw-border-2
${active ? 'tw-border-solid' : 'tw-border-dotted'}
${active && !accordion ? 'tw-bg-secondary' : 'tw-bg-transparent'}
2024-12-10 12:08:44 +01:00
`}
onClick={onClick}
>
{children}
</button>
)
/*
* Input for integers
*/
export const NumberInput = ({
label, // Label to use
update, // onChange handler
valid, // Method that should return whether the value is valid or not
current, // The current value
original, // The original value
placeholder, // The placeholder text
id = '', // An id to tie the input to the label
labelBL = false, // Bottom-Left label
labelBR = false, // Bottom-Right label
max = 0,
min = 220,
step = 1,
}) => (
<FormControl {...{ label, labelBL, labelBR }} forId={id}>
2024-12-10 12:08:44 +01:00
<input
id={id}
type="text"
inputMode="decimal"
placeholder={placeholder}
value={current}
onChange={(evt) => update(evt.target.value)}
2024-12-25 16:54:06 +01:00
className={`tw-daisy-input tw-w-full tw-daisy-input-bordered ${
2024-12-10 12:08:44 +01:00
current === original ? 'input-secondary' : valid(current) ? 'input-success' : 'input-error'
}`}
{...{ max, min, step }}
/>
</FormControl>
)
/*
* Input for strings
*/
export const StringInput = ({
label, // Label to use
update, // onChange handler
valid, // Method that should return whether the value is valid or not
current, // The current value
original, // The original value
placeholder, // The placeholder text
id = '', // An id to tie the input to the label
labelBL = false, // Bottom-Left label
labelBR = false, // Bottom-Right label
}) => (
<FormControl {...{ label, labelBL, labelBR }} forId={id}>
2024-12-10 12:08:44 +01:00
<input
id={id}
type="text"
placeholder={placeholder}
value={current}
onChange={(evt) => update(evt.target.value)}
2024-12-25 16:54:06 +01:00
className={`tw-daisy-input tw-w-full tw-daisy-input-bordered ${
2024-12-10 12:08:44 +01:00
current === original
? 'daisy-input-secondary'
: valid(current)
? 'daisy-input-success'
: 'daisy-input-error'
}`}
/>
</FormControl>
)
/*
* Input for MFA code
*/
export const MfaInput = ({
update, // onChange handler
current, // The current value
id = 'mfa', // An id to tie the input to the label
}) => {
return (
<StringInput
label="MFA Code"
valid={(val) => val.length > 4}
{...{ update, current, id }}
placeholder="MFA Code"
/>
)
}
/*
* Input for passwords
*/
export const PasswordInput = ({
label, // Label to use
update, // onChange handler
valid, // Method that should return whether the value is valid or not
current, // The current value
placeholder = '¯\\_(ツ)_/¯', // The placeholder text
id = '', // An id to tie the input to the label
onKeyDown = false, // Optionall capture certain keys (like enter)
}) => {
const [reveal, setReveal] = useState(false)
const extraProps = onKeyDown ? { onKeyDown } : {}
return (
<FormControl
label={label}
forId={id}
labelBR={
<button
2024-12-25 16:54:06 +01:00
className="tw-btn tw-btn-primary tw-btn-ghost tw-btn-xs tw--mt-2"
2024-12-10 12:08:44 +01:00
onClick={() => setReveal(!reveal)}
>
{reveal ? 'Hide Password' : 'Reveal Password'}
</button>
}
>
<input
id={id}
type={reveal ? 'text' : 'password'}
placeholder={placeholder}
value={current}
onChange={(evt) => update(evt.target.value)}
2024-12-25 16:54:06 +01:00
className={`tw-daisy-input tw-w-full tw-daisy-input-bordered ${
2024-12-10 12:08:44 +01:00
valid(current) ? 'input-success' : 'input-error'
}`}
{...extraProps}
/>
</FormControl>
)
}
/*
* Input for email addresses
*/
export const EmailInput = ({
label, // Label to use
update, // onChange handler
valid, // Method that should return whether the value is valid or not
current, // The current value
original, // The original value
placeholder, // The placeholder text
id = '', // An id to tie the input to the label
labelBL = false, // Bottom-Left label
labelBR = false, // Bottom-Right label
}) => (
<FormControl {...{ label, labelBL, labelBR }} forId={id}>
2024-12-10 12:08:44 +01:00
<input
id={id}
type="email"
placeholder={placeholder}
value={current}
onChange={(evt) => update(evt.target.value)}
2024-12-25 16:54:06 +01:00
className={`tw-daisy-input tw-w-full tw-daisy-input-bordered ${
2024-12-23 18:25:48 +01:00
current === original
2024-12-25 16:54:06 +01:00
? 'tw-daisy-input-secondary'
2024-12-23 18:25:48 +01:00
: valid(current)
2024-12-25 16:54:06 +01:00
? 'tw-daisy-input-success'
: 'tw-daisy-input-error'
2024-12-10 12:08:44 +01:00
}`}
/>
</FormControl>
)
/*
* Input for designs
2024-12-10 12:08:44 +01:00
*/
export const DesignInput = ({
2024-12-10 12:08:44 +01:00
label, // Label to use
update, // onChange handler
current, // The current value
firstOption = null, // Any first option to add in addition to designs
id = '', // An id to tie the input to the label
}) => {
return (
<FormControl label={label} forId={id}>
2024-12-10 12:08:44 +01:00
<select
id={id}
2024-12-25 16:54:06 +01:00
className="tw-daisy-select tw-daisy-select-bordered tw-w-full"
2024-12-10 12:08:44 +01:00
onChange={(evt) => update(evt.target.value)}
value={current}
>
{firstOption}
{collection.map((design) => (
<option key={design} value={design}>
{design}
</option>
))}
</select>
</FormControl>
)
}
/*
* Input for an image
*/
export const ImageInput = ({
label, // The label
update, // The onChange handler
current, // The current value
original, // The original value
active = false, // Whether or not to upload images
imgType = 'showcase', // The image type
imgSubid, // The image sub-id
imgSlug, // The image slug or other unique identifier to use in the image ID
id = '', // An id to tie the input to the label
}) => {
const backend = useBackend()
const { setLoadingStatus } = useContext(LoadingStatusContext)
const [url, setUrl] = useState(false)
const [uploadedId, setUploadedId] = useState(false)
const upload = async (img, fromUrl = false) => {
setLoadingStatus([true, 'uploadingImage'])
const data = {
type: imgType,
subId: imgSubid,
slug: imgSlug,
}
if (fromUrl) data.url = img
else data.img = img
const result = await backend.uploadAnonImage(data)
setLoadingStatus([true, 'allDone', true, true])
if (result.success) {
update(result.data.imgId)
setUploadedId(result.data.imgId)
} else setLoadingStatus([true, 'backendError', true, false])
}
const onDrop = useCallback(
(acceptedFiles) => {
const reader = new FileReader()
reader.onload = async () => {
if (active) upload(reader.result)
else update(reader.result)
}
acceptedFiles.forEach((file) => reader.readAsDataURL(file))
},
[current]
)
const { getRootProps, getInputProps } = useDropzone({ onDrop })
if (current)
return (
<FormControl label={label}>
2024-12-10 12:08:44 +01:00
<div
2024-12-25 16:54:06 +01:00
className="tw-bg-base-100 tw-w-full tw-h-36 tw-mb-2 tw-mx-auto tw-flex tw-flex-col tw-items-center tw-text-center tw-justify-center"
2024-12-10 12:08:44 +01:00
style={{
backgroundImage: `url(${
uploadedId ? cloudflareImageUrl({ type: 'public', id: uploadedId }) : current
})`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
backgroundPosition: '50%',
}}
>
<button
2024-12-25 16:54:06 +01:00
className="tw-daisy-btn tw-daisy-btn-neutral tw-daisy-btn-circle tw-opacity-50 hover:tw-opacity-100"
2024-12-10 12:08:44 +01:00
onClick={() => update(original)}
>
<ResetIcon />
</button>
</div>
</FormControl>
)
return (
<FormControl label={label} forId={id}>
2024-12-10 12:08:44 +01:00
<div
{...getRootProps()}
className={`
2024-12-25 16:54:06 +01:00
tw-flex tw-rounded-lg tw-w-full tw-flex-col tw-items-center tw-justify-center
lg:tw-p-6 lg:tw-border-4 lg:tw-border-secondary lg:tw-border-dashed
2024-12-10 12:08:44 +01:00
`}
>
<input {...getInputProps()} />
2024-12-25 16:54:06 +01:00
<p className="tw-hidden lg:tw-block tw-p-0 tw-m-0">Drag and drop and image here</p>
<p className="tw-hidden lg:tw-block tw-p-0 tw-my-2">or</p>
<button
className={`tw-daisy-btn tw-daisy-btn-secondary tw-daisy-btn-outline tw-mt-4 tw-px-8`}
>
2024-12-10 12:08:44 +01:00
Select an image to use
</button>
</div>
2024-12-25 16:54:06 +01:00
<p className="tw-p-0 tw-my-2 tw-text-center">or</p>
<div className="tw-flex tw-flex-row tw-items-center">
2024-12-10 12:08:44 +01:00
<input
id={id}
type="url"
2024-12-25 16:54:06 +01:00
className="tw-daisy-input tw-daisy-input-secondary tw-w-full tw-daisy-input-bordered"
2024-12-10 12:08:44 +01:00
placeholder="Paste an image URL here"
value={current}
onChange={active ? (evt) => setUrl(evt.target.value) : (evt) => update(evt.target.value)}
/>
{active && (
<button
2024-12-25 16:54:06 +01:00
className="tw-daisy-btn tw-daisy-btn-secondary tw-ml-2 tw-capitalize"
2024-12-10 12:08:44 +01:00
disabled={!url || url.length < 1}
onClick={() => upload(url, true)}
>
<UploadIcon /> Upload
</button>
)}
</div>
</FormControl>
)
}
/*
* Input for an image that is active (it does upload the image)
*/
export const ActiveImageInput = (props) => <ImageInput {...props} active={true} />
/*
* Input for an image that is passive (it does not upload the image)
*/
export const PassiveImageInput = (props) => <ImageInput {...props} active={false} />
/*
* Input for a list of things to pick from
*/
export const ListInput = ({
update, // the onChange handler
label, // The label
list, // The list of items to present { val, label, desc }
current, // The (value of the) current item
}) => (
<FormControl label={label}>
2024-12-10 12:08:44 +01:00
{list.map((item, i) => (
<ButtonFrame key={i} active={item.val === current} onClick={() => update(item.val)}>
2024-12-25 16:54:06 +01:00
<div className="tw-w-full tw-flex tw-flex-col tw-gap-2">
<div className="tw-w-full tw-text-lg tw-leading-5">{item.label}</div>
2024-12-10 12:08:44 +01:00
{item.desc ? (
2024-12-25 16:54:06 +01:00
<div className="tw-w-full tw-text-normal tw-font-normal tw-normal-case tw-pt-1 tw-leading-5">
2024-12-10 12:08:44 +01:00
{item.desc}
</div>
) : null}
</div>
</ButtonFrame>
))}
</FormControl>
)
/*
* Input for markdown content
*/
export const MarkdownInput = ({
label, // The label
current, // The current value (markdown)
update, // The onChange handler
placeholder, // The placeholder content
id = '', // An id to tie the input to the label
labelBL = false, // Bottom-Left label
labelBR = false, // Bottom-Right label
}) => (
<FormControl
{...{ label, labelBR }}
forId={id}
labelBL={labelBL ? labelBL : 'This field supports markdown'}
>
2024-12-10 12:08:44 +01:00
<Tabs tabs={['edit', 'preview']}>
<Tab key="edit">
2024-12-25 16:54:06 +01:00
<div className="tw-flex tw-flex-row tw-items-center tw-mt-2">
2024-12-10 12:08:44 +01:00
<textarea
id={id}
rows="5"
2024-12-25 16:54:06 +01:00
className="tw-daisy-textarea tw-daisy-textarea-bordered tw-daisy-textarea-lg tw-w-full"
2024-12-10 12:08:44 +01:00
value={current}
placeholder={placeholder}
onChange={(evt) => update(evt.target.value)}
/>
</div>
</Tab>
<Tab key="preview">
2024-12-23 18:25:48 +01:00
<div className="mdx markdown">
<Markdown>{current}</Markdown>
2024-12-10 12:08:44 +01:00
</div>
</Tab>
</Tabs>
</FormControl>
)
2025-01-05 13:51:35 +01:00
export const MeasurementInput = ({
2024-12-10 12:08:44 +01:00
imperial, // True for imperial, False for metric
m, // The measurement name
original, // The original value
update, // The onChange handler
placeholder, // The placeholder content
id = '', // An id to tie the input to the label
}) => {
const isDegree = isDegreeMeasurement(m)
const units = imperial ? 'imperial' : 'metric'
const [localVal, setLocalVal] = useState(
typeof original === 'undefined'
? original
: isDegree
? Number(original)
: measurementAsUnits(original, units)
)
const [validatedVal, setValidatedVal] = useState(measurementAsUnits(original, units))
const [valid, setValid] = useState(null)
// Update onChange
const localUpdate = (newVal) => {
setLocalVal(newVal)
2024-12-10 12:16:08 +01:00
const parsedVal = isDegree ? Number(newVal) : distanceAsMm(newVal, imperial)
2024-12-10 12:08:44 +01:00
if (parsedVal) {
update(m, isDegree ? parsedVal : measurementAsMm(parsedVal, units))
setValid(true)
setValidatedVal(parsedVal)
} else setValid(false)
}
if (!m) return null
// Various visual indicators for validating the input
let inputClasses = 'daisy-input-secondary'
2024-12-10 12:08:44 +01:00
let bottomLeftLabel = null
if (valid === true) {
2025-01-05 17:58:31 +01:00
inputClasses = 'daisy-input-success tw-outline-success'
2024-12-10 12:08:44 +01:00
const val = `${validatedVal}${isDegree ? '°' : imperial ? '"' : 'cm'}`
2024-12-25 16:54:06 +01:00
bottomLeftLabel = (
<span className="tw-font-medium tw-text-base tw-text-success tw--mt-2 tw-block">{val}</span>
)
2024-12-10 12:08:44 +01:00
} else if (valid === false) {
inputClasses = 'daisy-input-error'
2024-12-10 12:08:44 +01:00
bottomLeftLabel = (
2024-12-25 16:54:06 +01:00
<span className="tw-font-medium tw-text-error tw-text-base tw--mt-2 tw-block">
¯\_()_/¯
</span>
2024-12-10 12:08:44 +01:00
)
}
/*
* I'm on the fence here about using a text input rather than number
* Obviously, number is the more correct option, but when the user enter
* text, it won't fire an onChange event and thus they can enter text and it
* will not be marked as invalid input.
* See: https://github.com/facebook/react/issues/16554
*/
return (
2025-01-05 17:58:31 +01:00
<FormControl
label={measurementsTranslations[m] + (isDegree ? ' (°)' : '')}
forId={id}
labelBL={bottomLeftLabel}
>
<label
className={`tw-daisy-input tw-daisy-input-bordered tw-flex tw-items-center tw-gap-2 tw-border ${inputClasses} tw-mb-1 tw-outline tw-outline-base-300 tw-bg-transparent tw-outline-2 tw-outline-offset-2`}
>
<input
id={id}
type="text"
inputMode="numeric"
pattern="[0-9]*"
placeholder={placeholder}
value={localVal}
onChange={(evt) => localUpdate(evt.target.value)}
className={`tw-border-0 tw-grow-2 tw-w-full`}
/>
{isDegree ? '°' : imperial ? 'inch' : 'cm'}
</label>
2024-12-10 12:08:44 +01:00
</FormControl>
)
}
export const FileInput = ({
label, // The label
valid = () => true, // Method that should return whether the value is valid or not
update, // The onChange handler
current, // The current value
original, // The original value
id = '', // An id to tie the input to the label
dropzoneConfig = {}, // Configuration for react-dropzone
}) => {
/*
* Ondrop handler
*/
const onDrop = useCallback(
(acceptedFiles) => {
const reader = new FileReader()
reader.onload = async () => update(reader.result)
acceptedFiles.forEach((file) => reader.readAsDataURL(file))
},
[update]
)
/*
* Dropzone hook
*/
const { getRootProps, getInputProps } = useDropzone({ onDrop, ...dropzoneConfig })
/*
* If we have a current file, return this
*/
if (current)
return (
<FormControl label={label} isValid={valid(current)}>
2024-12-25 16:54:06 +01:00
<div className="tw-bg-base-100 tw-w-full tw-h-36 tw-mb-2 tw-mx-auto tw-flex tw-flex-col tw-items-center tw-text-center tw-justify-center">
2024-12-10 12:08:44 +01:00
<button
2024-12-25 16:54:06 +01:00
className="tw-daisy-btn tw-daisy-btn-neutral tw-daisy-btn-circle tw-opacity-50 hover:tw-opacity-100"
2024-12-10 12:08:44 +01:00
onClick={() => update(original)}
>
<ResetIcon />
</button>
</div>
</FormControl>
)
/*
* Return upload form
*/
return (
<FormControl label={label} forId={id} isValid={valid(current)}>
<div
{...getRootProps()}
className={`
2024-12-25 16:54:06 +01:00
tw-flex tw-rounded-lg tw-w-full tw-flex-col tw-items-center tw-justify-center
sm:tw-p-6 sm:tw-border-4 sm:tw-border-secondary sm:tw-border-dashed
2024-12-10 12:08:44 +01:00
`}
>
<input {...getInputProps()} />
2024-12-25 16:54:06 +01:00
<p className="tw-hidden lg:tw-block tw-p-0 tw-m-0">Drag and drop your file here</p>
<button
className={`tw-daisy-btn tw-daisy-btn-secondary tw-daisy-btn-outline tw-mt-4 tw-px-8`}
>
Browse...
</button>
2024-12-10 12:08:44 +01:00
</div>
</FormControl>
)
}
/*
* Input for booleans
*/
export const ToggleInput = ({
label, // Label to use
update, // onChange handler
current, // The current value
disabled = false, // Allows rendering a disabled view
list = [true, false], // The values to chose between
labels = ['Yes', 'No'], // The labels for the values
on = true, // The value that should show the toggle in the 'on' state
id = '', // An id to tie the input to the label
labelTR = false, // Top-Right label
labelBL = false, // Bottom-Left label
labelBR = false, // Bottom-Right label
}) => (
<FormControl
{...{ labelBL, labelBR, labelTR }}
label={
label
? `${label} (${current === on ? labels[0] : labels[1]})`
: `${current === on ? labels[0] : labels[1]}`
}
forId={id}
>
<input
id={id}
disabled={disabled}
type="checkbox"
value={current}
onChange={() => update(list.indexOf(current) === 0 ? list[1] : list[0])}
2024-12-25 16:54:06 +01:00
className="tw-daisy-toggle tw-my-3 tw-daisy-toggle-primary"
2024-12-10 12:08:44 +01:00
checked={list.indexOf(current) === 0 ? true : false}
/>
</FormControl>
)