nitro-web 0.0.87 → 0.0.89
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/client/index.ts +0 -3
- package/components/auth/auth.api.js +411 -0
- package/components/auth/reset.tsx +86 -0
- package/components/auth/signin.tsx +76 -0
- package/components/auth/signup.tsx +62 -0
- package/components/billing/stripe.api.js +268 -0
- package/components/dashboard/dashboard.tsx +32 -0
- package/components/partials/element/accordion.tsx +102 -0
- package/components/partials/element/avatar.tsx +40 -0
- package/components/partials/element/button.tsx +98 -0
- package/components/partials/element/calendar.tsx +125 -0
- package/components/partials/element/dropdown.tsx +248 -0
- package/components/partials/element/filters.tsx +194 -0
- package/components/partials/element/github-link.tsx +16 -0
- package/components/partials/element/initials.tsx +66 -0
- package/components/partials/element/message.tsx +141 -0
- package/components/partials/element/modal.tsx +90 -0
- package/components/partials/element/sidebar.tsx +195 -0
- package/components/partials/element/tooltip.tsx +154 -0
- package/components/partials/element/topbar.tsx +15 -0
- package/components/partials/form/checkbox.tsx +150 -0
- package/components/partials/form/drop-handler.tsx +68 -0
- package/components/partials/form/drop.tsx +141 -0
- package/components/partials/form/field-color.tsx +86 -0
- package/components/partials/form/field-currency.tsx +158 -0
- package/components/partials/form/field-date.tsx +252 -0
- package/components/partials/form/field.tsx +231 -0
- package/components/partials/form/form-error.tsx +27 -0
- package/components/partials/form/location.tsx +225 -0
- package/components/partials/form/select.tsx +360 -0
- package/components/partials/is-first-render.ts +14 -0
- package/components/partials/not-found.tsx +7 -0
- package/components/partials/styleguide.tsx +407 -0
- package/package.json +2 -1
- package/types/globals.d.ts +0 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { deepFind, s3Image, getErrorFromState } from 'nitro-web/util'
|
|
3
|
+
import { DropHandler } from 'nitro-web'
|
|
4
|
+
import noImage from 'nitro-web/client/imgs/no-image.svg'
|
|
5
|
+
import { Errors, MonasteryImage } from 'nitro-web/types'
|
|
6
|
+
import { twMerge } from 'nitro-web/util'
|
|
7
|
+
|
|
8
|
+
type DropProps = {
|
|
9
|
+
awsUrl?: string
|
|
10
|
+
className?: string
|
|
11
|
+
/** Field name or path on state (used to match errors), e.g. 'avatar', 'company.avatar' */
|
|
12
|
+
name: string
|
|
13
|
+
/** Optional ID for the input element. Defaults to name if not provided */
|
|
14
|
+
id?: string
|
|
15
|
+
/** Called when file is selected or dropped */
|
|
16
|
+
onChange?: (event: { target: { name: string, value: File|FileList } }) => void
|
|
17
|
+
/** Whether to allow multiple file selection */
|
|
18
|
+
multiple?: boolean
|
|
19
|
+
/** State object to get the value and check errors against */
|
|
20
|
+
state?: {
|
|
21
|
+
errors?: Errors
|
|
22
|
+
[key: string]: unknown
|
|
23
|
+
}
|
|
24
|
+
/** title used to find related error messages */
|
|
25
|
+
errorTitle?: string|RegExp
|
|
26
|
+
/** Props to pass to the input element */
|
|
27
|
+
[key: string]: unknown
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Image = File | FileList | MonasteryImage | null
|
|
31
|
+
|
|
32
|
+
export function Drop({ awsUrl, className, id, name, onChange, multiple, state, errorTitle, ...props }: DropProps) {
|
|
33
|
+
if (!name) throw new Error('Drop component requires a `name` prop')
|
|
34
|
+
let value: Image = null
|
|
35
|
+
const error = getErrorFromState(state, errorTitle || name)
|
|
36
|
+
const inputId = id || name
|
|
37
|
+
const [urls, setUrls] = useState([])
|
|
38
|
+
const stateRef = useRef(state)
|
|
39
|
+
stateRef.current = state
|
|
40
|
+
|
|
41
|
+
// Input is always controlled if state is passed in
|
|
42
|
+
if (typeof props.value !== 'undefined') value = props.value as Image
|
|
43
|
+
else if (typeof state == 'object') value = deepFind(state, name) as Image
|
|
44
|
+
if (typeof value == 'undefined') value = null
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
(async () => setUrls(await getUrls(value as File | FileList | MonasteryImage | null)))()
|
|
48
|
+
}, [value])
|
|
49
|
+
|
|
50
|
+
function tryAgain (e: { preventDefault: Function }) {
|
|
51
|
+
e.preventDefault()
|
|
52
|
+
// clear file input to allow reupload
|
|
53
|
+
const input = document.getElementById(name) as HTMLInputElement
|
|
54
|
+
if (input) input.value = ''
|
|
55
|
+
if (onChange) {
|
|
56
|
+
const errors = (stateRef?.current?.errors || []).filter((e: Errors[]) => e?.title != name)
|
|
57
|
+
onChange({
|
|
58
|
+
// remove file from state
|
|
59
|
+
target: { name: name, value: null },
|
|
60
|
+
// reset (server) errors
|
|
61
|
+
errors: errors.length ? errors : undefined,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function onFileAttach (files: FileList) {
|
|
67
|
+
// files is a FileList object
|
|
68
|
+
if (onChange) onChange({ target: { name: name, value: multiple ? files : files[0] } })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function getUrls(objectOrFileListItem: File | FileList | MonasteryImage | null) {
|
|
72
|
+
/**
|
|
73
|
+
* @param {object|FileList} objectOrFileListItem - FileList object or monastery image object
|
|
74
|
+
* @returns {Promise} - Resolves to an array of image URLs
|
|
75
|
+
*/
|
|
76
|
+
// Make sure FileLists are converted to a real array
|
|
77
|
+
if (!objectOrFileListItem) return []
|
|
78
|
+
const array = 'length' in objectOrFileListItem ? Array.from(objectOrFileListItem) : [objectOrFileListItem]
|
|
79
|
+
return Promise.all(array.map((item) => {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
if ('lastModified' in item) {
|
|
82
|
+
const reader = new FileReader()
|
|
83
|
+
reader.onload = () => resolve(reader.result)
|
|
84
|
+
reader.onerror = reject
|
|
85
|
+
reader.readAsDataURL(item)
|
|
86
|
+
} else {
|
|
87
|
+
resolve(s3Image(awsUrl, item))
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
}))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// function getFilename (objectOrFile) {
|
|
94
|
+
// if (objectOrFile.lastModified) return objectOrFile.name
|
|
95
|
+
// else return 'avatar.jpg'
|
|
96
|
+
// }
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div class={'mt-2.5 mb-6 ' + twMerge(`mt-input-before mb-input-after nitro-field nitro-drop ${className || ''}`)}>
|
|
100
|
+
<input
|
|
101
|
+
{...props}
|
|
102
|
+
id={inputId}
|
|
103
|
+
type="file"
|
|
104
|
+
onChange={(e) => onFileAttach(e.target.files as FileList)}
|
|
105
|
+
hidden
|
|
106
|
+
/>
|
|
107
|
+
<DropHandler
|
|
108
|
+
onDrop={onFileAttach}
|
|
109
|
+
className="flex flex-column justify-center items-center text-center gap-2 text-grey-300 text-sm px-8 min-h-[300px]"
|
|
110
|
+
>
|
|
111
|
+
{
|
|
112
|
+
!value &&
|
|
113
|
+
<>
|
|
114
|
+
{/* {todo upload svg here} */}
|
|
115
|
+
<div>
|
|
116
|
+
Drag and drop your file here
|
|
117
|
+
<label class="weight-500 inline-block text-sm text-primary" for={inputId}>or select a file</label>
|
|
118
|
+
</div>
|
|
119
|
+
</>
|
|
120
|
+
}
|
|
121
|
+
{
|
|
122
|
+
!!value &&
|
|
123
|
+
<>
|
|
124
|
+
{
|
|
125
|
+
urls.map((url, i) => (
|
|
126
|
+
<div key={i} class="flex align-items-center gap-1">
|
|
127
|
+
<img src={url || noImage} width="100%" />
|
|
128
|
+
</div>
|
|
129
|
+
))
|
|
130
|
+
}
|
|
131
|
+
<div>
|
|
132
|
+
Your file has been added successfully.
|
|
133
|
+
<Link to="#" class="text-primary" onClick={tryAgain}>Use another file?</Link>
|
|
134
|
+
</div>
|
|
135
|
+
</>
|
|
136
|
+
}
|
|
137
|
+
</DropHandler>
|
|
138
|
+
{error && <div class="form-error mt-0-5">{error.detail}</div>}
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { hsvaToHex, hexToHsva, validHex, HsvaColor } from '@uiw/color-convert'
|
|
2
|
+
import Saturation from '@uiw/react-color-saturation'
|
|
3
|
+
import Hue from '@uiw/react-color-hue'
|
|
4
|
+
import { Dropdown, util } from 'nitro-web'
|
|
5
|
+
|
|
6
|
+
export type FieldColorProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'|'value'> & {
|
|
7
|
+
name: string
|
|
8
|
+
/** name is applied if id is not provided */
|
|
9
|
+
id?: string
|
|
10
|
+
defaultColor?: string
|
|
11
|
+
Icon?: React.ReactNode
|
|
12
|
+
onChange?: (event: { target: { name: string, value: string } }) => void
|
|
13
|
+
value?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function FieldColor({ defaultColor='#333', Icon, onChange: onChangeProp, value: valueProp, ...props }: FieldColorProps) {
|
|
17
|
+
const [lastChanged, setLastChanged] = useState(() => `ic-${Date.now()}`)
|
|
18
|
+
const isInvalid = props.className?.includes('is-invalid') ? 'is-invalid' : ''
|
|
19
|
+
const id = props.id || props.name
|
|
20
|
+
|
|
21
|
+
// Since value and onChange are optional, we need to hold the value in state if not provided
|
|
22
|
+
const [internalValue, setInternalValue] = useState(valueProp ?? defaultColor)
|
|
23
|
+
const value = valueProp ?? internalValue
|
|
24
|
+
const onChange = onChangeProp ?? ((e: { target: { name: string, value: string } }) => setInternalValue(e.target.value))
|
|
25
|
+
|
|
26
|
+
function onInputChange(e: { target: { name: string, value: string } }) {
|
|
27
|
+
setLastChanged(`ic-${Date.now()}`)
|
|
28
|
+
onChange(e)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Dropdown
|
|
33
|
+
dir="bottom-left"
|
|
34
|
+
menuToggles={false}
|
|
35
|
+
menuContent={
|
|
36
|
+
<ColorPicker key={lastChanged} defaultColor={defaultColor} name={props.name} value={value} onChange={onChange} />
|
|
37
|
+
}
|
|
38
|
+
>
|
|
39
|
+
<div className="grid grid-cols-1">
|
|
40
|
+
{Icon}
|
|
41
|
+
<input
|
|
42
|
+
{...props}
|
|
43
|
+
className={(props.className || '') + ' ' + isInvalid}
|
|
44
|
+
id={id}
|
|
45
|
+
value={value}
|
|
46
|
+
onChange={onInputChange}
|
|
47
|
+
onBlur={() => !validHex(value||'') && onInputChange({ target: { name: props.name, value: '' }})}
|
|
48
|
+
autoComplete="off"
|
|
49
|
+
type="text"
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
</Dropdown>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ColorPicker({ name='', onChange, value='', defaultColor='' }: FieldColorProps) {
|
|
57
|
+
const [hsva, setHsva] = useState(() => hexToHsva(validHex(value) ? value : defaultColor))
|
|
58
|
+
const [debounce] = useState(() => util.throttle(callOnChange, 50))
|
|
59
|
+
|
|
60
|
+
function callOnChange(newHsva: HsvaColor) {
|
|
61
|
+
if (onChange) onChange({ target: { name: name, value: hsvaToHex(newHsva) }})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
<Saturation
|
|
67
|
+
className="!w-[100%] !h-[150px]"
|
|
68
|
+
hsva={hsva}
|
|
69
|
+
onChange={(newHsva) => {
|
|
70
|
+
setHsva(newHsva)
|
|
71
|
+
if (onChange) debounce(newHsva)
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
<Hue
|
|
75
|
+
hue={hsva.h}
|
|
76
|
+
onChange={(newHue) => {
|
|
77
|
+
setHsva({ ...hsva, ...newHue })
|
|
78
|
+
if (onChange) debounce({ ...hsva, ...newHue })
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
</>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { NumericFormat } from 'react-number-format'
|
|
2
|
+
import { getPrefixWidth } from 'nitro-web/util'
|
|
3
|
+
|
|
4
|
+
// Declaring the type here because typescript fails to infer type when referencing NumericFormatProps from react-number-format
|
|
5
|
+
type NumericFormatProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
|
6
|
+
thousandSeparator?: boolean | string;
|
|
7
|
+
decimalSeparator?: string;
|
|
8
|
+
allowedDecimalSeparators?: Array<string>;
|
|
9
|
+
thousandsGroupStyle?: 'thousand' | 'lakh' | 'wan' | 'none';
|
|
10
|
+
decimalScale?: number;
|
|
11
|
+
fixedDecimalScale?: boolean;
|
|
12
|
+
allowNegative?: boolean;
|
|
13
|
+
allowLeadingZeros?: boolean;
|
|
14
|
+
suffix?: string;
|
|
15
|
+
prefix?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type FieldCurrencyProps = NumericFormatProps & {
|
|
19
|
+
name: string
|
|
20
|
+
/** name is applied if id is not provided */
|
|
21
|
+
id?: string
|
|
22
|
+
/** e.g. { currencies: { nzd: { symbol: '$', digits: 2 } } } (check out the nitro example for more info) */
|
|
23
|
+
config: {
|
|
24
|
+
currencies: { [key: string]: { symbol: string, digits: number } },
|
|
25
|
+
countries: { [key: string]: { numberFormats: { currency: string } } }
|
|
26
|
+
}
|
|
27
|
+
/** currency iso, e.g. 'nzd' */
|
|
28
|
+
currency: string
|
|
29
|
+
onChange?: (event: { target: { name: string, value: string|number|null } }) => void
|
|
30
|
+
/** value should be in cents */
|
|
31
|
+
value?: string|number|null
|
|
32
|
+
defaultValue?: number | string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function FieldCurrency({ config, currency='nzd', onChange, value, defaultValue, ...props }: FieldCurrencyProps) {
|
|
36
|
+
const [dontFix, setDontFix] = useState(false)
|
|
37
|
+
const [settings, setSettings] = useState(() => getCurrencySettings(currency))
|
|
38
|
+
const [dollars, setDollars] = useState(() => toDollars(value, true, settings))
|
|
39
|
+
const [prefixWidth, setPrefixWidth] = useState(0)
|
|
40
|
+
const ref = useRef({ settings, dontFix }) // was null
|
|
41
|
+
const id = props.id || props.name
|
|
42
|
+
ref.current = { settings, dontFix }
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (settings.currency !== currency) {
|
|
46
|
+
const settings = getCurrencySettings(currency)
|
|
47
|
+
setSettings(settings)
|
|
48
|
+
setDollars(toDollars(value, true, settings)) // required latest _settings
|
|
49
|
+
}
|
|
50
|
+
}, [currency])
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (ref.current.dontFix) {
|
|
54
|
+
setDollars(toDollars(value))
|
|
55
|
+
setDontFix(false)
|
|
56
|
+
} else {
|
|
57
|
+
setDollars(toDollars(value, true))
|
|
58
|
+
}
|
|
59
|
+
}, [value])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
// Get the prefix content width
|
|
64
|
+
setPrefixWidth(settings.prefix == '$' ? getPrefixWidth(settings.prefix, 1) : 0)
|
|
65
|
+
}, [settings.prefix])
|
|
66
|
+
|
|
67
|
+
function toCents(value?: string|number|null) {
|
|
68
|
+
const maxDecimals = ref.current.settings.maxDecimals
|
|
69
|
+
const parsed = parseFloat(value + '')
|
|
70
|
+
if (!parsed && parsed !== 0) return null
|
|
71
|
+
if (!maxDecimals) return parsed
|
|
72
|
+
const value2 = Math.round(parsed * Math.pow(10, maxDecimals)) // e.g. 1.23 => 123
|
|
73
|
+
// console.log('toCents', parsed, value2)
|
|
74
|
+
return value2
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function toDollars(value?: string|number|null, toFixed?: boolean, settings?: { maxDecimals?: number }) {
|
|
78
|
+
const maxDecimals = (settings || ref.current.settings).maxDecimals
|
|
79
|
+
const parsed = parseFloat(value + '')
|
|
80
|
+
if (!parsed && parsed !== 0) return null
|
|
81
|
+
if (!maxDecimals) return parsed
|
|
82
|
+
const value2 = parsed / Math.pow(10, maxDecimals) // e.g. 1.23 => 123
|
|
83
|
+
// console.log('toDollars', value, value2)
|
|
84
|
+
return toFixed ? value2.toFixed(maxDecimals) : value2
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getCurrencySettings(currency: string) {
|
|
88
|
+
// parse CLDR currency string format, e.g. '¤#,##0.00'
|
|
89
|
+
const output: {
|
|
90
|
+
currency: string, // e.g. 'nzd'
|
|
91
|
+
decimalSeparator?: string, // e.g. '.'
|
|
92
|
+
thousandSeparator?: string, // e.g. ','
|
|
93
|
+
minDecimals?: number, // e.g. 2
|
|
94
|
+
maxDecimals?: number, // e.g. 2
|
|
95
|
+
prefix?: string, // e.g. '$'
|
|
96
|
+
suffix?: string // e.g. ''
|
|
97
|
+
} = { currency }
|
|
98
|
+
const { symbol, digits } = config.currencies[currency]
|
|
99
|
+
let format = config.countries['nz'].numberFormats.currency
|
|
100
|
+
|
|
101
|
+
// Check for currency symbol (¤) and determine its position
|
|
102
|
+
if (format.indexOf('¤') !== -1) {
|
|
103
|
+
const position = format.indexOf('¤') === 0 ? 'prefix' : 'suffix'
|
|
104
|
+
output[position] = symbol
|
|
105
|
+
format = format.replace('¤', '')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Find and set the thousands separator
|
|
109
|
+
const thousandMatch = format.match(/[^0-9#]/)
|
|
110
|
+
if (thousandMatch) output.thousandSeparator = thousandMatch[0]
|
|
111
|
+
|
|
112
|
+
// Find and set the decimal separator and fraction digits
|
|
113
|
+
const decimalMatch = format.match(/0[^0-9]/)
|
|
114
|
+
if (decimalMatch) {
|
|
115
|
+
output.decimalSeparator = decimalMatch[0].slice(1)
|
|
116
|
+
if (typeof digits !== 'undefined') {
|
|
117
|
+
output.minDecimals = digits
|
|
118
|
+
output.maxDecimals = digits
|
|
119
|
+
} else {
|
|
120
|
+
const fractionDigits = format.split(output.decimalSeparator)[1]
|
|
121
|
+
if (fractionDigits) {
|
|
122
|
+
output.minDecimals = fractionDigits.length
|
|
123
|
+
output.maxDecimals = fractionDigits.length
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return output
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className="relative">
|
|
132
|
+
<NumericFormat
|
|
133
|
+
{...props}
|
|
134
|
+
id={id}
|
|
135
|
+
name={props.name}
|
|
136
|
+
decimalSeparator={settings.decimalSeparator}
|
|
137
|
+
thousandSeparator={settings.thousandSeparator}
|
|
138
|
+
decimalScale={settings.maxDecimals}
|
|
139
|
+
onValueChange={!onChange ? undefined : ({ floatValue }, e) => {
|
|
140
|
+
// console.log('onValueChange', floatValue, e)
|
|
141
|
+
if (e.source === 'event') setDontFix(true)
|
|
142
|
+
onChange({ target: { name: props.name, value: toCents(floatValue) }})
|
|
143
|
+
}}
|
|
144
|
+
onBlur={() => { setDollars(toDollars(value, true))}}
|
|
145
|
+
placeholder={props.placeholder || '0.00'}
|
|
146
|
+
value={dollars}
|
|
147
|
+
style={{ textIndent: `${prefixWidth}px` }}
|
|
148
|
+
type="text"
|
|
149
|
+
defaultValue={defaultValue}
|
|
150
|
+
/>
|
|
151
|
+
<span
|
|
152
|
+
class={`absolute top-0 bottom-0 left-[12px] left-input-x inline-flex items-center select-none text-gray-500 text-input-base ${dollars !== null && settings.prefix == '$' ? 'text-foreground' : ''}`}
|
|
153
|
+
>
|
|
154
|
+
{settings.prefix || settings.suffix}
|
|
155
|
+
</span>
|
|
156
|
+
</div>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { format, isValid, parse } from 'date-fns'
|
|
3
|
+
import { getPrefixWidth } from 'nitro-web/util'
|
|
4
|
+
import { Calendar, Dropdown } from 'nitro-web'
|
|
5
|
+
import { dayButtonClassName } from '../element/calendar'
|
|
6
|
+
|
|
7
|
+
type Mode = 'single' | 'multiple' | 'range'
|
|
8
|
+
type DropdownRef = {
|
|
9
|
+
setIsActive: (value: boolean) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type PreFieldDateProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
|
|
13
|
+
/** field name or path on state (used to match errors), e.g. 'date', 'company.email' **/
|
|
14
|
+
name: string
|
|
15
|
+
/** mode of the date picker */
|
|
16
|
+
mode: Mode
|
|
17
|
+
/** name is used as the id if not provided */
|
|
18
|
+
id?: string
|
|
19
|
+
/** show the time picker */
|
|
20
|
+
showTime?: boolean
|
|
21
|
+
/** prefix to add to the input */
|
|
22
|
+
prefix?: string
|
|
23
|
+
/** number of months to show in the dropdown */
|
|
24
|
+
numberOfMonths?: number
|
|
25
|
+
/** icon to show in the input */
|
|
26
|
+
Icon?: React.ReactNode
|
|
27
|
+
/** direction of the dropdown */
|
|
28
|
+
dir?: 'bottom-left'|'bottom-right'|'top-left'|'top-right'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// An array is returned for mode = 'multiple' or 'range'
|
|
32
|
+
export type FieldDateProps = (
|
|
33
|
+
| ({ mode: 'single' } & PreFieldDateProps & {
|
|
34
|
+
onChange?: (e: { target: { name: string, value: null|number } }) => void
|
|
35
|
+
value?: null|number|string
|
|
36
|
+
})
|
|
37
|
+
| ({ mode: 'multiple' | 'range' } & PreFieldDateProps & {
|
|
38
|
+
onChange?: (e: { target: { name: string, value: (null|number)[] } }) => void
|
|
39
|
+
value?: null|number|string|(null|number|string)[]
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
type TimePickerProps = {
|
|
44
|
+
date: Date|null
|
|
45
|
+
onChange: (mode: Mode, value: number|null) => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function FieldDate({
|
|
49
|
+
dir = 'bottom-left',
|
|
50
|
+
Icon,
|
|
51
|
+
mode,
|
|
52
|
+
numberOfMonths,
|
|
53
|
+
onChange: onChangeProp,
|
|
54
|
+
prefix = '',
|
|
55
|
+
showTime,
|
|
56
|
+
value: valueProp,
|
|
57
|
+
...props
|
|
58
|
+
}: FieldDateProps) {
|
|
59
|
+
const localePattern = `d MMM yyyy${showTime && mode == 'single' ? ' hh:mmaa' : ''}`
|
|
60
|
+
const [prefixWidth, setPrefixWidth] = useState(0)
|
|
61
|
+
const dropdownRef = useRef<DropdownRef>(null)
|
|
62
|
+
const [month, setMonth] = useState<number|undefined>()
|
|
63
|
+
const [lastUpdated, setLastUpdated] = useState(0)
|
|
64
|
+
const id = props.id || props.name
|
|
65
|
+
|
|
66
|
+
// Since value and onChange are optional, we need to hold the value in state if not provided
|
|
67
|
+
const [internalValue, setInternalValue] = useState<typeof valueProp>(valueProp)
|
|
68
|
+
const value = valueProp ?? internalValue
|
|
69
|
+
const onChange = onChangeProp ?? ((e: { target: { name: string, value: any } }) => setInternalValue(e.target.value))
|
|
70
|
+
|
|
71
|
+
// Convert the value to an array of valid* dates
|
|
72
|
+
const dates = useMemo(() => {
|
|
73
|
+
const arrOfNumbers = typeof value === 'string'
|
|
74
|
+
? value.split(/\s*,\s*/g).map(o => parseFloat(o))
|
|
75
|
+
: Array.isArray(value) ? value : [value]
|
|
76
|
+
const out = arrOfNumbers.map(date => isValid(date) ? new Date(date as number) : null) /// changed to null
|
|
77
|
+
return out
|
|
78
|
+
}, [value])
|
|
79
|
+
|
|
80
|
+
// Hold the input value in state
|
|
81
|
+
const [inputValue, setInputValue] = useState(() => getInputValue(dates))
|
|
82
|
+
|
|
83
|
+
// Update the date's inputValue (text) when the value changes outside of the component
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (new Date().getTime() > lastUpdated + 100) setInputValue(getInputValue(dates))
|
|
86
|
+
}, [dates])
|
|
87
|
+
|
|
88
|
+
// Get the prefix content width
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
setPrefixWidth(getPrefixWidth(prefix, 4))
|
|
91
|
+
}, [prefix])
|
|
92
|
+
|
|
93
|
+
function onCalendarChange(mode: Mode, value: null|number|(null|number)[]) {
|
|
94
|
+
if (mode == 'single' && !showTime) dropdownRef.current?.setIsActive(false) // Close the dropdown
|
|
95
|
+
setInputValue(getInputValue(value))
|
|
96
|
+
// Update the value
|
|
97
|
+
onChange({ target: { name: props.name, value: getOutputValue(value) } })
|
|
98
|
+
setLastUpdated(new Date().getTime())
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function onInputChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
102
|
+
setInputValue(e.target.value) // keep the input value in sync
|
|
103
|
+
|
|
104
|
+
let split = e.target.value.split(/-|,/).map(o => {
|
|
105
|
+
const date = parse(o.trim(), localePattern, new Date())
|
|
106
|
+
return isValid(date) ? date : null
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// For single/range we need limit the array
|
|
110
|
+
if (mode == 'range' && split.length > 1) split.length = 2
|
|
111
|
+
else if (mode == 'multiple') split = split.filter(o => o) // remove invalid dates
|
|
112
|
+
|
|
113
|
+
// Swap dates if needed
|
|
114
|
+
if (mode == 'range' && (split[0] || 0) > (split[1] || 0)) split = [split[0], split[0]]
|
|
115
|
+
|
|
116
|
+
// Set month
|
|
117
|
+
for (let i=split.length; i--;) {
|
|
118
|
+
if (split[i]) setMonth((split[i] as Date).getTime())
|
|
119
|
+
break
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Update the value
|
|
123
|
+
const value = mode == 'single' ? split[0]?.getTime() ?? null : split.map(d => d?.getTime() ?? null)
|
|
124
|
+
onChange({ target: { name: props.name, value: getOutputValue(value) }})
|
|
125
|
+
setLastUpdated(new Date().getTime())
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getInputValue(value: Date|number|null|(Date|number|null)[]) {
|
|
129
|
+
const _dates = Array.isArray(value) ? value : [value]
|
|
130
|
+
return _dates.map(o => o ? format(o, localePattern) : '').join(mode == 'range' ? ' - ' : ', ')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getOutputValue(value: Date|number|null|(Date|number|null)[]): any {
|
|
134
|
+
// console.log(value)
|
|
135
|
+
return value
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<Dropdown
|
|
140
|
+
ref={dropdownRef}
|
|
141
|
+
menuToggles={false}
|
|
142
|
+
// animate={false}
|
|
143
|
+
// menuIsOpen={true}
|
|
144
|
+
minWidth={0}
|
|
145
|
+
menuContent={
|
|
146
|
+
<div className="flex">
|
|
147
|
+
<Calendar
|
|
148
|
+
// Calendar actually accepts an array of dates, but the type is not typed correctly
|
|
149
|
+
{...{ mode: mode, value: dates as any, numberOfMonths: numberOfMonths, month: month }}
|
|
150
|
+
preserveTime={!!showTime}
|
|
151
|
+
onChange={onCalendarChange}
|
|
152
|
+
className="pt-1 pb-2 px-3"
|
|
153
|
+
/>
|
|
154
|
+
{!!showTime && mode == 'single' && <TimePicker date={dates?.[0]} onChange={onCalendarChange} />}
|
|
155
|
+
</div>
|
|
156
|
+
}
|
|
157
|
+
dir={dir}
|
|
158
|
+
>
|
|
159
|
+
<div className="grid grid-cols-1">
|
|
160
|
+
{Icon}
|
|
161
|
+
{
|
|
162
|
+
prefix &&
|
|
163
|
+
// Similar classNames to the input.tsx:IconWrapper()
|
|
164
|
+
<span className="z-[0] col-start-1 row-start-1 self-center select-none justify-self-start text-input-base ml-[12px] ml-input-x">
|
|
165
|
+
{prefix}
|
|
166
|
+
</span>
|
|
167
|
+
}
|
|
168
|
+
<input
|
|
169
|
+
{...props}
|
|
170
|
+
key={'k' + prefixWidth}
|
|
171
|
+
id={id}
|
|
172
|
+
autoComplete="off"
|
|
173
|
+
className={(props.className||'')}// + props.className?.includes('is-invalid') ? ' is-invalid' : ''}
|
|
174
|
+
onBlur={() => setInputValue(getInputValue(dates))}
|
|
175
|
+
onChange={onInputChange}
|
|
176
|
+
style={{ textIndent: prefixWidth + 'px' }}
|
|
177
|
+
type="text"
|
|
178
|
+
value={inputValue}
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
</Dropdown>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function TimePicker({ date, onChange }: TimePickerProps) {
|
|
186
|
+
const lists = [
|
|
187
|
+
[12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], // hours
|
|
188
|
+
[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55], // minutes
|
|
189
|
+
['AM', 'PM'], // AM/PM
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
// Get current values from date or use defaults
|
|
193
|
+
const hour = date ? parseInt(format(date, 'h')) : undefined
|
|
194
|
+
const minute = date ? parseInt(format(date, 'm')) : undefined
|
|
195
|
+
const period = date ? format(date, 'a') : undefined
|
|
196
|
+
|
|
197
|
+
const handleTimeChange = (type: 'hour' | 'minute' | 'period', value: string | number) => {
|
|
198
|
+
// Create a new date object from the current date or current time
|
|
199
|
+
const newDate = new Date(date || new Date())
|
|
200
|
+
|
|
201
|
+
if (type === 'hour') {
|
|
202
|
+
// Parse the time with the new hour value
|
|
203
|
+
const timeString = `${value}:${format(newDate, 'mm')} ${format(newDate, 'a')}`
|
|
204
|
+
const updatedDate = parse(timeString, 'h:mm a', newDate)
|
|
205
|
+
newDate.setHours(updatedDate.getHours(), updatedDate.getMinutes())
|
|
206
|
+
} else if (type === 'minute') {
|
|
207
|
+
// Parse the time with the new minute value
|
|
208
|
+
const timeString = `${format(newDate, 'h')}:${value} ${format(newDate, 'a')}`
|
|
209
|
+
const updatedDate = parse(timeString, 'h:mm a', newDate)
|
|
210
|
+
newDate.setMinutes(updatedDate.getMinutes())
|
|
211
|
+
} else if (type === 'period') {
|
|
212
|
+
// Parse the time with the new period value
|
|
213
|
+
const timeString = `${format(newDate, 'h')}:${format(newDate, 'mm')} ${value}`
|
|
214
|
+
const updatedDate = parse(timeString, 'h:mm a', newDate)
|
|
215
|
+
newDate.setHours(updatedDate.getHours())
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
onChange('single', newDate.getTime())
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
lists.map((list, i) => {
|
|
223
|
+
const type = i === 0 ? 'hour' : i === 1 ? 'minute' : 'period'
|
|
224
|
+
const currentValue = i === 0 ? hour : i === 1 ? minute : period
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<div key={i} className="w-[60px] py-1 relative overflow-hidden hover:overflow-y-auto border-l border-gray-100">
|
|
228
|
+
<div className="w-[60px] absolute flex flex-col items-center">
|
|
229
|
+
{list.map(item => (
|
|
230
|
+
<div
|
|
231
|
+
className="py-1 flex group cursor-pointer"
|
|
232
|
+
key={item}
|
|
233
|
+
onClick={() => handleTimeChange(type, item)}
|
|
234
|
+
>
|
|
235
|
+
<button
|
|
236
|
+
key={item}
|
|
237
|
+
className={
|
|
238
|
+
`${dayButtonClassName} rounded-full flex justify-center items-center group-hover:bg-gray-100 `
|
|
239
|
+
+ (item === currentValue ? '!bg-input-border-focus text-white' : '')
|
|
240
|
+
}
|
|
241
|
+
onClick={() => handleTimeChange(type, item)}
|
|
242
|
+
>
|
|
243
|
+
{item.toString().padStart(2, '0').toLowerCase()}
|
|
244
|
+
</button>
|
|
245
|
+
</div>
|
|
246
|
+
))}
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
)
|
|
250
|
+
})
|
|
251
|
+
)
|
|
252
|
+
}
|