@cxxi-lab/hub-ui 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/fonts/MerriweatherSans/MerriweatherSans-Bold.woff +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-Bold.woff2 +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-BoldItalic.woff +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-BoldItalic.woff2 +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-ExtraBold.woff +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-ExtraBold.woff2 +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-ExtraBoldItalic.woff +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-ExtraBoldItalic.woff2 +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-Italic.woff +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-Italic.woff2 +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-Light.woff +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-Light.woff2 +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-LightItalic.woff +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-LightItalic.woff2 +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-Medium.woff +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-Medium.woff2 +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-MediumItalic.woff +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-MediumItalic.woff2 +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-Regular.woff +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-Regular.woff2 +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-SemiBold.woff +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-SemiBold.woff2 +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-SemiBoldItalic.woff +0 -0
- package/fonts/MerriweatherSans/MerriweatherSans-SemiBoldItalic.woff2 +0 -0
- package/fonts/Sofia/Sofia-Regular.woff +0 -0
- package/fonts/Sofia/Sofia-Regular.woff2 +0 -0
- package/package.json +45 -5
- package/src/Brand/Brand.tsx +23 -0
- package/src/Brand/Brand.types.ts +6 -0
- package/src/Brand/Brand.variants.ts +9 -0
- package/src/Brand/HubLogo.tsx +22 -0
- package/src/Brand/index.ts +2 -0
- package/src/back.ts +52 -0
- package/src/buttons/Button/Button.tsx +38 -0
- package/src/buttons/Button/Button.types.ts +18 -0
- package/src/buttons/Button/Button.variants.ts +36 -0
- package/src/buttons/Button/index.ts +2 -0
- package/src/buttons/FavButton/FavButton.tsx +29 -0
- package/src/buttons/FavButton/FavButton.types.ts +10 -0
- package/src/buttons/FavButton/FavButton.variants.ts +12 -0
- package/src/buttons/FavButton/index.ts +2 -0
- package/src/buttons/IconButton/IconButton.tsx +40 -0
- package/src/buttons/IconButton/IconButton.types.ts +23 -0
- package/src/buttons/IconButton/IconButton.variants.ts +56 -0
- package/src/buttons/IconButton/index.ts +2 -0
- package/src/buttons/index.ts +3 -0
- package/src/contexts/FormContext.tsx +57 -0
- package/src/forms/CheckboxField/Checkbox.tsx +17 -0
- package/src/forms/CheckboxField/CheckboxField.tsx +39 -0
- package/src/forms/CheckboxField/CheckboxField.types.ts +5 -0
- package/src/forms/CheckboxField/CheckboxField.variants.ts +36 -0
- package/src/forms/CheckboxField/index.ts +3 -0
- package/src/forms/DropdownField/DropdownField.tsx +72 -0
- package/src/forms/DropdownField/DropdownField.types.ts +11 -0
- package/src/forms/DropdownField/DropdownField.variants.ts +37 -0
- package/src/forms/DropdownField/index.ts +2 -0
- package/src/forms/Form/Form.tsx +41 -0
- package/src/forms/Form/Form.types.ts +11 -0
- package/src/forms/Form/Form.variants.ts +8 -0
- package/src/forms/Form/index.ts +2 -0
- package/src/forms/FormField/FormField.tsx +26 -0
- package/src/forms/FormField/FormField.types.ts +10 -0
- package/src/forms/FormField/FormField.variants.ts +44 -0
- package/src/forms/FormField/index.ts +3 -0
- package/src/forms/InputField/InputField.tsx +33 -0
- package/src/forms/InputField/InputField.types.ts +14 -0
- package/src/forms/InputField/InputField.variants.ts +14 -0
- package/src/forms/InputField/index.ts +2 -0
- package/src/forms/SpoilerField/SpoilerField.tsx +48 -0
- package/src/forms/SpoilerField/SpoilerField.types.ts +8 -0
- package/src/forms/SpoilerField/SpoilerField.variants.ts +31 -0
- package/src/forms/SpoilerField/index.ts +2 -0
- package/src/forms/SwitchField/ChoiceSwitch.tsx +43 -0
- package/src/forms/SwitchField/Switch.tsx +18 -0
- package/src/forms/SwitchField/SwitchField.tsx +43 -0
- package/src/forms/SwitchField/SwitchField.types.ts +5 -0
- package/src/forms/SwitchField/SwitchField.variants.ts +62 -0
- package/src/forms/SwitchField/index.ts +4 -0
- package/src/forms/TagsField/TagsField.tsx +109 -0
- package/src/forms/TagsField/TagsField.types.ts +7 -0
- package/src/forms/TagsField/TagsField.variants.ts +51 -0
- package/src/forms/TagsField/index.ts +2 -0
- package/src/forms/TextareaField/TextareaField.tsx +33 -0
- package/src/forms/TextareaField/TextareaField.types.ts +9 -0
- package/src/forms/TextareaField/TextareaField.variants.ts +14 -0
- package/src/forms/TextareaField/index.ts +2 -0
- package/src/forms/UploadField/UploadField.tsx +70 -0
- package/src/forms/UploadField/UploadField.types.ts +7 -0
- package/src/forms/UploadField/UploadField.variants.ts +23 -0
- package/src/forms/UploadField/index.ts +2 -0
- package/src/forms/index.ts +10 -0
- package/src/index.ts +15 -0
- package/src/menus/Dropdown/Dropdown.tsx +39 -0
- package/src/menus/Dropdown/Dropdown.types.ts +17 -0
- package/src/menus/Dropdown/Dropdown.variants.ts +44 -0
- package/src/menus/Dropdown/index.ts +2 -0
- package/src/menus/index.ts +1 -0
- package/src/modals/ConfirmationModal/ConfirmationModal.tsx +44 -0
- package/src/modals/ConfirmationModal/ConfirmationModal.types.ts +14 -0
- package/src/modals/ConfirmationModal/ConfirmationModal.variants.ts +6 -0
- package/src/modals/ConfirmationModal/index.ts +2 -0
- package/src/modals/FormModal/FormModal.tsx +46 -0
- package/src/modals/FormModal/FormModal.types.ts +14 -0
- package/src/modals/FormModal/FormModal.variants.ts +5 -0
- package/src/modals/FormModal/index.ts +2 -0
- package/src/modals/InfoModal/InfoModal.tsx +21 -0
- package/src/modals/InfoModal/InfoModal.types.ts +10 -0
- package/src/modals/InfoModal/InfoModal.variants.ts +6 -0
- package/src/modals/InfoModal/index.ts +2 -0
- package/src/modals/Modal/Modal.tsx +34 -0
- package/src/modals/Modal/Modal.types.ts +10 -0
- package/src/modals/Modal/Modal.variants.ts +48 -0
- package/src/modals/Modal/index.ts +2 -0
- package/src/modals/index.ts +4 -0
- package/src/navs/BreadCrumb/BreadCrumb.tsx +19 -0
- package/src/navs/BreadCrumb/BreadCrumb.types.ts +11 -0
- package/src/navs/BreadCrumb/BreadCrumb.variants.ts +7 -0
- package/src/navs/BreadCrumb/index.ts +2 -0
- package/src/navs/index.ts +1 -0
- package/src/tables/Table/Table.tsx +6 -0
- package/src/tables/Table/Table.types.ts +6 -0
- package/src/tables/Table/Table.variants.ts +5 -0
- package/src/tables/Table/index.ts +2 -0
- package/src/tables/TableCell/TableCell.tsx +27 -0
- package/src/tables/TableCell/TableCell.types.ts +9 -0
- package/src/tables/TableCell/TableCell.variants.ts +26 -0
- package/src/tables/TableCell/index.ts +2 -0
- package/src/tables/TableFootGroup/TableFootGroup.tsx +10 -0
- package/src/tables/TableFootGroup/TableFootGroup.variants.ts +6 -0
- package/src/tables/TableFootGroup/index.ts +1 -0
- package/src/tables/TableHead/TableHead.tsx +22 -0
- package/src/tables/TableHead/TableHead.types.ts +9 -0
- package/src/tables/TableHead/TableHead.variants.ts +16 -0
- package/src/tables/TableHead/index.ts +2 -0
- package/src/tables/TableHeadGroup/TableHeadGroup.tsx +10 -0
- package/src/tables/TableHeadGroup/TableHeadGroup.variants.ts +6 -0
- package/src/tables/TableHeadGroup/index.ts +1 -0
- package/src/tables/TableRow/TableRow.tsx +7 -0
- package/src/tables/TableRow/TableRow.types.ts +7 -0
- package/src/tables/TableRow/TableRow.variants.ts +12 -0
- package/src/tables/TableRow/index.ts +2 -0
- package/src/tables/TableRowGroup/TableRowGroup.tsx +6 -0
- package/src/tables/TableRowGroup/TableRowGroup.variants.ts +5 -0
- package/src/tables/TableRowGroup/index.ts +1 -0
- package/src/tables/index.ts +7 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/snakeCase.ts +6 -0
- package/src/utils/useClickOutside.ts +16 -0
- package/src/utils/useMediaQuery.ts +11 -0
- package/src/utils/withLoading.ts +13 -0
- package/styles/fonts.css +116 -0
- package/styles/theme.css +78 -0
- package/README.md +0 -3
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useFormContext } from '../../contexts/FormContext'
|
|
2
|
+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
3
|
+
import { faCheck } from '@fortawesome/free-solid-svg-icons'
|
|
4
|
+
import { snakeCase } from '../../utils'
|
|
5
|
+
import { type CheckboxFieldProps } from './CheckboxField.types'
|
|
6
|
+
import { styles } from './CheckboxField.variants'
|
|
7
|
+
|
|
8
|
+
export function CheckboxField({ name, label, required = false }: CheckboxFieldProps) {
|
|
9
|
+
const { data, setField } = useFormContext()
|
|
10
|
+
const id = `field_${snakeCase(name)}`
|
|
11
|
+
const checked = Boolean(data[name])
|
|
12
|
+
|
|
13
|
+
const toggle = () => setField(name, !checked)
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div className={styles.field()} onClick={toggle} aria-pressed={checked} aria-controls={id}>
|
|
17
|
+
<div>
|
|
18
|
+
<button type="button" className={styles.check({ checked })}>
|
|
19
|
+
<FontAwesomeIcon icon={faCheck} />
|
|
20
|
+
</button>
|
|
21
|
+
<input
|
|
22
|
+
id={id}
|
|
23
|
+
name={name}
|
|
24
|
+
type="checkbox"
|
|
25
|
+
checked={checked}
|
|
26
|
+
className="hidden"
|
|
27
|
+
readOnly
|
|
28
|
+
required={required}
|
|
29
|
+
/>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
{label && (
|
|
33
|
+
<label htmlFor={id} className={styles.label({ required })}>
|
|
34
|
+
{label}
|
|
35
|
+
</label>
|
|
36
|
+
)}
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { tv } from 'tailwind-variants'
|
|
2
|
+
|
|
3
|
+
export const styles = {
|
|
4
|
+
field: tv({ base: 'flex items-center gap-2 mb-4 cursor-pointer' }),
|
|
5
|
+
|
|
6
|
+
label: tv({
|
|
7
|
+
base: 'text-sm font-normal pointer-events-none',
|
|
8
|
+
variants: {
|
|
9
|
+
required: {
|
|
10
|
+
true: "after:content-['*'] after:pl-[2px] after:text-red-100"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}),
|
|
14
|
+
|
|
15
|
+
check: tv({
|
|
16
|
+
base: [
|
|
17
|
+
'size-[14px]',
|
|
18
|
+
'text-[9px]',
|
|
19
|
+
'flex',
|
|
20
|
+
'justify-center',
|
|
21
|
+
'items-center',
|
|
22
|
+
'rounded-xs',
|
|
23
|
+
'border',
|
|
24
|
+
'cursor-pointer'
|
|
25
|
+
],
|
|
26
|
+
variants: {
|
|
27
|
+
checked: {
|
|
28
|
+
true: 'bg-primary-700 text-primary-500 border-primary-500',
|
|
29
|
+
false: 'bg-transparent text-transparent border-steel-300'
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
defaultVariants: {
|
|
33
|
+
checked: false
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useEffect, useMemo } from 'react'
|
|
2
|
+
import { useFormContext } from '../../contexts/FormContext'
|
|
3
|
+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
4
|
+
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
|
|
5
|
+
import { FormField } from '../FormField'
|
|
6
|
+
import { Dropdown } from '../../menus'
|
|
7
|
+
import { type DropdownFieldProps } from './DropdownField.types'
|
|
8
|
+
import { styles } from './DropdownField.variants'
|
|
9
|
+
|
|
10
|
+
export function DropdownField({ name, label, choices, required = false }: DropdownFieldProps) {
|
|
11
|
+
const { data, setField } = useFormContext()
|
|
12
|
+
|
|
13
|
+
const allChoices = useMemo(
|
|
14
|
+
() => (required ? choices : [{ value: '', label: 'No selection' }, ...choices]),
|
|
15
|
+
[choices, required]
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (data[name] === undefined) {
|
|
20
|
+
setField(name, allChoices[0]?.value ?? '')
|
|
21
|
+
}
|
|
22
|
+
}, [allChoices, data, name, setField])
|
|
23
|
+
|
|
24
|
+
const selected = data[name] as string | undefined
|
|
25
|
+
const selectedLabel = allChoices.find(c => c.value === selected)?.label ?? 'Select an option'
|
|
26
|
+
|
|
27
|
+
const handleSelection = (value: string) => setField(name, value)
|
|
28
|
+
|
|
29
|
+
const dropdownHeader = (isOpen: boolean) => (
|
|
30
|
+
<div className={styles.inputWrapper({ open: isOpen })}>
|
|
31
|
+
<div className={styles.input({ open: isOpen })}>
|
|
32
|
+
<span className={styles.inputText()}>{selectedLabel}</span>
|
|
33
|
+
<FontAwesomeIcon icon={faChevronDown} className={styles.inputIcon()} />
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<FormField name={name} label={label} required={required}>
|
|
40
|
+
{id => (
|
|
41
|
+
<div className={styles.field()}>
|
|
42
|
+
<select
|
|
43
|
+
id={id}
|
|
44
|
+
name={name}
|
|
45
|
+
value={selected ?? ''}
|
|
46
|
+
required={required}
|
|
47
|
+
className={styles.htmlSelect()}
|
|
48
|
+
onChange={e => handleSelection(e.target.value)}
|
|
49
|
+
>
|
|
50
|
+
{allChoices.map(c => (
|
|
51
|
+
<option key={c.value} value={c.value}>{c.label}</option>
|
|
52
|
+
))}
|
|
53
|
+
</select>
|
|
54
|
+
|
|
55
|
+
<Dropdown side="center" color="field" header={dropdownHeader}>
|
|
56
|
+
<ul className={styles.choices()}>
|
|
57
|
+
{allChoices.map(c => (
|
|
58
|
+
<li
|
|
59
|
+
key={c.value}
|
|
60
|
+
className={styles.choice()}
|
|
61
|
+
onClick={() => handleSelection(c.value)}
|
|
62
|
+
>
|
|
63
|
+
{c.label}
|
|
64
|
+
</li>
|
|
65
|
+
))}
|
|
66
|
+
</ul>
|
|
67
|
+
</Dropdown>
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
</FormField>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { styles as fieldStyles } from '../FormField'
|
|
2
|
+
import { tv } from 'tailwind-variants'
|
|
3
|
+
|
|
4
|
+
export const styles = {
|
|
5
|
+
field: tv({ base: 'relative w-full pb-1' }),
|
|
6
|
+
|
|
7
|
+
htmlSelect: tv({ base: 'hidden absolute w-full' }),
|
|
8
|
+
|
|
9
|
+
inputWrapper: tv({
|
|
10
|
+
base: ['w-full', 'rounded-xs', 'border', 'border-transparent'],
|
|
11
|
+
variants: {
|
|
12
|
+
open: { true: 'border-primary-300' }
|
|
13
|
+
}
|
|
14
|
+
}),
|
|
15
|
+
|
|
16
|
+
input: tv({
|
|
17
|
+
extend: fieldStyles.input,
|
|
18
|
+
base: [
|
|
19
|
+
'relative',
|
|
20
|
+
'w-full',
|
|
21
|
+
'flex',
|
|
22
|
+
'items-center',
|
|
23
|
+
'h-11',
|
|
24
|
+
'bg-steel-800',
|
|
25
|
+
'cursor-pointer'
|
|
26
|
+
],
|
|
27
|
+
variants: {
|
|
28
|
+
open: { true: 'border-primary-300' }
|
|
29
|
+
}
|
|
30
|
+
}),
|
|
31
|
+
|
|
32
|
+
inputText: tv({ base: 'flex-1' }),
|
|
33
|
+
inputIcon: tv({ base: 'mr-1' }),
|
|
34
|
+
|
|
35
|
+
choices: tv({ base: 'py-1 my-2' }),
|
|
36
|
+
choice: tv({ base: 'py-1 px-3 text-sm hover:bg-steel-700 cursor-pointer' })
|
|
37
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useState, type FormEvent } from 'react'
|
|
2
|
+
import { FormContextProvider, useFormContext } from '../../contexts/FormContext'
|
|
3
|
+
import { withLoading } from '../../utils'
|
|
4
|
+
import { type FormProps } from './Form.types'
|
|
5
|
+
import { styles } from './Form.variants'
|
|
6
|
+
|
|
7
|
+
export function Form({ initialData, children, ...props }: FormProps) {
|
|
8
|
+
return (
|
|
9
|
+
<FormContextProvider initialData={initialData}>
|
|
10
|
+
<InnerForm {...props}>{children}</InnerForm>
|
|
11
|
+
</FormContextProvider>
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type InnerProps = Omit<FormProps, 'initialData'>
|
|
16
|
+
|
|
17
|
+
function InnerForm({ title, actions, onSubmit, children, className }: InnerProps) {
|
|
18
|
+
const { data } = useFormContext()
|
|
19
|
+
const [, setLoading] = useState(false)
|
|
20
|
+
|
|
21
|
+
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
|
22
|
+
e.preventDefault()
|
|
23
|
+
await withLoading(setLoading, () => Promise.resolve(onSubmit(data)))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<form onSubmit={handleSubmit} className={className}>
|
|
28
|
+
{title && (
|
|
29
|
+
<header className={styles.header()}>
|
|
30
|
+
<h1 className={styles.title()}>{title}</h1>
|
|
31
|
+
</header>
|
|
32
|
+
)}
|
|
33
|
+
|
|
34
|
+
<div className={styles.content()}>{children}</div>
|
|
35
|
+
|
|
36
|
+
{actions && (
|
|
37
|
+
<footer className={styles.footer()}>{actions}</footer>
|
|
38
|
+
)}
|
|
39
|
+
</form>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ReactNode } from 'react'
|
|
2
|
+
import { type FormData } from '../../contexts/FormContext'
|
|
3
|
+
|
|
4
|
+
export interface FormProps {
|
|
5
|
+
initialData?: FormData
|
|
6
|
+
title?: string
|
|
7
|
+
actions?: ReactNode
|
|
8
|
+
onSubmit: (data: FormData) => void | Promise<void>
|
|
9
|
+
children: ReactNode
|
|
10
|
+
className?: string
|
|
11
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useFormContext } from '../../contexts/FormContext'
|
|
2
|
+
import { snakeCase } from '../../utils'
|
|
3
|
+
import { type FormFieldProps } from './FormField.types'
|
|
4
|
+
import { styles } from './FormField.variants'
|
|
5
|
+
|
|
6
|
+
export function FormField({ name, label, required = false, children }: FormFieldProps) {
|
|
7
|
+
const { errors } = useFormContext()
|
|
8
|
+
const id = `field_${snakeCase(name)}`
|
|
9
|
+
const hasError = Boolean(errors[name])
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div className={styles.field()}>
|
|
13
|
+
{label && (
|
|
14
|
+
<label htmlFor={id} className={styles.label({ required })}>
|
|
15
|
+
{label}
|
|
16
|
+
</label>
|
|
17
|
+
)}
|
|
18
|
+
|
|
19
|
+
<div className={styles.inputWrapper()}>
|
|
20
|
+
{children(id, hasError)}
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
{hasError && <p className={styles.error()}>{errors[name]}</p>}
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { tv } from 'tailwind-variants'
|
|
2
|
+
|
|
3
|
+
export const styles = {
|
|
4
|
+
field: tv({ base: 'flex flex-col mb-4' }),
|
|
5
|
+
|
|
6
|
+
label: tv({
|
|
7
|
+
base: 'text-xs font-semibold mb-1',
|
|
8
|
+
variants: {
|
|
9
|
+
required: {
|
|
10
|
+
true: "after:content-['*'] after:pl-[2px] after:text-red-100"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}),
|
|
14
|
+
|
|
15
|
+
inputWrapper: tv({
|
|
16
|
+
base: [
|
|
17
|
+
'w-full',
|
|
18
|
+
'rounded-xs',
|
|
19
|
+
'border',
|
|
20
|
+
'border-transparent',
|
|
21
|
+
'focus-within:border-primary-300'
|
|
22
|
+
]
|
|
23
|
+
}),
|
|
24
|
+
|
|
25
|
+
input: tv({
|
|
26
|
+
base: [
|
|
27
|
+
'w-full',
|
|
28
|
+
'min-w-3xs',
|
|
29
|
+
'py-2',
|
|
30
|
+
'px-[6px]',
|
|
31
|
+
'rounded-xs',
|
|
32
|
+
'border',
|
|
33
|
+
'border-steel-300',
|
|
34
|
+
'focus:border-primary-300',
|
|
35
|
+
'outline-none',
|
|
36
|
+
'text-sm',
|
|
37
|
+
'bg-transparent',
|
|
38
|
+
'text-white',
|
|
39
|
+
'placeholder:text-steel-300'
|
|
40
|
+
]
|
|
41
|
+
}),
|
|
42
|
+
|
|
43
|
+
error: tv({ base: 'pt-1 text-red-500 text-xs' })
|
|
44
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useFormContext } from '../../contexts/FormContext'
|
|
2
|
+
import { FormField } from '../FormField'
|
|
3
|
+
import { type InputFieldProps } from './InputField.types'
|
|
4
|
+
import { styles } from './InputField.variants'
|
|
5
|
+
|
|
6
|
+
export function InputField({
|
|
7
|
+
name,
|
|
8
|
+
label,
|
|
9
|
+
type = 'text',
|
|
10
|
+
required = false,
|
|
11
|
+
placeholder = '',
|
|
12
|
+
...rest
|
|
13
|
+
}: InputFieldProps) {
|
|
14
|
+
const { data, setField } = useFormContext()
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<FormField name={name} label={label} required={required}>
|
|
18
|
+
{(id, hasError) => (
|
|
19
|
+
<input
|
|
20
|
+
id={id}
|
|
21
|
+
name={name}
|
|
22
|
+
type={type}
|
|
23
|
+
value={(data[name] as string | number | undefined) ?? ''}
|
|
24
|
+
placeholder={placeholder}
|
|
25
|
+
className={styles.input({ error: hasError })}
|
|
26
|
+
onChange={e => setField(name, e.target.value)}
|
|
27
|
+
required={required}
|
|
28
|
+
{...rest}
|
|
29
|
+
/>
|
|
30
|
+
)}
|
|
31
|
+
</FormField>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type HTMLInputTypeAttribute } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface InputFieldProps {
|
|
4
|
+
name: string
|
|
5
|
+
label?: string
|
|
6
|
+
type?: HTMLInputTypeAttribute
|
|
7
|
+
required?: boolean
|
|
8
|
+
placeholder?: string
|
|
9
|
+
autoComplete?: string
|
|
10
|
+
autoFocus?: boolean
|
|
11
|
+
min?: number | string
|
|
12
|
+
max?: number | string
|
|
13
|
+
step?: number | string
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { styles as fieldStyles } from '../FormField'
|
|
2
|
+
import { tv } from 'tailwind-variants'
|
|
3
|
+
|
|
4
|
+
export const styles = {
|
|
5
|
+
input: tv({
|
|
6
|
+
extend: fieldStyles.input,
|
|
7
|
+
base: 'h-11',
|
|
8
|
+
variants: {
|
|
9
|
+
error: {
|
|
10
|
+
true: 'border-red-500!'
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useFormContext } from '../../contexts/FormContext'
|
|
3
|
+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
4
|
+
import { faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'
|
|
5
|
+
import { FormField } from '../FormField'
|
|
6
|
+
import { type SpoilerFieldProps } from './SpoilerField.types'
|
|
7
|
+
import { styles } from './SpoilerField.variants'
|
|
8
|
+
|
|
9
|
+
export function SpoilerField({
|
|
10
|
+
name,
|
|
11
|
+
label,
|
|
12
|
+
required = false,
|
|
13
|
+
placeholder = '',
|
|
14
|
+
...rest
|
|
15
|
+
}: SpoilerFieldProps) {
|
|
16
|
+
const { data, setField } = useFormContext()
|
|
17
|
+
const [revealed, setRevealed] = useState(false)
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<FormField name={name} label={label} required={required}>
|
|
21
|
+
{(id, hasError) => (
|
|
22
|
+
<div className={styles.field()}>
|
|
23
|
+
<input
|
|
24
|
+
id={id}
|
|
25
|
+
name={name}
|
|
26
|
+
type={revealed ? 'text' : 'password'}
|
|
27
|
+
value={(data[name] as string | undefined) ?? ''}
|
|
28
|
+
placeholder={placeholder}
|
|
29
|
+
className={styles.input({ error: hasError })}
|
|
30
|
+
onChange={e => setField(name, e.target.value)}
|
|
31
|
+
required={required}
|
|
32
|
+
{...rest}
|
|
33
|
+
/>
|
|
34
|
+
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
onClick={() => setRevealed(r => !r)}
|
|
38
|
+
className={styles.revealer()}
|
|
39
|
+
tabIndex={-1}
|
|
40
|
+
aria-label={revealed ? 'Hide value' : 'Reveal value'}
|
|
41
|
+
>
|
|
42
|
+
<FontAwesomeIcon icon={revealed ? faEyeSlash : faEye} />
|
|
43
|
+
</button>
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
</FormField>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { styles as fieldStyles } from '../FormField'
|
|
2
|
+
import { tv } from 'tailwind-variants'
|
|
3
|
+
|
|
4
|
+
export const styles = {
|
|
5
|
+
field: tv({ base: 'relative' }),
|
|
6
|
+
|
|
7
|
+
input: tv({
|
|
8
|
+
extend: fieldStyles.input,
|
|
9
|
+
base: 'h-11 pr-8',
|
|
10
|
+
variants: {
|
|
11
|
+
error: {
|
|
12
|
+
true: 'border-red-500!'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}),
|
|
16
|
+
|
|
17
|
+
revealer: tv({
|
|
18
|
+
base: [
|
|
19
|
+
'absolute',
|
|
20
|
+
'right-2',
|
|
21
|
+
'top-1/2',
|
|
22
|
+
'-translate-y-1/2',
|
|
23
|
+
'text-steel-300',
|
|
24
|
+
'hover:text-primary-500',
|
|
25
|
+
'focus:outline-none',
|
|
26
|
+
'cursor-pointer',
|
|
27
|
+
'bg-transparent',
|
|
28
|
+
'border-0'
|
|
29
|
+
]
|
|
30
|
+
})
|
|
31
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A switch between two choices (this || that, not on/off): both labels sit in the track, the
|
|
5
|
+
* thumb slides under the picked one. SwitchField's look, blue on both sides.
|
|
6
|
+
*/
|
|
7
|
+
export function ChoiceSwitch<V extends string>({ value, options, label, onChange }: {
|
|
8
|
+
value: V
|
|
9
|
+
/** `count`: a number shown after the label (e.g. results waiting there) */
|
|
10
|
+
options: [{ value: V, label: string, icon?: ReactNode, count?: number }, { value: V, label: string, icon?: ReactNode, count?: number }]
|
|
11
|
+
/** said to screen readers */
|
|
12
|
+
label: string
|
|
13
|
+
onChange: (value: V) => void
|
|
14
|
+
}) {
|
|
15
|
+
const second = value === options[1].value
|
|
16
|
+
return (
|
|
17
|
+
<div role="radiogroup" aria-label={label} className="relative inline-grid grid-cols-2 h-8 p-0.5 border border-steel-300 rounded-xs">
|
|
18
|
+
<span
|
|
19
|
+
aria-hidden
|
|
20
|
+
className={`absolute top-0.5 bottom-0.5 left-0.5 w-[calc(50%-2px)] rounded-xs bg-primary-500 transition-transform duration-200 ${second ? 'translate-x-full' : 'translate-x-0'}`}
|
|
21
|
+
/>
|
|
22
|
+
{options.map(o => {
|
|
23
|
+
const picked = o.value === value
|
|
24
|
+
return (
|
|
25
|
+
<button
|
|
26
|
+
key={o.value}
|
|
27
|
+
type="button"
|
|
28
|
+
role="radio"
|
|
29
|
+
aria-checked={picked}
|
|
30
|
+
onClick={() => onChange(o.value)}
|
|
31
|
+
className={`relative z-10 flex items-center justify-center gap-1.5 px-3 text-xs font-semibold bg-transparent border-0 cursor-pointer transition-colors duration-200 ${picked ? 'text-steel-950' : 'text-steel-100 hover:text-white'}`}
|
|
32
|
+
>
|
|
33
|
+
{o.icon}
|
|
34
|
+
{o.label}
|
|
35
|
+
{o.count !== undefined && (
|
|
36
|
+
<span className={`min-w-5 h-4 px-1 rounded-full text-[10px] leading-4 tabular-nums ${picked ? 'bg-steel-950/25 text-steel-950' : 'bg-steel-800 text-steel-100'}`}>{o.count}</span>
|
|
37
|
+
)}
|
|
38
|
+
</button>
|
|
39
|
+
)
|
|
40
|
+
})}
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { styles } from './SwitchField.variants'
|
|
2
|
+
|
|
3
|
+
/** The SwitchField switch on its own, outside a form: controlled by `checked`. */
|
|
4
|
+
export function Switch({ checked, onChange, title }: { checked: boolean, onChange: (checked: boolean) => void, title?: string }) {
|
|
5
|
+
return (
|
|
6
|
+
<button
|
|
7
|
+
type="button"
|
|
8
|
+
role="switch"
|
|
9
|
+
aria-checked={checked}
|
|
10
|
+
aria-label={title}
|
|
11
|
+
title={title}
|
|
12
|
+
onClick={() => onChange(!checked)}
|
|
13
|
+
className={`${styles.switch({ checked })} shrink-0 p-0 bg-clip-padding`}
|
|
14
|
+
>
|
|
15
|
+
<span className={styles.thumb({ checked })} />
|
|
16
|
+
</button>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useFormContext } from '../../contexts/FormContext'
|
|
2
|
+
import { snakeCase } from '../../utils'
|
|
3
|
+
import { type SwitchFieldProps } from './SwitchField.types'
|
|
4
|
+
import { styles } from './SwitchField.variants'
|
|
5
|
+
|
|
6
|
+
export function SwitchField({ name, label, required = false }: SwitchFieldProps) {
|
|
7
|
+
const { data, setField } = useFormContext()
|
|
8
|
+
const id = `field_${snakeCase(name)}`
|
|
9
|
+
const checked = Boolean(data[name])
|
|
10
|
+
|
|
11
|
+
const toggle = () => setField(name, !checked)
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className={styles.field()} onClick={toggle} aria-pressed={checked} aria-controls={id}>
|
|
15
|
+
{label && (
|
|
16
|
+
<label htmlFor={id} className={styles.label({ required })}>
|
|
17
|
+
{label}
|
|
18
|
+
</label>
|
|
19
|
+
)}
|
|
20
|
+
|
|
21
|
+
<div>
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
className={styles.switch({ checked })}
|
|
25
|
+
role="switch"
|
|
26
|
+
aria-checked={checked}
|
|
27
|
+
>
|
|
28
|
+
<span className={styles.thumb({ checked })} />
|
|
29
|
+
</button>
|
|
30
|
+
|
|
31
|
+
<input
|
|
32
|
+
id={id}
|
|
33
|
+
name={name}
|
|
34
|
+
type="checkbox"
|
|
35
|
+
checked={checked}
|
|
36
|
+
className="hidden"
|
|
37
|
+
readOnly
|
|
38
|
+
required={required}
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|