nitro-web 0.0.145 → 0.0.147

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.
@@ -15,128 +15,74 @@ type NumericFormatProps = React.InputHTMLAttributes<HTMLInputElement> & {
15
15
  prefix?: string;
16
16
  }
17
17
 
18
- export type FieldCurrencyProps = NumericFormatProps & {
18
+ type Currencies = { [key: string]: { symbol: string, digits: number } }
19
+ type Cents = string|number|null
20
+ export type FieldCurrencyProps = Omit<NumericFormatProps, 'onChange' | 'value' | 'defaultValue'> & {
19
21
  name: string
20
22
  /** name is applied if id is not provided */
21
23
  id?: string
22
24
  /** currency iso, e.g. 'nzd' */
23
25
  currency: string
24
26
  /** override the default currencies array used to lookup currency symbol and digits, e.g. {nzd: { symbol: '$', digits: 2 }} */
25
- currencies?: { [key: string]: { symbol: string, digits: number } },
27
+ currencies?: Currencies,
26
28
  /** override the default CLDR country currency format, e.g. '¤#,##0.00' */
27
29
  format?: string,
28
- onChange?: (event: { target: { name: string, value: string|number|null } }) => void
30
+ onChange?: (event: { target: { name: string, value: Cents } }) => void
29
31
  /** value should be in cents */
30
- value?: string|number|null
31
- defaultValue?: number | string | null
32
+ value?: Cents
33
+ defaultValue?: Cents // defined to just fix typescript error
32
34
  }
33
35
 
34
- export function FieldCurrency({ currency='nzd', currencies, format, onChange, value, defaultValue, ...props }: FieldCurrencyProps) {
36
+ export function FieldCurrency({ currency='nzd', currencies, format, onChange: onChangeProp, ...props }: FieldCurrencyProps) {
35
37
  const [dontFix, setDontFix] = useState(false)
36
- const [settings, setSettings] = useState(() => getCurrencySettings(currency))
37
- const [dollars, setDollars] = useState(() => toDollars(value, true, settings))
38
+ const [lastBlurred, setLastBlurred] = useState(0)
39
+ const [settings, setSettings] = useState(() => getCurrencySettings(currency, currencies, format, props.name))
38
40
  const [prefixWidth, setPrefixWidth] = useState(0)
39
- const ref = useRef({ settings, dontFix }) // was null
41
+ const ref = useRef({ dontFix }) // was null
40
42
  const id = props.id || props.name
41
- ref.current = { settings, dontFix }
43
+ ref.current = { dontFix }
42
44
 
45
+ // Since value and onChange are optional, we need need to create an internal value state
46
+ const [internalValue, setInternalValue] = useState<FieldCurrencyProps['value']>(props.value ?? props.defaultValue)
47
+ const inputValue = useMemo(() => to('dollars', internalValue, settings), [internalValue, settings.key, lastBlurred])
48
+
49
+ // Update the internal value when the value changes outside of the component
43
50
  useEffect(() => {
44
- if (settings.currency !== currency) {
45
- const settings = getCurrencySettings(currency)
46
- setSettings(settings)
47
- setDollars(toDollars(value, true, settings)) // required latest _settings
48
- }
49
- }, [currency])
51
+ if (internalValue !== (props.value ?? props.defaultValue)) setInternalValue(props.value ?? props.defaultValue)
52
+ }, [props.value])
50
53
 
54
+ // Update the settings if the setting parameters change
51
55
  useEffect(() => {
52
- if (ref.current.dontFix) {
53
- setDollars(toDollars(value))
54
- setDontFix(false)
55
- } else {
56
- setDollars(toDollars(value, true))
57
- }
58
- }, [value])
56
+ const _settings = getCurrencySettings(currency, currencies, format, props.name)
57
+ if (settings.key !== _settings.key) setSettings(_settings)
58
+ }, [currency, currencies, format])
59
59
 
60
+ // Get the prefix content width
60
61
  useEffect(() => {
61
- // Get the prefix content width
62
62
  setPrefixWidth(settings.prefix ? getPrefixWidth(settings.prefix, 1) : 0)
63
63
  }, [settings.prefix])
64
64
 
65
- function toCents(value?: string|number|null) {
66
- const maxDecimals = ref.current.settings.maxDecimals
65
+ function to(type: 'dollars' | 'cents', value?: Cents, settings?: { maxDecimals?: number }) {
66
+ const maxDecimals = settings?.maxDecimals
67
67
  const parsed = parseFloat(value + '')
68
68
  if (!parsed && parsed !== 0) return null
69
69
  if (!maxDecimals) return parsed
70
- const value2 = Math.round(parsed * Math.pow(10, maxDecimals)) // e.g. 1.23 => 123
71
- // console.log('toCents', parsed, value2)
72
- return value2
73
- }
74
70
 
75
- function toDollars(value?: string|number|null, toFixed?: boolean, settings?: { maxDecimals?: number }) {
76
- const maxDecimals = (settings || ref.current.settings).maxDecimals
77
- const parsed = parseFloat(value + '')
78
- if (!parsed && parsed !== 0) return null
79
- if (!maxDecimals) return parsed
80
- const value2 = parsed / Math.pow(10, maxDecimals) // e.g. 1.23 => 123
81
- // console.log('toDollars', value, value2)
82
- return toFixed ? value2.toFixed(maxDecimals) : value2
83
- }
71
+ const value2 = type === 'dollars'
72
+ ? parsed / Math.pow(10, maxDecimals)
73
+ : parsed * Math.pow(10, maxDecimals) // e.g. 1.23 => 123
84
74
 
85
- function getCurrencySettings(currency: string) {
86
- // parse CLDR currency string format, e.g. '¤#,##0.00'
87
- const output: {
88
- currency: string, // e.g. 'nzd'
89
- decimalSeparator?: string, // e.g. '.'
90
- thousandSeparator?: string, // e.g. ','
91
- minDecimals?: number, // e.g. 2
92
- maxDecimals?: number, // e.g. 2
93
- prefix?: string, // e.g. '$'
94
- suffix?: string // e.g. ''
95
- } = { currency }
96
-
97
- let _format = format || defaultFormat
98
- const _currencies = currencies ?? defaultCurrencies
99
- const currencyObject = _currencies[currency as keyof typeof _currencies]
100
- if (!currencyObject && currencies) {
101
- console.error(
102
- `The currency field "${props.name}" is using the currency "${currency}" which is not found in your currencies object`
103
- )
104
- } else if (!currencyObject && !currencies) {
105
- console.error(
106
- `The currency field "${props.name}" is using the currency "${currency}" which is not found in the
107
- default currencies, please provide a currencies object.`
108
- )
109
- }
110
- const symbol = currencyObject ? currencyObject.symbol : ''
111
- const digits = currencyObject ? currencyObject.digits : 2
112
-
113
- // Check for currency symbol (¤) and determine its position
114
- if (_format.indexOf('¤') !== -1) {
115
- const position = _format.indexOf('¤') === 0 ? 'prefix' : 'suffix'
116
- output[position] = symbol
117
- _format = _format.replace('¤', '')
118
- }
75
+ // dont fix when the user is typing.
76
+ if (type === 'dollars' && ref.current.dontFix) { setDontFix(false) }
119
77
 
120
- // Find and set the thousands separator
121
- const thousandMatch = _format.match(/[^0-9#]/)
122
- if (thousandMatch) output.thousandSeparator = thousandMatch[0]
123
-
124
- // Find and set the decimal separator and fraction digits
125
- const decimalMatch = _format.match(/0[^0-9]/)
126
- if (decimalMatch) {
127
- output.decimalSeparator = decimalMatch[0].slice(1)
128
- if (typeof digits !== 'undefined') {
129
- output.minDecimals = digits
130
- output.maxDecimals = digits
131
- } else {
132
- const fractionDigits = _format.split(output.decimalSeparator)[1]
133
- if (fractionDigits) {
134
- output.minDecimals = fractionDigits.length
135
- output.maxDecimals = fractionDigits.length
136
- }
137
- }
138
- }
139
- return output
78
+ // console.log('to', type, value, value2)
79
+ return type === 'cents' || ref.current.dontFix ? value2 : value2.toFixed(maxDecimals)
80
+ }
81
+
82
+ function onChange(source: 'event' | 'prop', floatValue?: number) {
83
+ if (source === 'event') setDontFix(true)
84
+ if (onChangeProp) onChangeProp({ target: { name: props.name, value: to('cents', floatValue, settings) }})
85
+ else setInternalValue(to('cents', floatValue, settings))
140
86
  }
141
87
 
142
88
  return (
@@ -148,20 +94,15 @@ export function FieldCurrency({ currency='nzd', currencies, format, onChange, va
148
94
  decimalSeparator={settings.decimalSeparator}
149
95
  thousandSeparator={settings.thousandSeparator}
150
96
  decimalScale={settings.maxDecimals}
151
- onValueChange={!onChange ? undefined : ({ floatValue }, e) => {
152
- // console.log('onValueChange', floatValue, e)
153
- if (e.source === 'event') setDontFix(true)
154
- onChange({ target: { name: props.name, value: toCents(floatValue) }})
155
- }}
156
- onBlur={() => { setDollars(toDollars(value, true))}}
97
+ onValueChange={({ floatValue }, e) => onChange(e.source, floatValue)}
98
+ onBlur={() => { setLastBlurred(Date.now())}}
157
99
  placeholder={props.placeholder || '0.00'}
158
- value={dollars}
100
+ value={inputValue}
159
101
  style={{ textIndent: `${prefixWidth}px` }}
160
102
  type="text"
161
- defaultValue={defaultValue}
162
103
  />
163
104
  <span
164
- 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' : ''}`}
105
+ class={`absolute top-0 bottom-0 left-[12px] left-input-x inline-flex items-center select-none text-gray-500 text-input-base ${inputValue !== null && settings.prefix == '$' ? 'text-foreground' : ''}`}
165
106
  >
166
107
  {settings.prefix || settings.suffix}
167
108
  </span>
@@ -169,6 +110,71 @@ export function FieldCurrency({ currency='nzd', currencies, format, onChange, va
169
110
  )
170
111
  }
171
112
 
113
+ function getCurrencySettings(currency: string, currencies?: Currencies, format?: string, name?: string) {
114
+ // parse CLDR currency string format, e.g. '¤#,##0.00'
115
+ const output: {
116
+ key?: string
117
+ currency: string, // e.g. 'nzd'
118
+ decimalSeparator?: string, // e.g. '.'
119
+ thousandSeparator?: string, // e.g. ','
120
+ minDecimals?: number, // e.g. 2
121
+ maxDecimals?: number, // e.g. 2
122
+ prefix?: string, // e.g. '$'
123
+ suffix?: string // e.g. ''
124
+ } = { currency }
125
+
126
+ let _format = format || defaultFormat
127
+ const _currencies = currencies ?? defaultCurrencies
128
+ const currencyObject = _currencies[currency as keyof typeof _currencies]
129
+ if (!currencyObject && currencies) {
130
+ console.error(
131
+ `The currency field "${name||''}" is using the currency "${currency}" which is not found in your currencies object`
132
+ )
133
+ } else if (!currencyObject && !currencies) {
134
+ console.error(
135
+ `The currency field "${name||''}" is using the currency "${currency}" which is not found in the
136
+ default currencies, please provide a currencies object.`
137
+ )
138
+ }
139
+ const symbol = currencyObject ? currencyObject.symbol : ''
140
+ const digits = currencyObject ? currencyObject.digits : 2
141
+
142
+ // Check for currency symbol (¤) and determine its position
143
+ if (_format.indexOf('¤') !== -1) {
144
+ const position = _format.indexOf('¤') === 0 ? 'prefix' : 'suffix'
145
+ output[position] = symbol
146
+ _format = _format.replace('¤', '')
147
+ }
148
+
149
+ // Find and set the thousands separator
150
+ const thousandMatch = _format.match(/[^0-9#]/)
151
+ if (thousandMatch) output.thousandSeparator = thousandMatch[0]
152
+
153
+ // Find and set the decimal separator and fraction digits
154
+ const decimalMatch = _format.match(/0[^0-9]/)
155
+ if (decimalMatch) {
156
+ output.decimalSeparator = decimalMatch[0].slice(1)
157
+ if (typeof digits !== 'undefined') {
158
+ output.minDecimals = digits
159
+ output.maxDecimals = digits
160
+ } else {
161
+ const fractionDigits = _format.split(output.decimalSeparator)[1]
162
+ if (fractionDigits) {
163
+ output.minDecimals = fractionDigits.length
164
+ output.maxDecimals = fractionDigits.length
165
+ }
166
+ }
167
+ }
168
+
169
+ // create stable key from final output
170
+ output.key = Object.keys(output)
171
+ .filter(k => k !== 'key')
172
+ .sort()
173
+ .map(k => `${k}:${output[k as keyof typeof output] ?? ''}`)
174
+ .join('|')
175
+ return output
176
+ }
177
+
172
178
  export const defaultCurrencies: { [key: string]: { name: string, symbol: string, digits: number } } = {
173
179
  nzd: { name: 'New Zealand Dollar', symbol: '$', digits: 2 },
174
180
  aud: { name: 'Australian Dollar', symbol: '$', digits: 2 },
@@ -1,23 +1,24 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { format, isValid, parse } from 'date-fns'
3
+ import { tz as _tz, TZDate } from '@date-fns/tz'
3
4
  import { getPrefixWidth } from 'nitro-web/util'
4
- import { Calendar, Dropdown, DropdownProps } from 'nitro-web'
5
+ import { Button, Calendar, Dropdown, DropdownProps, TimePicker } from 'nitro-web'
5
6
  import { DayPickerProps } from '../element/calendar'
6
- import { TimePicker } from './field-time'
7
7
 
8
- type Mode = 'single' | 'multiple' | 'range'
8
+ type Timestamp = null | number
9
+ type TimestampArray = null | Timestamp[]
9
10
  type DropdownRef = {
10
11
  setIsActive: (value: boolean) => void
11
12
  }
12
13
 
13
- type PreFieldDateProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
14
+ type PreFieldDateProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'defaultValue'> & {
14
15
  /** field name or path on state (used to match errors), e.g. 'date', 'company.email' **/
15
16
  name: string
16
- /** mode of the date picker */
17
- mode: Mode
18
17
  /** name is used as the id if not provided */
19
18
  id?: string
20
- /** show the time picker */
19
+ /** mode of the date picker */
20
+ mode: 'single' | 'multiple' | 'range' | 'time'
21
+ /** show the time picker for single mode*/
21
22
  showTime?: boolean
22
23
  /** prefix to add to the input */
23
24
  prefix?: string
@@ -31,116 +32,150 @@ type PreFieldDateProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onCh
31
32
  DayPickerProps?: DayPickerProps
32
33
  /** Dropdown props */
33
34
  DropdownProps?: DropdownProps
35
+ /** timezone to use for the date picker */
36
+ tz?: string
34
37
  }
35
38
 
36
- // An array is returned for mode = 'multiple' or 'range'
37
- export type FieldDateProps = (
38
- | ({ mode: 'single' } & PreFieldDateProps & {
39
- onChange?: (e: { target: { name: string, value: null|number } }) => void
40
- value?: null|number|string
41
- })
42
- | ({ mode: 'multiple' | 'range' } & PreFieldDateProps & {
43
- onChange?: (e: { target: { name: string, value: (null|number)[] } }) => void
44
- value?: null|number|string|(null|number|string)[]
45
- })
46
- )
39
+ // Discriminated union types based on mode
40
+ type FieldDatePropsSingle = PreFieldDateProps & {
41
+ mode: 'single' | 'time'
42
+ defaultValue?: Timestamp
43
+ onChange?: (e: { target: { name: string, value: Timestamp } }) => void
44
+ value?: Timestamp // gracefully handles falsey values
45
+ }
46
+
47
+ type FieldDatePropsMultiple = PreFieldDateProps & {
48
+ mode: 'multiple' | 'range'
49
+ defaultValue?: TimestampArray
50
+ onChange?: (e: { target: { name: string, value: TimestampArray } }) => void
51
+ value?: TimestampArray // gracefully handles falsey values
52
+ }
53
+
54
+ export type FieldDateProps = FieldDatePropsSingle | FieldDatePropsMultiple
55
+ const errors: string[] = []
47
56
 
48
57
  export function FieldDate({
49
58
  dir = 'bottom-left',
50
59
  Icon,
51
- mode,
52
60
  numberOfMonths,
53
61
  onChange: onChangeProp,
54
62
  prefix = '',
55
63
  showTime,
56
- value: valueProp,
57
64
  DayPickerProps,
58
65
  DropdownProps,
66
+ tz,
59
67
  ...props
60
68
  }: FieldDateProps) {
61
- // Currently this displays the dates in local timezone and saves in utc. We should allow the user to display the dates in a
62
- // different timezone.
63
- const localePattern = `d MMM yyyy${showTime && mode == 'single' ? ' hh:mmaa' : ''}`
69
+ const [month, setMonth] = useState<number|undefined>()
70
+ const [preventInputValueUpdates, setPreventInputValueUpdates] = useState(false)
64
71
  const [prefixWidth, setPrefixWidth] = useState(0)
65
72
  const dropdownRef = useRef<DropdownRef>(null)
66
- const [month, setMonth] = useState<number|undefined>()
67
- const [lastUpdated, setLastUpdated] = useState(0)
73
+ const pattern = props.mode == 'time' ? 'hh:mmaa' : `d MMM yyyy${showTime && props.mode == 'single' ? ' hh:mmaa' : ''}`
68
74
  const id = props.id || props.name
69
75
 
70
- // Since value and onChange are optional, we need to hold the value in state if not provided
71
- const [internalValue, setInternalValue] = useState<typeof valueProp>(valueProp)
72
- const value = valueProp ?? internalValue
73
- const onChange = onChangeProp ?? ((e: { target: { name: string, value: any } }) => setInternalValue(e.target.value))
74
-
75
- // Convert the value to an array of valid* dates
76
- const validDates = useMemo(() => {
77
- const arrOfNumbers = Array.isArray(value) ? value : [value]
78
- const out = arrOfNumbers.map((date) => {
79
- if (typeof date === 'string' && !isNaN(parseFloat(date))) date = parseFloat(date)
80
- return isValid(date) ? new Date(date as number) : null /// changed to null
81
- })
82
- return out
83
- }, [value])
84
-
85
- // Hold the input value in state
86
- const [inputValue, setInputValue] = useState(() => getInputValue(validDates))
87
-
88
- // Update the date's inputValue (text) when the value changes outside of the component
76
+ // Since value and onChange are optional, we need need to create an internal value state
77
+ const [internalValue, setInternalValue] = useState<Timestamp[]>(() => preInternalValue(props))
78
+ const inputValue = useMemo(() => getInputValue(internalValue), [internalValue])
79
+ const [inputValueSticky, setInputValueSticky] = useState(() => inputValue)
80
+
81
+ // Update the internal value when the value changes outside of the component
82
+ useEffect(() => {
83
+ const newValue = preInternalValue(props)
84
+ for (let i=0; i<Math.max(internalValue.length, newValue.length); i++) {
85
+ if (internalValue[i] !== newValue[i]) {
86
+ setInternalValue(newValue)
87
+ break
88
+ }
89
+ }
90
+ }, [props.value])
91
+
92
+ // Only update the sticky input when the input is blurred
89
93
  useEffect(() => {
90
- if (new Date().getTime() > lastUpdated + 100) setInputValue(getInputValue(validDates))
91
- }, [validDates])
94
+ if (!preventInputValueUpdates) setInputValueSticky(inputValue)
95
+ }, [inputValue, preventInputValueUpdates])
92
96
 
93
97
  // Get the prefix content width
94
98
  useEffect(() => {
95
99
  setPrefixWidth(getPrefixWidth(prefix, 4))
96
100
  }, [prefix])
97
101
 
98
- function onCalendarChange(value: null|number|(null|number)[]) {
99
- if (mode == 'single' && !showTime) dropdownRef.current?.setIsActive(false) // Close the dropdown
100
- setInputValue(getInputValue(value))
101
- // Update the value
102
- onChange({ target: { name: props.name, value: getOutputValue(value) } })
103
- setLastUpdated(new Date().getTime())
102
+ function preInternalValue(props: FieldDateProps) {
103
+ // Even though we are using types, the value may be different coming from the state, so lets sanitise/parse it.
104
+ // We need to use props.* to get type narrowing.
105
+ switch (props.mode) {
106
+ case 'single':
107
+ case 'time': {
108
+ const value = props?.value ?? props?.defaultValue
109
+ return [value && isValid(value) ? new Date(value).getTime() : null]
110
+ }
111
+ case 'multiple':
112
+ case 'range': {
113
+ const value = props.value ?? props?.defaultValue
114
+ if (!value || !Array.isArray(value)) {
115
+ const error = `FieldDate: ${props.name} value needs to be an array for mode 'multiple' or 'range', received`
116
+ if (value && !errors.includes(error)) { errors.push(error); console.error(error, value) }
117
+ return []
118
+ } else {
119
+ return value.map((timestamp) => {
120
+ return timestamp && isValid(timestamp) ? new Date(timestamp).getTime() : null
121
+ })
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ function getInputValue(value: Timestamp[]) {
128
+ return value.map(o => date(o, pattern, tz)).join(props.mode == 'range' ? ' - ' : ', ')
129
+ }
130
+
131
+ function onChange<T>(value: T) {
132
+ if (props.mode == 'single' && !showTime) dropdownRef.current?.setIsActive(false) // Close the dropdown
133
+ if (onChangeProp) onChangeProp({ target: { name: props.name, value: value as any } }) // type enforced in the parameter
134
+ else setInternalValue(preInternalValue({ ...props, value: value } as FieldDateProps))
104
135
  }
105
136
 
106
137
  function onInputChange(e: React.ChangeEvent<HTMLInputElement>) {
107
138
  // Calls onChange (should update state, thus updating the value) with "raw" values
108
- setInputValue(e.target.value) // keep the input value in sync
139
+ setInputValueSticky(e.target.value) // keep the sticky input value in sync
140
+ setPreventInputValueUpdates(true)
109
141
 
110
- let split = e.target.value.split(/-|,/).map(o => {
111
- const date = parse(o.trim(), localePattern, new Date())
112
- return isValid(date) ? date : null
142
+ // Parse the datestring into timestamps
143
+ let timestamps = e.target.value.split(/-|,/).map(o => {
144
+ return parseDateString(o.trim(), pattern, tz)
113
145
  })
114
-
115
- // For single/range we need limit the array
116
- if (mode == 'range' && split.length > 1) split.length = 2
117
- else if (mode == 'multiple') split = split.filter(o => o) // remove invalid dates
118
146
 
119
- // Swap dates if needed
120
- if (mode == 'range' && (split[0] || 0) > (split[1] || 0)) split = [split[0], split[0]]
147
+ // For range mode we need limit the array to 2
148
+ if (props.mode == 'range' && timestamps.length > 1) timestamps.length = 2
149
+
150
+ // Swap range dates if needed
151
+ if (props.mode == 'range' && (timestamps[0] || 0) > (timestamps[1] || 0)) timestamps = [timestamps[0], timestamps[0]]
121
152
 
122
- // Set month
123
- for (let i=split.length; i--;) {
124
- if (split[i]) setMonth((split[i] as Date).getTime())
125
- break
153
+ // Remove/nullify invalid dates
154
+ if (props.mode == 'range') timestamps = timestamps.map(o => o ?? null)
155
+ else if (props.mode == 'multiple') timestamps = timestamps.filter(o => o)
156
+
157
+ // Set month for date mode
158
+ if (props.mode != 'time') {
159
+ for (let i=timestamps.length; i--;) {
160
+ if (timestamps[i]) setMonth(timestamps[i] as number)
161
+ break
162
+ }
126
163
  }
127
164
 
128
165
  // Update the value
129
- const value = mode == 'single' ? split[0]?.getTime() ?? null : split.map(d => d?.getTime() ?? null)
130
- onChange({ target: { name: props.name, value: getOutputValue(value) }})
131
- setLastUpdated(new Date().getTime())
132
- }
133
-
134
- function getInputValue(value: Date|number|null|(Date|number|null)[]) {
135
- const _dates = Array.isArray(value) ? value : [value]
136
- return _dates.map(o => o ? format(o, localePattern) : '').join(mode == 'range' ? ' - ' : ', ')
166
+ const value = props.mode == 'single' || props.mode == 'time' ? timestamps[0] ?? null : timestamps
167
+ onChange(value)
168
+ }
169
+
170
+ function onNowClick() {
171
+ onChange(new Date().getTime())
137
172
  }
138
173
 
139
- function getOutputValue(value: Date|number|null|(Date|number|null)[]): any {
140
- // console.log(value)
141
- return value
174
+ // Common props for the Calendar component
175
+ const commonCalendarProps = {
176
+ className: 'pt-1 pb-2 px-3', month: month, numberOfMonths: numberOfMonths, preserveTime: !!showTime, tz: tz, ...DayPickerProps,
142
177
  }
143
-
178
+
144
179
  return (
145
180
  <Dropdown
146
181
  ref={dropdownRef}
@@ -148,20 +183,34 @@ export function FieldDate({
148
183
  // animate={false}
149
184
  // menuIsOpen={true}
150
185
  minWidth={0}
186
+ dir={dir}
151
187
  menuContent={
152
- <div className="flex">
153
- <Calendar
154
- // Calendar actually accepts an array of dates, but the type is not typed correctly
155
- {...{ mode: mode, value: validDates as any, numberOfMonths: numberOfMonths, month: month }}
156
- {...DayPickerProps}
157
- preserveTime={!!showTime}
158
- onChange={onCalendarChange}
159
- className="pt-1 pb-2 px-3"
160
- />
161
- {!!showTime && mode == 'single' && <TimePicker date={validDates?.[0] ?? undefined} onChange={onCalendarChange} />}
188
+ <div>
189
+ <div className="flex">
190
+ {
191
+ props.mode == 'single' &&
192
+ <Calendar {...commonCalendarProps} mode="single" value={internalValue[0]} onChange={onChange<Timestamp>} />
193
+ }
194
+ {
195
+ (props.mode == 'range' || props.mode == 'multiple') &&
196
+ <Calendar {...commonCalendarProps} mode={props.mode} value={internalValue} onChange={onChange<TimestampArray>} />
197
+ }
198
+ {
199
+ (props.mode == 'time' || (!!showTime && props.mode == 'single')) &&
200
+ <TimePicker value={internalValue?.[0]} onChange={onChange<Timestamp>}
201
+ className={`border-l border-gray-100 ${props.mode == 'single' ? 'min-h-[0]' : ''}`}
202
+ />
203
+ }
204
+ </div>
205
+ {
206
+ props.mode == 'time' &&
207
+ <div className="flex justify-between p-2 border-t border-gray-100">
208
+ <Button color="secondary" size="xs" onClick={() => onNowClick()}>Now</Button>
209
+ <Button color="primary" size="xs" onClick={() => dropdownRef.current?.setIsActive(false)}>Done</Button>
210
+ </div>
211
+ }
162
212
  </div>
163
213
  }
164
- dir={dir}
165
214
  {...DropdownProps}
166
215
  >
167
216
  <div className="grid grid-cols-1">
@@ -173,19 +222,44 @@ export function FieldDate({
173
222
  {prefix}
174
223
  </span>
175
224
  }
176
- <input
225
+ <input
177
226
  {...props}
178
227
  key={'k' + prefixWidth}
179
228
  id={id}
180
229
  autoComplete="off"
181
- className={(props.className||'')}// + props.className?.includes('is-invalid') ? ' is-invalid' : ''}
182
- onBlur={() => setInputValue(getInputValue(validDates))} // onChange should of updated the value -> validValue by this point
230
+ className={(props.className || '')}// + props.className?.includes('is-invalid') ? ' is-invalid' : ''}
183
231
  onChange={onInputChange}
232
+ onBlur={() => setPreventInputValueUpdates(false)}
184
233
  style={{ textIndent: prefixWidth + 'px' }}
185
234
  type="text"
186
- value={inputValue}
235
+ value={inputValueSticky} // allways controlled
236
+ defaultValue={undefined}
187
237
  />
188
238
  </div>
189
239
  </Dropdown>
190
240
  )
191
241
  }
242
+
243
+ /**
244
+ * Parse a date string into a timestamp, optionally, from another timezone
245
+ * @param value - date string to parse
246
+ * @param pattern - date format pattern
247
+ * @param [referenceDate] - required if value doesn't contain a date, e.g. for time only
248
+ * @param [tz] - timezone
249
+ */
250
+ function parseDateString(value: string, pattern: string, tz?: string, referenceDate?: Date) {
251
+ const parsedDate = parse(value.trim(), pattern, referenceDate ?? new Date(), tz ? { in: _tz(tz) } : undefined)
252
+ if (!isValid(parsedDate)) return null
253
+ else return parsedDate.getTime()
254
+ }
255
+
256
+ /**
257
+ * Returns a formatted date string
258
+ * @param [value] - timestamp or date
259
+ * @param [format] - e.g. "dd mmmm yy" (https://date-fns.org/v4.1.0/docs/format#)
260
+ * @param [tz] - display in this timezone
261
+ */
262
+ export function date(value?: null|number|Date, pattern?: string, tz?: string) {
263
+ if (!value || !isValid(value)) return ''
264
+ return format((tz ? new TZDate(value as number, tz) : value), pattern ?? 'do MMMM')
265
+ }