nitro-web 0.0.145 → 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.
package/client/index.ts CHANGED
@@ -26,12 +26,13 @@ export { Avatar } from '../components/partials/element/avatar'
26
26
  export { Button } from '../components/partials/element/button'
27
27
  export { Calendar, type CalendarProps } from '../components/partials/element/calendar'
28
28
  export { Dropdown, type DropdownProps, type DropdownOption } from '../components/partials/element/dropdown'
29
- export { Filters, type FiltersHandleType, type FilterType } from '../components/partials/element/filters'
29
+ export { Filters, type FiltersHandleType, type FilterType, usePushChangesToPath } from '../components/partials/element/filters'
30
30
  export { GithubLink } from '../components/partials/element/github-link'
31
31
  export { Initials } from '../components/partials/element/initials'
32
32
  export { Message } from '../components/partials/element/message'
33
33
  export { Modal } from '../components/partials/element/modal'
34
34
  export { Sidebar, type SidebarProps } from '../components/partials/element/sidebar'
35
+ export { TimePicker, type TimePickerProps } from '../components/partials/element/timepicker'
35
36
  export { Tooltip } from '../components/partials/element/tooltip'
36
37
  export { Topbar } from '../components/partials/element/topbar'
37
38
  export { Table, type TableColumn, type TableProps, type TableRow, type TableRowType } from '../components/partials/element/table'
@@ -42,10 +43,9 @@ export { Drop } from '../components/partials/form/drop'
42
43
  export { DropHandler } from '../components/partials/form/drop-handler'
43
44
  export { FormError } from '../components/partials/form/form-error'
44
45
  export { Field, isFieldCached, type FieldProps } from '../components/partials/form/field'
45
- export { FieldColor, type FieldColorProps } from '../components/partials/form/field-color'
46
- export { FieldCurrency, type FieldCurrencyProps } from '../components/partials/form/field-currency'
47
- export { FieldDate, type FieldDateProps } from '../components/partials/form/field-date'
48
- export { FieldTime, type FieldTimeProps } from '../components/partials/form/field-time'
46
+ export { type FieldColorProps } from '../components/partials/form/field-color'
47
+ export { type FieldCurrencyProps } from '../components/partials/form/field-currency'
48
+ export { type FieldDateProps } from '../components/partials/form/field-date'
49
49
  export { Location } from '../components/partials/form/location'
50
50
  export { Select, getSelectStyle, type SelectProps, type SelectOption } from '../components/partials/form/select'
51
51
 
@@ -1,79 +1,100 @@
1
- import { DayPicker, getDefaultClassNames, DayPickerProps as DayPickerPropsBase } from 'react-day-picker'
1
+ import { DayPicker, getDefaultClassNames, DayPickerProps as DayPickerPropsBase, TZDate } from 'react-day-picker'
2
2
  import { isValid } from 'date-fns'
3
3
  import 'react-day-picker/style.css'
4
4
  import { IsFirstRender } from 'nitro-web'
5
5
 
6
6
  export const dayButtonClassName = 'size-[33px] text-sm'
7
7
 
8
+ type Timestamp = null | number
9
+ type TimestampArray = null | Timestamp[]
8
10
  type Mode = 'single'|'multiple'|'range'
9
- type ModeSelection<T extends Mode> = (
11
+ type DayPickerSelection<T extends Mode> = (
10
12
  T extends 'single' ? Date | undefined
11
13
  : T extends 'multiple' ? Date[]
12
14
  : { from?: Date; to?: Date }
13
15
  )
16
+
14
17
  export type DayPickerProps = Omit<DayPickerPropsBase,
15
18
  'mode' | 'selected' | 'onSelect' | 'modifiersClassNames' | 'classNames' | 'numberOfMonths' | 'month' | 'onMonthChange'>
16
19
 
17
- export type CalendarProps = DayPickerProps & {
18
- mode?: Mode
19
- onChange?: (value: null|number|(null|number)[]) => void
20
- value?: null|number|string|(null|number|string)[]
20
+ type PreCalendarProps = DayPickerProps & {
21
21
  numberOfMonths?: number
22
- month?: number // the value may be updated from an outside source, thus the month may have changed
22
+ /** month to display in the calendar (the value may be updated from an outside source, thus the month may have changed) */
23
+ month?: number
23
24
  className?: string
24
- preserveTime?: boolean // just for single mode
25
+ /** single mode only: preserve the time of the original date if needed */
26
+ preserveTime?: boolean
27
+ /** timezone to display and set the dates in for the calendar (output always a timestamp) */
28
+ tz?: string
29
+ }
30
+
31
+ // Discriminated union types based on mode
32
+ type CalendarPropsSingle = PreCalendarProps & {
33
+ mode: 'single'
34
+ onChange?: (value: Timestamp) => void
35
+ value?: Timestamp // gracefully handles falsey values
25
36
  }
26
37
 
27
- export function Calendar({ mode='single', onChange, value, numberOfMonths, month: monthProp, className, preserveTime,
28
- ...props }: CalendarProps) {
38
+ type CalendarPropsMultiple = PreCalendarProps & {
39
+ mode: 'multiple' | 'range'
40
+ onChange?: (value: TimestampArray) => void
41
+ value?: TimestampArray // gracefully handles falsey values
42
+ }
43
+
44
+ export type CalendarProps = CalendarPropsSingle | CalendarPropsMultiple
45
+
46
+ export function Calendar({ value, numberOfMonths, month: monthProp, className, preserveTime, tz, ...props }: CalendarProps) {
29
47
  const isFirstRender = IsFirstRender()
30
- const isRange = mode == 'range'
48
+ const isRange = props.mode == 'range'
31
49
 
32
- // Convert the value to an array of valid* dates
33
- const dates = useMemo(() => {
34
- const _dates = Array.isArray(value) ? value : [value]
35
- return _dates.map(date => isValid(date) ? new Date(date as number) : undefined) ////change to null
50
+ // Convert the value to an array of valid dates
51
+ const internalValue = useMemo(() => {
52
+ const _dates = value ? (Array.isArray(value) ? value : [value]) : []
53
+ return _dates.map(date => date && isValid(date) ? new TZDate(date, tz) : undefined)// DayPicker uses undefined (allgood, we output null)
36
54
  }, [value])
37
55
 
38
56
  // Hold the month in state to control the calendar when the input changes
39
- const [month, setMonth] = useState(dates[0])
57
+ const [month, setMonth] = useState(internalValue[0] as Date)
40
58
 
41
59
  // Update the month if its changed from an outside source
42
60
  useEffect(() => {
43
- if (!isFirstRender && monthProp) setMonth(new Date(monthProp))
61
+ if (!isFirstRender && monthProp) setMonth(new TZDate(monthProp, tz))
44
62
  }, [monthProp])
45
63
 
46
- function handleDayPickerSelect<T extends Mode>(newDate: ModeSelection<T>) {
47
- switch (mode as T) {
64
+ function handleDayPickerSelect(value: DayPickerSelection<CalendarProps['mode']>) {
65
+ switch (props.mode) {
48
66
  case 'single': {
49
- const date = newDate as ModeSelection<'single'>
50
- preserveTimeFn(date)
51
- onChange?.(date?.getTime() ?? null)
67
+ const date = preserveTimeFn(value as DayPickerSelection<'single'>)
68
+ props.onChange?.(date ? date.getTime() : null)
52
69
  break
53
70
  }
54
71
  case 'range': {
55
- const { from, to } = (newDate ?? {}) as ModeSelection<'range'>
56
- onChange?.(from ? [from.getTime() || null, to?.getTime() || null] : null)
72
+ const { from, to } = (value ?? {}) as DayPickerSelection<'range'>
73
+ props.onChange?.(from ? [from.getTime() || null, to?.getTime() || null] : null)
57
74
  break
58
75
  }
59
76
  case 'multiple': {
60
- const dates = (newDate as ModeSelection<'multiple'>)?.filter(Boolean) ?? []
61
- onChange?.(dates.map((d) => d.getTime()))
77
+ const dates = (value as DayPickerSelection<'multiple'>)?.filter(Boolean)
78
+ props.onChange?.(dates.length ? dates.map((d) => d.getTime()) : null)
62
79
  break
63
80
  }
64
81
  }
65
82
  }
66
83
 
67
- function preserveTimeFn(date?: Date) {
68
- // Preserve time from the original date if needed
69
- if (preserveTime && dates[0] && date) {
70
- const originalDate = dates[0]
71
- date.setHours(
72
- originalDate.getHours(),
73
- originalDate.getMinutes(),
74
- originalDate.getSeconds(),
75
- originalDate.getMilliseconds()
84
+ function preserveTimeFn(newDate?: Date) {
85
+ // Preserve time from the original date if needed. Since internalValue[0] is a TZDate object, we need to make sure the
86
+ // new date is also a TZDate object in the same timezone.
87
+ if (newDate && preserveTime && internalValue[0]) {
88
+ const tzDate = new TZDate(newDate, tz)
89
+ tzDate.setHours(
90
+ internalValue[0].getHours(),
91
+ internalValue[0].getMinutes(),
92
+ internalValue[0].getSeconds(),
93
+ internalValue[0].getMilliseconds()
76
94
  )
95
+ return tzDate
96
+ } else {
97
+ return newDate
77
98
  }
78
99
  }
79
100
 
@@ -83,6 +104,7 @@ export function Calendar({ mode='single', onChange, value, numberOfMonths, month
83
104
  onMonthChange: setMonth,
84
105
  onSelect: handleDayPickerSelect,
85
106
  numberOfMonths: numberOfMonths || (isRange ? 2 : 1),
107
+ timeZone: tz,
86
108
  modifiersClassNames: {
87
109
  // Add a class without _, TW seems to replace this with a space in the css definition, e.g. &:not(.range middle)
88
110
  range_middle: `${d.range_middle} rangemiddle`,
@@ -115,12 +137,13 @@ export function Calendar({ mode='single', onChange, value, numberOfMonths, month
115
137
  return (
116
138
  <div>
117
139
  {
118
- mode === 'single' ? (
119
- <DayPicker {...props} {...common} mode="single" selected={dates[0]} className={className} />
120
- ) : mode === 'range' ? (
121
- <DayPicker {...props} {...common} mode="range" selected={{ from: dates[0], to: dates[1] }} className={className} />
140
+ props.mode === 'single' ? (
141
+ <DayPicker {...props} {...common} mode="single" selected={internalValue[0]} className={className} />
142
+ ) : props.mode === 'range' ? (
143
+ <DayPicker {...props} {...common} mode="range" selected={{ from: internalValue[0], to: internalValue[1] }}
144
+ className={className} />
122
145
  ) : (
123
- <DayPicker {...props} {...common} mode="multiple" selected={dates.filter((d) => !!d)} className={className} />
146
+ <DayPicker {...props} {...common} mode="multiple" selected={internalValue.filter((d) => !!d)} className={className} />
124
147
  )
125
148
  }
126
149
  </div>
@@ -1,4 +1,5 @@
1
- import { forwardRef, Dispatch, SetStateAction, useRef, useEffect, useImperativeHandle } from 'react'
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { Dispatch, SetStateAction, useRef, useEffect, useLayoutEffect } from 'react'
2
3
  import { Button, Dropdown, Field, Select, type FieldProps, type SelectProps } from 'nitro-web'
3
4
  import { camelCaseToTitle, debounce, omit, queryString, queryObject, twMerge } from 'nitro-web/util'
4
5
  import { ListFilterIcon } from 'lucide-react'
@@ -14,10 +15,11 @@ export type FilterType = (
14
15
  )
15
16
 
16
17
  type FilterState = {
17
- [key: string]: string|true|(string|true)[] // aka queryObject
18
+ [key: string]: unknown
18
19
  }
19
20
 
20
21
  type FiltersProps = {
22
+ /** State passed to the component, values must be processed, i.e. real numbers for dates, etc. */
21
23
  state?: FilterState
22
24
  setState?: Dispatch<SetStateAction<FilterState>>
23
25
  filters?: FilterType[]
@@ -41,26 +43,23 @@ export type FiltersHandleType = {
41
43
 
42
44
  const debounceTime = 250
43
45
 
44
- export const Filters = forwardRef<FiltersHandleType, FiltersProps>(({
46
+ export function Filters({
45
47
  filters,
46
48
  setState: setStateProp,
47
- state: stateProp,
49
+ state: stateProp, // state passed
48
50
  buttonProps,
49
51
  buttonCounterClassName,
50
52
  buttonText,
51
53
  dropdownProps,
52
54
  dropdownFiltersClassName,
53
55
  elements,
54
- }, ref) => {
56
+ }: FiltersProps) {
55
57
  const location = useLocation()
56
- const navigate = useNavigate()
57
58
  const [lastUpdated, setLastUpdated] = useState(0)
58
- const [debouncedSubmit] = useState(() => debounce(submit, debounceTime))
59
- const [stateDefault, setStateDefault] = useState(() => ({ ...queryObject(location.search) }))
59
+ const [stateDefault, setStateDefault] = useState<FilterState>(() => processState({ ...queryObject(location.search) }, filters))
60
60
  const [state, setState] = [stateProp || stateDefault, setStateProp || setStateDefault]
61
- const stateRef = useRef(state)
62
- const locationRef = useRef(location)
63
61
  const count = useMemo(() => Object.keys(state).filter((k) => state[k] && filters?.some((f) => f.name === k)).length, [state, filters])
62
+ const pushChangesToPath = usePushChangesToPath(state)
64
63
 
65
64
  const Elements = {
66
65
  Button: elements?.Button || Button,
@@ -69,29 +68,11 @@ export const Filters = forwardRef<FiltersHandleType, FiltersProps>(({
69
68
  Select: elements?.Select || Select,
70
69
  FilterIcon: elements?.FilterIcon || ListFilterIcon,
71
70
  }
72
-
73
- useImperativeHandle(ref, () => ({
74
- submit: debouncedSubmit,
75
- }))
76
-
77
- useEffect(() => {
78
- return () => debouncedSubmit.cancel()
79
- }, [])
80
71
 
81
- useEffect(() => {
82
- stateRef.current = state
83
- }, [state])
84
-
85
- useEffect(() => {
86
- locationRef.current = location
87
- }, [location])
88
-
89
- useEffect(() => {
90
- // Only update the state if the filters haven't been input changed in the last 500ms
72
+ useLayoutEffect(() => {
73
+ // Only update the state if the filters haven't been input changed in the last 500ms (calls initially since lastUpdated is 0)
91
74
  if (Date.now() - lastUpdated > (debounceTime + 250)) {
92
- setState(() => ({
93
- ...queryObject(location.search),
94
- }))
75
+ setState(() => processState({ ...queryObject(location.search) }, filters))
95
76
  }
96
77
  }, [location.search])
97
78
 
@@ -110,21 +91,15 @@ export const Filters = forwardRef<FiltersHandleType, FiltersProps>(({
110
91
  async function onInputChange(e: {target: {name: string, value: unknown}}) {
111
92
  // console.log('onInputChange', e.target.name, e.target.value)
112
93
  // the state is flattened for the query string, so here we se full paths as the key names e.g. 'job.location': '10')
113
- setState((s) => ({ ...s, [e.target.name]: e.target.value as string }))
94
+ setState((s) => ({ ...s, [e.target.name]: e.target.value as unknown }))
114
95
  onAfterChange()
115
96
  }
116
97
 
117
98
  function onAfterChange() {
118
99
  setLastUpdated(Date.now())
119
- debouncedSubmit()
100
+ pushChangesToPath()
120
101
  }
121
102
 
122
- // Update the URL by replacing the current entry in the history stack
123
- function submit(includePagination?: boolean) {
124
- const queryStr = queryString(omit(stateRef.current, includePagination ? [] : ['page']))
125
- navigate(locationRef.current.pathname + queryStr, { replace: true })
126
- }
127
-
128
103
  function getBasisWidth(width: 'full' | 'half' | 'third' | 'quarter' | 'fifth') {
129
104
  // Need to splay out the classnames for tailwind to work
130
105
  if (width == 'full') return 'w-full'
@@ -134,6 +109,40 @@ export const Filters = forwardRef<FiltersHandleType, FiltersProps>(({
134
109
  else if (width == 'fifth') return 'shrink basis-[calc(20%-8px)]'
135
110
  }
136
111
 
112
+ function processState(state: FilterState, filters: FilterType[]|undefined) {
113
+ // Since queryObject returns a string|true|(string|true)[], we need to parse the values to the correct type for the Fields/Selects
114
+ const output: FilterState = {...state}
115
+ for (const filter of filters || []) {
116
+ const name = filter.name
117
+ // Undefined values
118
+ if (typeof state[name] === 'undefined') {
119
+ output[name] = undefined
120
+
121
+ // Date single needs to be null|string
122
+ } else if (filter.type === 'date' && (filter.mode === 'single' || filter.mode === 'time')) {
123
+ output[name] = parseDateValue(state[name], name)
124
+
125
+ } else if (filter.type === 'date' && (filter.mode === 'range' || filter.mode === 'multiple')) {
126
+ if (!state[name]) state[name] = undefined
127
+ else if (!Array.isArray(state[name])) console.error(`The "${name}" filter expected an array, received:`, state[name])
128
+ else output[name] = state[name].map((v, i) => parseDateValue(v, name + '.' + i))
129
+
130
+ // Remaining filters should accept text values
131
+ } else {
132
+ output[filter.name] = state[filter.name] + ''
133
+ }
134
+ }
135
+ return output
136
+ }
137
+
138
+ function parseDateValue(input: unknown, name: string) {
139
+ const number = parseFloat(input + '')
140
+ if (typeof input === 'undefined') return undefined
141
+ else if (input === null) return null
142
+ if (isNaN(number)) console.error(`The "${name}" filter expected a number, received:`, input)
143
+ else return number
144
+ }
145
+
137
146
  return (
138
147
  <Elements.Dropdown
139
148
  // menuIsOpen={true}
@@ -153,35 +162,26 @@ export const Filters = forwardRef<FiltersHandleType, FiltersProps>(({
153
162
  </div> */}
154
163
  <div class={twMerge(`flex flex-wrap gap-[16px] p-[16px] pb-6 ${dropdownFiltersClassName || ''}`)}>
155
164
  {
156
- filters?.map(({label, width='full', rowClassName, ...filter}, i) => (
157
- <div key={i} class={twMerge(getBasisWidth(width), rowClassName || '')}>
158
- <div class="flex justify-between">
159
- <label for={filter.id || filter.name}>{label || camelCaseToTitle(filter.name)}</label>
160
- <a href="#" class="label font-normal text-secondary underline" onClick={(e) => reset(e, filter)}>Reset</a>
165
+ filters?.map(({label, width='full', rowClassName, ...filter}, i) => {
166
+ // `filter.name` is a full path e.g. 'job.location', not just the key `location`
167
+ const common = { className: '!mb-0', onChange: onInputChange }
168
+ return (
169
+ <div key={i} class={twMerge(getBasisWidth(width), rowClassName || '')}>
170
+ <div class="flex justify-between">
171
+ <label for={filter.id || filter.name}>{label || camelCaseToTitle(filter.name)}</label>
172
+ <a href="#" class="label font-normal text-secondary underline" onClick={(e) => reset(e, filter)}>Reset</a>
173
+ </div>
174
+ {
175
+ // Note: ignore typings for field, it has been sanitised in processState()
176
+ filter.type === 'select'
177
+ ? <Elements.Select {...filter} {...common} value={state[filter.name] ?? ''} type={undefined} />
178
+ : filter.type === 'date'
179
+ ? <Elements.Field {...filter} {...common} value={state[filter.name] as null ?? null} />
180
+ : <Elements.Field {...filter} {...common} value={state[filter.name] as string ?? ''} />
181
+ }
161
182
  </div>
162
- {
163
- filter.type === 'select' &&
164
- <Elements.Select
165
- {...filter}
166
- class="!mb-0"
167
- // `filter.name` is a full path e.g. 'job.location', not just the key `location`
168
- value={typeof state[filter.name] === 'undefined' ? '' : state[filter.name]}
169
- onChange={onInputChange}
170
- type={undefined}
171
- />
172
- }
173
- {
174
- filter.type !== 'select' &&
175
- <Elements.Field
176
- {...filter}
177
- class="!mb-0"
178
- // `filter.name` is a full path e.g. 'job.location', not just the key `location`
179
- value={typeof state[filter.name] === 'undefined' ? '' : state[filter.name] as string}
180
- onChange={onInputChange}
181
- />
182
- }
183
- </div>
184
- ))
183
+ )
184
+ })
185
185
  }
186
186
  </div>
187
187
  </div>
@@ -207,6 +207,35 @@ export const Filters = forwardRef<FiltersHandleType, FiltersProps>(({
207
207
  </Elements.Button>
208
208
  </Elements.Dropdown>
209
209
  )
210
- })
210
+ }
211
211
 
212
- Filters.displayName = 'Filters'
212
+ Filters.displayName = 'Filters'
213
+
214
+ export function usePushChangesToPath(state: { [key: string]: unknown }) {
215
+ // Return a debounced function which updates the query path using the state
216
+ const navigate = useNavigate()
217
+ const location = useLocation()
218
+ const [debouncedPush] = useState(() => debounce(push, debounceTime))
219
+ const stateRef = useRef(state)
220
+ const locationRef = useRef(location)
221
+
222
+ useEffect(() => {
223
+ locationRef.current = location
224
+ }, [location])
225
+
226
+ useEffect(() => {
227
+ return () => debouncedPush.cancel()
228
+ }, [])
229
+
230
+ useEffect(() => {
231
+ stateRef.current = state
232
+ }, [state])
233
+
234
+ // Update the URL by replacing the current entry in the history stack
235
+ function push(includePagination?: boolean) {
236
+ const queryStr = queryString(omit(stateRef.current, includePagination ? [] : ['page']))
237
+ navigate(locationRef.current.pathname + queryStr, { replace: true })
238
+ }
239
+
240
+ return debouncedPush
241
+ }
@@ -0,0 +1,119 @@
1
+ import { isValid, format } from 'date-fns'
2
+ import { TZDate } from '@date-fns/tz'
3
+ import { twMerge } from 'nitro-web'
4
+ import { dayButtonClassName } from '../element/calendar'
5
+
6
+ type Timestamp = null | number
7
+ export type TimePickerProps = {
8
+ className?: string
9
+ onChange?: (value: Timestamp) => void
10
+ tz?: string
11
+ value?: Timestamp
12
+ }
13
+
14
+ export function TimePicker({ value, onChange, className, tz }: TimePickerProps) {
15
+ const refs = {
16
+ hour: useRef<HTMLDivElement>(null),
17
+ minute: useRef<HTMLDivElement>(null),
18
+ period: useRef<HTMLDivElement>(null),
19
+ }
20
+ const lists = [
21
+ [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], // hours
22
+ [
23
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
24
+ 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50,
25
+ 51, 52, 53, 54, 55, 56, 57, 58, 59,
26
+ ], // minutes
27
+ ['AM', 'PM'], // AM/PM
28
+ ]
29
+
30
+ // Convert the value to an valid* date
31
+ const internalValue = useMemo(() => {
32
+ return value && isValid(value) ? new TZDate(value, tz) : undefined
33
+ }, [value])
34
+
35
+ // Get current values from date or use defaults
36
+ const hour = useMemo(() => internalValue ? parseInt(format(internalValue, 'h')) : undefined, [internalValue])
37
+ const minute = useMemo(() => internalValue ? parseInt(format(internalValue, 'm')) : undefined, [internalValue])
38
+ const period = useMemo(() => internalValue ? format(internalValue, 'a') : undefined, [internalValue])
39
+
40
+ // Scroll into view when the date changes
41
+ useEffect(() => {
42
+ if (hour !== undefined) scrollIntoView('hour', hour)
43
+ if (minute !== undefined) scrollIntoView('minute', minute)
44
+ if (period) scrollIntoView('period', period)
45
+ }, [hour, minute, period])
46
+
47
+ const handleTimeChange = (type: 'hour' | 'minute' | 'period', value: string | number) => {
48
+ // use original internValue or new TZDate, make sure to use the same timezone to base it from
49
+ const _internalValue = internalValue ?? new TZDate(new Date(), tz)
50
+ const isPm = format(_internalValue, 'a') === 'PM'
51
+ if (type === 'hour') {
52
+ const newHour = value === 12 ? 0 : value as number
53
+ _internalValue.setHours(newHour + (isPm ? 12 : 0))
54
+ } else if (type === 'minute') {
55
+ _internalValue.setMinutes(value as number)
56
+ } else if (type === 'period') {
57
+ const newHours = _internalValue.getHours() + (isPm && value === 'AM' ? -12 : !isPm && value === 'PM' ? 12 : 0)
58
+ _internalValue.setHours(newHours)
59
+ }
60
+
61
+ onChange?.(_internalValue.getTime())
62
+ }
63
+
64
+ function scrollIntoView (type: 'hour' | 'minute' | 'period', value: string | number) {
65
+ const container = refs[type].current
66
+ if (!container) return
67
+ const element = container.querySelector(`[data-val="${value}"]`) as HTMLElement
68
+ if (!element) return
69
+
70
+ const target =
71
+ element.offsetTop
72
+ - (container.clientHeight / 2)
73
+ + (element.clientHeight / 2)
74
+
75
+ container.scrollTo({ top: target, behavior: 'smooth' })
76
+ }
77
+
78
+ return (
79
+ <div className={twMerge('flex justify-center min-h-[250px]', className)}>
80
+ {
81
+ lists.map((list, i) => {
82
+ const type = i === 0 ? 'hour' : i === 1 ? 'minute' : 'period'
83
+ const currentValue = i === 0 ? hour : i === 1 ? minute : period
84
+ return (
85
+ <div
86
+ key={i}
87
+ ref={refs[type]}
88
+ className="w-[60px] relative overflow-hidden hover:overflow-y-auto border-l border-gray-100 sm-scrollbar first:border-l-0"
89
+ >
90
+ <div className="w-[60px] absolute flex flex-col items-center py-2">
91
+ {/* using absolute since the scrollbar takes up space */}
92
+ {list.map(item => (
93
+ <div
94
+ className="py-[1px] flex group cursor-pointer"
95
+ data-val={item}
96
+ key={item}
97
+ onClick={(_e) => {
98
+ handleTimeChange(type, item)
99
+ }}
100
+ >
101
+ <button
102
+ key={item}
103
+ className={
104
+ `${dayButtonClassName} rounded-full flex justify-center items-center group-hover:bg-gray-100 `
105
+ + (item === currentValue ? '!bg-input-border-focus text-white' : '')
106
+ }
107
+ >
108
+ {item.toString().padStart(2, '0').toLowerCase()}
109
+ </button>
110
+ </div>
111
+ ))}
112
+ </div>
113
+ </div>
114
+ )
115
+ })
116
+ }
117
+ </div>
118
+ )
119
+ }
@@ -1,31 +1,34 @@
1
1
  import { hsvaToHex, hexToHsva, validHex, HsvaColor } from '@uiw/color-convert'
2
2
  import Saturation from '@uiw/react-color-saturation'
3
3
  import Hue from '@uiw/react-color-hue'
4
- import { Dropdown, util } from 'nitro-web'
4
+ import { currency, Dropdown, util } from 'nitro-web'
5
5
 
6
- export type FieldColorProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'|'value'> & {
6
+ export type FieldColorProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'defaultValue'> & {
7
7
  name: string
8
8
  /** name is applied if id is not provided */
9
9
  id?: string
10
- defaultColor?: string
11
10
  Icon?: React.ReactNode
12
11
  onChange?: (event: { target: { name: string, value: string } }) => void
13
- value?: string
12
+ value?: string // e.g. '#333'
13
+ defaultValue?: string // e.g. '#333'
14
14
  }
15
15
 
16
- export function FieldColor({ defaultColor='#333', Icon, onChange: onChangeProp, value: valueProp, ...props }: FieldColorProps) {
17
- const [lastChanged, setLastChanged] = useState(() => `ic-${Date.now()}`)
16
+ export function FieldColor({ defaultValue='#000', Icon, onChange: onChangeProp, ...props }: FieldColorProps) {
18
17
  const isInvalid = props.className?.includes('is-invalid') ? 'is-invalid' : ''
19
18
  const id = props.id || props.name
20
19
 
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))
20
+ // Since value and onChange are optional, we need need to create an internal value state
21
+ const [internalValue, setInternalValue] = useState<FieldColorProps['value']>(props.value ?? defaultValue)
22
+ const inputValue = internalValue
25
23
 
26
- function onInputChange(e: { target: { name: string, value: string } }) {
27
- setLastChanged(`ic-${Date.now()}`)
28
- onChange(e)
24
+ // Update the internal value when the value changes outside of the component
25
+ useEffect(() => {
26
+ if (internalValue !== (props.value ?? defaultValue)) setInternalValue(props.value ?? defaultValue)
27
+ }, [props.value])
28
+
29
+ function onChange(e: { target: { name: string, value: string } }) {
30
+ if (onChangeProp) onChangeProp({ target: { name: props.name, value: e.target.value }})
31
+ else setInternalValue(e.target.value)
29
32
  }
30
33
 
31
34
  return (
@@ -33,7 +36,7 @@ export function FieldColor({ defaultColor='#333', Icon, onChange: onChangeProp,
33
36
  dir="bottom-left"
34
37
  menuToggles={false}
35
38
  menuContent={
36
- <ColorPicker key={lastChanged} defaultColor={defaultColor} name={props.name} value={value} onChange={onChange} />
39
+ <ColorPicker defaultValue={defaultValue} name={props.name} value={internalValue} onChange={onChange} />
37
40
  }
38
41
  >
39
42
  <div className="grid grid-cols-1">
@@ -42,9 +45,9 @@ export function FieldColor({ defaultColor='#333', Icon, onChange: onChangeProp,
42
45
  {...props}
43
46
  className={(props.className || '') + ' ' + isInvalid}
44
47
  id={id}
45
- value={value}
46
- onChange={onInputChange}
47
- onBlur={() => !validHex(value||'') && onInputChange({ target: { name: props.name, value: '' }})}
48
+ value={inputValue}
49
+ onChange={onChange}
50
+ onBlur={() => !validHex(internalValue||'') && onChange({ target: { name: props.name, value: '' }})} // wipe if invalid
48
51
  autoComplete="off"
49
52
  type="text"
50
53
  />
@@ -53,10 +56,15 @@ export function FieldColor({ defaultColor='#333', Icon, onChange: onChangeProp,
53
56
  )
54
57
  }
55
58
 
56
- function ColorPicker({ name='', onChange, value='', defaultColor='' }: FieldColorProps) {
57
- const [hsva, setHsva] = useState(() => hexToHsva(validHex(value) ? value : defaultColor))
59
+ function ColorPicker({ name='', onChange, value='', defaultValue='' }: FieldColorProps) {
60
+ const [hsva, setHsva] = useState(() => hexToHsva(validHex(value) ? value : defaultValue))
58
61
  const [debounce] = useState(() => util.throttle(callOnChange, 50))
59
62
 
63
+ // Update the hsva when the internal value changes
64
+ useEffect(() => {
65
+ if (validHex(value)) setHsva(hexToHsva(value))
66
+ }, [value])
67
+
60
68
  function callOnChange(newHsva: HsvaColor) {
61
69
  if (onChange) onChange({ target: { name: name, value: hsvaToHex(newHsva) }})
62
70
  }