nitro-web 0.0.144 → 0.0.146

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.
@@ -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
+ }
@@ -1,14 +1,15 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  // fill-current tw class for lucide icons (https://github.com/lucide-icons/lucide/discussions/458)
3
3
  import { css } from 'twin.macro'
4
- import { FieldCurrency, FieldCurrencyProps, FieldColor, FieldColorProps, FieldDate, FieldDateProps, FieldTime,
5
- FieldTimeProps } from 'nitro-web'
4
+ import { type FieldColorProps, FieldColor } from './field-color'
5
+ import { type FieldCurrencyProps, FieldCurrency } from './field-currency'
6
+ import { type FieldDateProps, FieldDate } from './field-date'
6
7
  import { twMerge, getErrorFromState, deepFind } from 'nitro-web/util'
7
8
  import { Errors, type Error } from 'nitro-web/types'
8
9
  import { MailIcon, CalendarIcon, FunnelIcon, SearchIcon, EyeIcon, EyeOffIcon, ClockIcon } from 'lucide-react'
9
10
  import { memo } from 'react'
10
11
 
11
- type FieldType = 'text' | 'number' | 'password' | 'email' | 'filter' | 'search' | 'textarea' | 'currency' | 'date' | 'color' | 'time'
12
+ type FieldType = 'text' | 'number' | 'password' | 'email' | 'filter' | 'search' | 'textarea' | 'currency' | 'date' | 'color'
12
13
  type InputProps = React.InputHTMLAttributes<HTMLInputElement>
13
14
  type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>
14
15
  type FieldExtraProps = {
@@ -41,8 +42,6 @@ export type FieldProps = (
41
42
  | ({ type: 'currency' } & FieldCurrencyProps & FieldExtraProps)
42
43
  | ({ type: 'color' } & FieldColorProps & FieldExtraProps)
43
44
  | ({ type: 'date' } & FieldDateProps & FieldExtraProps)
44
- | ({ type: 'time' } & FieldTimeProps & FieldExtraProps)
45
- // | ({ type: 'time2' } & FieldTimeProps2 & FieldExtraProps)
46
45
  )
47
46
  type IsFieldCachedProps = {
48
47
  name: string
@@ -57,7 +56,7 @@ export const Field = memo(FieldBase, (prev, next) => {
57
56
 
58
57
  function FieldBase({ state, icon, iconPos: ip, errorTitle, ...props }: FieldProps) {
59
58
  // `type` must be kept as props.type for TS to be happy and follow the conditions below
60
- let value!: string
59
+ let value!: any
61
60
  let Icon!: React.ReactNode
62
61
  const error = getErrorFromState(state, errorTitle || props.name)
63
62
  const type = props.type
@@ -74,10 +73,10 @@ function FieldBase({ state, icon, iconPos: ip, errorTitle, ...props }: FieldProp
74
73
  })
75
74
 
76
75
  // Value: Input is always controlled if state is passed in
77
- if (typeof props.value !== 'undefined') value = props.value as string
76
+ if (typeof props.value !== 'undefined') value = props.value
78
77
  else if (typeof state == 'object') {
79
- const v = deepFind(state, props.name) as string | undefined
80
- value = v ?? ''
78
+ const v = deepFind(state, props.name) ?? ''
79
+ value = v
81
80
  }
82
81
 
83
82
  // Icon
@@ -96,10 +95,10 @@ function FieldBase({ state, icon, iconPos: ip, errorTitle, ...props }: FieldProp
96
95
  Icon = <IconWrapper iconPos={iconPos} icon={icon || <SearchIcon />} className="size-[14px] size-input-icon" />
97
96
  } else if (type == 'color') {
98
97
  Icon = <IconWrapper iconPos={iconPos} icon={icon || <ColorSvg hex={value}/>} className="size-[17px]" />
98
+ } else if (type == 'date' && props.mode == 'time') {
99
+ Icon = <IconWrapper iconPos={iconPos} icon={icon || <ClockIcon />} className="size-[14px] size-input-icon" />
99
100
  } else if (type == 'date') {
100
101
  Icon = <IconWrapper iconPos={iconPos} icon={icon || <CalendarIcon />} className="size-[14px] size-input-icon" />
101
- } else if (type == 'time') {
102
- Icon = <IconWrapper iconPos={iconPos} icon={icon || <ClockIcon />} className="size-[14px] size-input-icon" />
103
102
  } else if (icon) {
104
103
  Icon = <IconWrapper iconPos={iconPos} icon={icon} className="size-[14px] size-input-icon" />
105
104
  }
@@ -121,44 +120,32 @@ function FieldBase({ state, icon, iconPos: ip, errorTitle, ...props }: FieldProp
121
120
  {Icon}<textarea {...props} {...commonProps} />
122
121
  </FieldContainer>
123
122
  )
124
- } else if (type == 'currency') {
125
- return (
126
- <FieldContainer error={error} className={props.className}>
127
- {Icon}<FieldCurrency {...props} {...commonProps} />
128
- </FieldContainer>
129
- )
130
123
  } else if (type == 'color') {
131
124
  return (
132
125
  <FieldContainer error={error} className={props.className}>
133
126
  <FieldColor {...props} {...commonProps} Icon={Icon} />
134
127
  </FieldContainer>
135
128
  )
136
- } else if (type == 'date') {
129
+ } else if (type == 'currency') {
137
130
  return (
138
131
  <FieldContainer error={error} className={props.className}>
139
- <FieldDate {...props} {...commonProps} Icon={Icon} />
132
+ {Icon}<FieldCurrency {...props} {...commonProps} />
140
133
  </FieldContainer>
141
134
  )
142
- } else if (type == 'time') {
135
+ } else if (type == 'date') {
143
136
  return (
144
137
  <FieldContainer error={error} className={props.className}>
145
- <FieldTime {...props} {...commonProps} Icon={Icon} />
138
+ <FieldDate {...props} {...commonProps} Icon={Icon} />
146
139
  </FieldContainer>
147
140
  )
148
- }
149
- // else if (type == 'time2') {
150
- // return (
151
- // <FieldContainer error={error} className={props.className}>
152
- // <FieldTime2 {...props} {...commonProps} Icon={Icon} />
153
- // </FieldContainer>
154
- // )
155
- // }
141
+ }
156
142
  }
157
143
 
158
144
  function FieldContainer({ children, className, error }: { children: React.ReactNode, className?: string, error?: Error }) {
159
145
  return (
160
146
  <div
161
147
  css={style}
148
+ // todo: add (mt-2.5 mb-6 mt-input-before mb-input-after) as export? fieldSpacing?
162
149
  className={twMerge('(mt-2.5 mb-6 mt-input-before mb-input-after) grid grid-cols-1 nitro-field', className || '')}
163
150
  >
164
151
  {children}