@cxxi-lab/hub-ui 0.0.0-stage → 0.1.0
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 +44 -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,62 @@
|
|
|
1
|
+
import { tv } from 'tailwind-variants'
|
|
2
|
+
|
|
3
|
+
export const styles = {
|
|
4
|
+
field: tv({
|
|
5
|
+
base: [
|
|
6
|
+
'w-full',
|
|
7
|
+
'h-11',
|
|
8
|
+
'flex',
|
|
9
|
+
'items-center',
|
|
10
|
+
'justify-between',
|
|
11
|
+
'cursor-pointer',
|
|
12
|
+
'select-none'
|
|
13
|
+
]
|
|
14
|
+
}),
|
|
15
|
+
|
|
16
|
+
switch: tv({
|
|
17
|
+
base: [
|
|
18
|
+
'relative',
|
|
19
|
+
'w-10',
|
|
20
|
+
'h-5',
|
|
21
|
+
'border',
|
|
22
|
+
'rounded-xs',
|
|
23
|
+
'transition-colors',
|
|
24
|
+
'duration-200',
|
|
25
|
+
'cursor-pointer'
|
|
26
|
+
],
|
|
27
|
+
variants: {
|
|
28
|
+
checked: {
|
|
29
|
+
true: 'border-steel-300 bg-primary-700',
|
|
30
|
+
false: 'border-steel-300 bg-transparent'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}),
|
|
34
|
+
|
|
35
|
+
thumb: tv({
|
|
36
|
+
base: [
|
|
37
|
+
'absolute',
|
|
38
|
+
'top-0.5',
|
|
39
|
+
'left-0.5',
|
|
40
|
+
'size-[14px]',
|
|
41
|
+
'rounded-xs',
|
|
42
|
+
'transition-transform',
|
|
43
|
+
'duration-200',
|
|
44
|
+
'cursor-pointer'
|
|
45
|
+
],
|
|
46
|
+
variants: {
|
|
47
|
+
checked: {
|
|
48
|
+
true: 'translate-x-5 bg-primary-500',
|
|
49
|
+
false: 'translate-x-0 bg-steel-300'
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}),
|
|
53
|
+
|
|
54
|
+
label: tv({
|
|
55
|
+
base: 'text-sm',
|
|
56
|
+
variants: {
|
|
57
|
+
required: {
|
|
58
|
+
true: 'after:content-["*"] after:ml-1 after:text-red-500'
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useRef, useState, type KeyboardEvent, type ChangeEvent } from 'react'
|
|
2
|
+
import { useFormContext } from '../../contexts/FormContext'
|
|
3
|
+
import { faXmark } from '@fortawesome/free-solid-svg-icons'
|
|
4
|
+
import { IconButton } from '../../buttons'
|
|
5
|
+
import { FormField } from '../FormField'
|
|
6
|
+
import { type TagsFieldProps } from './TagsField.types'
|
|
7
|
+
import { styles } from './TagsField.variants'
|
|
8
|
+
|
|
9
|
+
export function TagsField({
|
|
10
|
+
name,
|
|
11
|
+
label,
|
|
12
|
+
tagLimit = 10,
|
|
13
|
+
required = false,
|
|
14
|
+
placeholder = ''
|
|
15
|
+
}: TagsFieldProps) {
|
|
16
|
+
const { data, setField } = useFormContext()
|
|
17
|
+
const [limitWarning, setLimitWarning] = useState<string | null>(null)
|
|
18
|
+
const [input, setInput] = useState('')
|
|
19
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
20
|
+
|
|
21
|
+
const tags = Array.isArray(data[name]) ? (data[name] as string[]) : []
|
|
22
|
+
const isFull = Boolean(limitWarning)
|
|
23
|
+
|
|
24
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
25
|
+
if (e.key === ' ' && input.length === 0) {
|
|
26
|
+
e.preventDefault()
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
if ((e.key === ' ' || e.key === 'Enter') && input.trim() !== '') {
|
|
30
|
+
e.preventDefault()
|
|
31
|
+
const next = input.trim()
|
|
32
|
+
|
|
33
|
+
if (tags.length >= tagLimit) {
|
|
34
|
+
if (!limitWarning) setLimitWarning(`Tag limit reached. A maximum of ${tagLimit} tags is allowed.`)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
if (limitWarning) setLimitWarning(null)
|
|
38
|
+
|
|
39
|
+
if (!tags.includes(next)) {
|
|
40
|
+
setField(name, [...tags, next])
|
|
41
|
+
}
|
|
42
|
+
setInput('')
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
47
|
+
const sanitized = e.target.value
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.replace(/_/g, '-')
|
|
50
|
+
.replace(/[^a-z0-9-]/g, '')
|
|
51
|
+
setInput(sanitized)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const handleRemove = (index: number) => {
|
|
55
|
+
const next = [...tags]
|
|
56
|
+
next.splice(index, 1)
|
|
57
|
+
setField(name, next)
|
|
58
|
+
if (limitWarning) setLimitWarning(null)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const handleBlur = () => {
|
|
62
|
+
if (input.trim() !== '') setInput('')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const focusInput = () => inputRef.current?.focus()
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
<FormField name={name} label={label} required={required}>
|
|
70
|
+
{id => (
|
|
71
|
+
<ul className={styles.tags({ isFull })} onClick={focusInput}>
|
|
72
|
+
{tags.map((tag, i) => (
|
|
73
|
+
<li key={i} className={styles.tag({ persisted: true })}>
|
|
74
|
+
<span className={styles.tagText()} style={{ width: `${tag.length + 0.5}ch` }}>
|
|
75
|
+
{tag}
|
|
76
|
+
</span>
|
|
77
|
+
<IconButton
|
|
78
|
+
type="button"
|
|
79
|
+
icon={faXmark}
|
|
80
|
+
color="secondary"
|
|
81
|
+
size="xxs"
|
|
82
|
+
onClick={() => handleRemove(i)}
|
|
83
|
+
aria-label={`Remove ${tag}`}
|
|
84
|
+
/>
|
|
85
|
+
</li>
|
|
86
|
+
))}
|
|
87
|
+
<li className={styles.tag({ persisted: false })}>
|
|
88
|
+
<input
|
|
89
|
+
ref={inputRef}
|
|
90
|
+
id={id}
|
|
91
|
+
type="text"
|
|
92
|
+
className={styles.input({ isFull })}
|
|
93
|
+
maxLength={24}
|
|
94
|
+
value={input}
|
|
95
|
+
onChange={handleChange}
|
|
96
|
+
onBlur={handleBlur}
|
|
97
|
+
onKeyDown={handleKeyDown}
|
|
98
|
+
placeholder={placeholder}
|
|
99
|
+
required={required && tags.length === 0}
|
|
100
|
+
/>
|
|
101
|
+
</li>
|
|
102
|
+
</ul>
|
|
103
|
+
)}
|
|
104
|
+
</FormField>
|
|
105
|
+
|
|
106
|
+
{limitWarning && <p className={styles.tagLimit()}>{limitWarning}</p>}
|
|
107
|
+
</>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { styles as fieldStyles } from '../FormField'
|
|
2
|
+
import { tv } from 'tailwind-variants'
|
|
3
|
+
|
|
4
|
+
export const styles = {
|
|
5
|
+
tags: tv({
|
|
6
|
+
extend: fieldStyles.input,
|
|
7
|
+
base: [
|
|
8
|
+
'min-h-11',
|
|
9
|
+
'w-full',
|
|
10
|
+
'flex',
|
|
11
|
+
'flex-wrap',
|
|
12
|
+
'gap-y-2',
|
|
13
|
+
'items-center',
|
|
14
|
+
'focus-within:border-primary-300'
|
|
15
|
+
],
|
|
16
|
+
variants: {
|
|
17
|
+
isFull: { true: 'cursor-not-allowed' }
|
|
18
|
+
}
|
|
19
|
+
}),
|
|
20
|
+
|
|
21
|
+
tag: tv({
|
|
22
|
+
base: 'h-full flex items-center font-mono',
|
|
23
|
+
variants: {
|
|
24
|
+
persisted: {
|
|
25
|
+
true: [
|
|
26
|
+
'flex-[initial]',
|
|
27
|
+
'mr-2',
|
|
28
|
+
'border',
|
|
29
|
+
'border-steel-700',
|
|
30
|
+
'rounded-sm',
|
|
31
|
+
'p-[2px]',
|
|
32
|
+
'pl-2',
|
|
33
|
+
'leading-none'
|
|
34
|
+
],
|
|
35
|
+
false: 'flex-1'
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
defaultVariants: { persisted: false }
|
|
39
|
+
}),
|
|
40
|
+
|
|
41
|
+
tagText: tv({ base: 'text-xs leading-[115%]' }),
|
|
42
|
+
|
|
43
|
+
input: tv({
|
|
44
|
+
base: 'outline-none border-0 w-auto grow shrink bg-transparent text-white text-sm',
|
|
45
|
+
variants: {
|
|
46
|
+
isFull: { true: 'cursor-not-allowed' }
|
|
47
|
+
}
|
|
48
|
+
}),
|
|
49
|
+
|
|
50
|
+
tagLimit: tv({ base: '-mt-4 pt-1 text-xs text-red-300' })
|
|
51
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useFormContext } from '../../contexts/FormContext'
|
|
2
|
+
import { FormField } from '../FormField'
|
|
3
|
+
import { type TextareaFieldProps } from './TextareaField.types'
|
|
4
|
+
import { styles } from './TextareaField.variants'
|
|
5
|
+
|
|
6
|
+
export function TextareaField({
|
|
7
|
+
name,
|
|
8
|
+
label,
|
|
9
|
+
rows = 4,
|
|
10
|
+
required = false,
|
|
11
|
+
placeholder = '',
|
|
12
|
+
...rest
|
|
13
|
+
}: TextareaFieldProps) {
|
|
14
|
+
const { data, setField } = useFormContext()
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<FormField name={name} label={label} required={required}>
|
|
18
|
+
{(id, hasError) => (
|
|
19
|
+
<textarea
|
|
20
|
+
id={id}
|
|
21
|
+
name={name}
|
|
22
|
+
rows={rows}
|
|
23
|
+
value={(data[name] as string | 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 { 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: 'min-h-11 resize-y',
|
|
8
|
+
variants: {
|
|
9
|
+
error: {
|
|
10
|
+
true: 'border-red-500!'
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useRef, useState, type ChangeEvent, type DragEvent } from 'react'
|
|
2
|
+
import { useFormContext } from '../../contexts/FormContext'
|
|
3
|
+
import { FormField } from '../FormField'
|
|
4
|
+
import { type UploadFieldProps } from './UploadField.types'
|
|
5
|
+
import { styles } from './UploadField.variants'
|
|
6
|
+
|
|
7
|
+
export function UploadField({
|
|
8
|
+
name,
|
|
9
|
+
label,
|
|
10
|
+
required = false,
|
|
11
|
+
multiple = false,
|
|
12
|
+
accept = 'image/*'
|
|
13
|
+
}: UploadFieldProps) {
|
|
14
|
+
const { data, setField } = useFormContext()
|
|
15
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
16
|
+
const [dragActive, setDragActive] = useState(false)
|
|
17
|
+
|
|
18
|
+
const value = data[name]
|
|
19
|
+
const files: File[] = value
|
|
20
|
+
? (multiple ? (value as File[]) : [value as File])
|
|
21
|
+
: []
|
|
22
|
+
|
|
23
|
+
const apply = (incoming: FileList | File[]) => {
|
|
24
|
+
const arr = Array.from(incoming)
|
|
25
|
+
if (arr.length === 0) return
|
|
26
|
+
setField(name, multiple ? arr : arr[0])
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
30
|
+
if (e.target.files) apply(e.target.files)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
|
34
|
+
e.preventDefault()
|
|
35
|
+
setDragActive(false)
|
|
36
|
+
if (e.dataTransfer.files) apply(e.dataTransfer.files)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<FormField name={name} label={label} required={required}>
|
|
41
|
+
{(id, hasError) => (
|
|
42
|
+
<div
|
|
43
|
+
id={id}
|
|
44
|
+
className={styles.dropzone({ dragActive, error: hasError })}
|
|
45
|
+
onClick={() => inputRef.current?.click()}
|
|
46
|
+
onDragOver={e => { e.preventDefault(); setDragActive(true) }}
|
|
47
|
+
onDragLeave={() => setDragActive(false)}
|
|
48
|
+
onDrop={handleDrop}
|
|
49
|
+
>
|
|
50
|
+
<input
|
|
51
|
+
ref={inputRef}
|
|
52
|
+
type="file"
|
|
53
|
+
name={name}
|
|
54
|
+
accept={accept}
|
|
55
|
+
multiple={multiple}
|
|
56
|
+
required={required && files.length === 0}
|
|
57
|
+
className="hidden"
|
|
58
|
+
onChange={handleChange}
|
|
59
|
+
/>
|
|
60
|
+
<p>{dragActive ? 'Drop the file here…' : 'Drag a file or click to select'}</p>
|
|
61
|
+
{files.length > 0 && (
|
|
62
|
+
<ul className={styles.previews()}>
|
|
63
|
+
{files.map((f, i) => <li key={i}>{f.name}</li>)}
|
|
64
|
+
</ul>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
</FormField>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { tv } from 'tailwind-variants'
|
|
2
|
+
|
|
3
|
+
export const styles = {
|
|
4
|
+
dropzone: tv({
|
|
5
|
+
base: [
|
|
6
|
+
'flex flex-col items-center justify-center gap-2',
|
|
7
|
+
'min-h-24',
|
|
8
|
+
'p-4',
|
|
9
|
+
'border-2 border-dashed border-steel-700',
|
|
10
|
+
'rounded-xs',
|
|
11
|
+
'cursor-pointer',
|
|
12
|
+
'transition',
|
|
13
|
+
'text-sm text-steel-300',
|
|
14
|
+
'hover:border-primary-500 hover:text-primary-500'
|
|
15
|
+
],
|
|
16
|
+
variants: {
|
|
17
|
+
dragActive: { true: 'border-primary-500! text-primary-500!' },
|
|
18
|
+
error: { true: 'border-red-500!' }
|
|
19
|
+
}
|
|
20
|
+
}),
|
|
21
|
+
|
|
22
|
+
previews: tv({ base: 'mt-3 text-xs text-steel-100 list-disc list-inside' })
|
|
23
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './Form'
|
|
2
|
+
export * from './FormField'
|
|
3
|
+
export * from './InputField'
|
|
4
|
+
export * from './TextareaField'
|
|
5
|
+
export * from './SwitchField'
|
|
6
|
+
export * from './CheckboxField'
|
|
7
|
+
export * from './SpoilerField'
|
|
8
|
+
export * from './DropdownField'
|
|
9
|
+
export * from './TagsField'
|
|
10
|
+
export * from './UploadField'
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cxxi-lab/hub-ui — hub's design system: the components, the design tokens (theme.css), the fonts
|
|
3
|
+
* (fonts.css) and the few helpers the components rely on (the back-button stack…).
|
|
4
|
+
*/
|
|
5
|
+
export * from './buttons'
|
|
6
|
+
export * from './forms'
|
|
7
|
+
export * from './menus'
|
|
8
|
+
export * from './modals'
|
|
9
|
+
export * from './navs'
|
|
10
|
+
export * from './tables'
|
|
11
|
+
export * from './Brand'
|
|
12
|
+
export type { FormData, FormErrors, FormContextValue } from './contexts/FormContext'
|
|
13
|
+
export { useFormContext } from './contexts/FormContext'
|
|
14
|
+
export { useBackHandler, handleBack, onBackDepth } from './back'
|
|
15
|
+
export * from './utils'
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
import { useClickOutside } from '../../utils/useClickOutside'
|
|
3
|
+
import { useBackHandler } from '../../back'
|
|
4
|
+
import { type DropdownProps } from './Dropdown.types'
|
|
5
|
+
import { styles } from './Dropdown.variants'
|
|
6
|
+
|
|
7
|
+
export function Dropdown({
|
|
8
|
+
header,
|
|
9
|
+
children,
|
|
10
|
+
color,
|
|
11
|
+
side,
|
|
12
|
+
isOpen: controlledOpen,
|
|
13
|
+
onOpenChange,
|
|
14
|
+
className
|
|
15
|
+
}: DropdownProps) {
|
|
16
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
|
|
17
|
+
const isControlled = controlledOpen !== undefined
|
|
18
|
+
const open = isControlled ? controlledOpen : uncontrolledOpen
|
|
19
|
+
|
|
20
|
+
const setOpen = (next: boolean) => {
|
|
21
|
+
if (!isControlled) setUncontrolledOpen(next)
|
|
22
|
+
onOpenChange?.(next)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
26
|
+
const menuRef = useRef<HTMLDivElement>(null)
|
|
27
|
+
|
|
28
|
+
useClickOutside([containerRef, menuRef], () => setOpen(false))
|
|
29
|
+
useBackHandler(open, () => setOpen(false))
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div ref={containerRef} className={styles.container({ class: className })} onClick={() => setOpen(!open)}>
|
|
33
|
+
{header && <div>{typeof header === 'function' ? header(open) : header}</div>}
|
|
34
|
+
<div ref={menuRef} className={styles.menu({ open, color, side })}>
|
|
35
|
+
<div>{children}</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export type DropdownColor = 'menu' | 'field'
|
|
4
|
+
export type DropdownSide = 'left' | 'center' | 'right'
|
|
5
|
+
|
|
6
|
+
export type DropdownHeader = ReactNode | ((isOpen: boolean) => ReactNode)
|
|
7
|
+
|
|
8
|
+
export interface DropdownProps {
|
|
9
|
+
header?: DropdownHeader
|
|
10
|
+
children: ReactNode
|
|
11
|
+
color?: DropdownColor
|
|
12
|
+
side?: DropdownSide
|
|
13
|
+
isOpen?: boolean
|
|
14
|
+
onOpenChange?: (open: boolean) => void
|
|
15
|
+
/** extra classes on the root (e.g. to size it inside a flex row) */
|
|
16
|
+
className?: string
|
|
17
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { tv } from 'tailwind-variants'
|
|
2
|
+
|
|
3
|
+
export const styles = {
|
|
4
|
+
container: tv({ base: 'relative' }),
|
|
5
|
+
|
|
6
|
+
menu: tv({
|
|
7
|
+
base: [
|
|
8
|
+
'absolute',
|
|
9
|
+
'w-fit',
|
|
10
|
+
'rounded-xs',
|
|
11
|
+
'border',
|
|
12
|
+
'origin-top',
|
|
13
|
+
'transition-transform',
|
|
14
|
+
'transition-opacity',
|
|
15
|
+
'z-50'
|
|
16
|
+
],
|
|
17
|
+
|
|
18
|
+
variants: {
|
|
19
|
+
color: {
|
|
20
|
+
menu: 'bg-steel-800 border-steel-700 shadow-out',
|
|
21
|
+
field: 'bg-steel-800 border-steel-300 shadow'
|
|
22
|
+
},
|
|
23
|
+
side: {
|
|
24
|
+
left: 'start-0',
|
|
25
|
+
center: 'start-0 w-full end-0',
|
|
26
|
+
right: 'end-0'
|
|
27
|
+
},
|
|
28
|
+
open: {
|
|
29
|
+
true: 'scale-y-100 opacity-100 pointer-events-auto',
|
|
30
|
+
false: 'scale-y-0 opacity-0 pointer-events-none'
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
defaultVariants: {
|
|
35
|
+
color: 'menu',
|
|
36
|
+
side: 'left'
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
compoundVariants: [
|
|
40
|
+
{ side: ['left', 'right'], color: 'menu', class: 'top-[calc(100%+8px)]' },
|
|
41
|
+
{ side: 'center', color: 'field', class: 'top-12' }
|
|
42
|
+
]
|
|
43
|
+
})
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Dropdown'
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { withLoading } from '../../utils'
|
|
3
|
+
import { Button } from '../../buttons'
|
|
4
|
+
import { Modal } from '../Modal'
|
|
5
|
+
import { type ConfirmationModalProps } from './ConfirmationModal.types'
|
|
6
|
+
import { styles } from './ConfirmationModal.variants'
|
|
7
|
+
|
|
8
|
+
export function ConfirmationModal({
|
|
9
|
+
title,
|
|
10
|
+
size,
|
|
11
|
+
confirmLabel = 'Confirm',
|
|
12
|
+
confirmColor,
|
|
13
|
+
cancelLabel = 'Cancel',
|
|
14
|
+
onConfirm,
|
|
15
|
+
onClose,
|
|
16
|
+
children
|
|
17
|
+
}: ConfirmationModalProps) {
|
|
18
|
+
const [loading, setLoading] = useState(false)
|
|
19
|
+
|
|
20
|
+
const handleConfirm = async () => {
|
|
21
|
+
await withLoading(setLoading, () => Promise.resolve(onConfirm()))
|
|
22
|
+
onClose()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Modal title={title} size={size} onClose={onClose}>
|
|
27
|
+
<div className={styles.content()}>{children}</div>
|
|
28
|
+
<footer className={styles.footer()}>
|
|
29
|
+
<Button
|
|
30
|
+
label={cancelLabel}
|
|
31
|
+
color="secondary"
|
|
32
|
+
onClick={onClose}
|
|
33
|
+
isDisabled={loading}
|
|
34
|
+
/>
|
|
35
|
+
<Button
|
|
36
|
+
label={confirmLabel}
|
|
37
|
+
color={confirmColor}
|
|
38
|
+
onClick={handleConfirm}
|
|
39
|
+
isLoading={loading}
|
|
40
|
+
/>
|
|
41
|
+
</footer>
|
|
42
|
+
</Modal>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ReactNode } from 'react'
|
|
2
|
+
import { type ButtonColor } from '../../buttons/Button/Button.types'
|
|
3
|
+
import { type ModalSize } from '../Modal/Modal.types'
|
|
4
|
+
|
|
5
|
+
export interface ConfirmationModalProps {
|
|
6
|
+
title: ReactNode
|
|
7
|
+
size?: ModalSize
|
|
8
|
+
confirmLabel?: string
|
|
9
|
+
confirmColor?: ButtonColor
|
|
10
|
+
cancelLabel?: string
|
|
11
|
+
onConfirm: () => void | Promise<void>
|
|
12
|
+
onClose: () => void
|
|
13
|
+
children: ReactNode
|
|
14
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { withLoading } from '../../utils'
|
|
3
|
+
import { type FormData } from '../../contexts/FormContext'
|
|
4
|
+
import { Button } from '../../buttons'
|
|
5
|
+
import { Form } from '../../forms'
|
|
6
|
+
import { Modal } from '../Modal'
|
|
7
|
+
import { type FormModalProps } from './FormModal.types'
|
|
8
|
+
|
|
9
|
+
export function FormModal({
|
|
10
|
+
title,
|
|
11
|
+
size,
|
|
12
|
+
initialData,
|
|
13
|
+
submitLabel = 'Submit',
|
|
14
|
+
cancelLabel = 'Cancel',
|
|
15
|
+
onSubmit,
|
|
16
|
+
onClose,
|
|
17
|
+
children
|
|
18
|
+
}: FormModalProps) {
|
|
19
|
+
const [loading, setLoading] = useState(false)
|
|
20
|
+
|
|
21
|
+
const handleSubmit = async (data: FormData) => {
|
|
22
|
+
await withLoading(setLoading, () => Promise.resolve(onSubmit(data)))
|
|
23
|
+
onClose()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const actions = (
|
|
27
|
+
<>
|
|
28
|
+
<Button
|
|
29
|
+
type="button"
|
|
30
|
+
color="secondary"
|
|
31
|
+
label={cancelLabel}
|
|
32
|
+
onClick={onClose}
|
|
33
|
+
isDisabled={loading}
|
|
34
|
+
/>
|
|
35
|
+
<Button type="submit" label={submitLabel} isLoading={loading} />
|
|
36
|
+
</>
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Modal title={title} size={size} onClose={onClose}>
|
|
41
|
+
<Form initialData={initialData} onSubmit={handleSubmit} actions={actions}>
|
|
42
|
+
{children}
|
|
43
|
+
</Form>
|
|
44
|
+
</Modal>
|
|
45
|
+
)
|
|
46
|
+
}
|