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 +5 -5
- package/components/partials/element/calendar.tsx +63 -40
- package/components/partials/element/filters.tsx +98 -69
- package/components/partials/element/timepicker.tsx +119 -0
- package/components/partials/form/field-color.tsx +27 -19
- package/components/partials/form/field-currency.tsx +108 -102
- package/components/partials/form/field-date.tsx +167 -93
- package/components/partials/form/field.tsx +16 -29
- package/components/partials/styleguide.tsx +94 -40
- package/package.json +3 -4
- package/types/util.d.ts +3 -8
- package/types/util.d.ts.map +1 -1
- package/util.js +9 -24
- package/components/partials/form/field-time.tsx +0 -214
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 {
|
|
46
|
-
export {
|
|
47
|
-
export {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
33
|
-
const
|
|
34
|
-
const _dates = Array.isArray(value) ? value : [value]
|
|
35
|
-
return _dates.map(date => isValid(date) ? new
|
|
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(
|
|
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
|
|
61
|
+
if (!isFirstRender && monthProp) setMonth(new TZDate(monthProp, tz))
|
|
44
62
|
}, [monthProp])
|
|
45
63
|
|
|
46
|
-
function handleDayPickerSelect
|
|
47
|
-
switch (mode
|
|
64
|
+
function handleDayPickerSelect(value: DayPickerSelection<CalendarProps['mode']>) {
|
|
65
|
+
switch (props.mode) {
|
|
48
66
|
case 'single': {
|
|
49
|
-
const date =
|
|
50
|
-
|
|
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 } = (
|
|
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 = (
|
|
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(
|
|
68
|
-
// Preserve time from the original date if needed
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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={
|
|
120
|
-
) : mode === 'range' ? (
|
|
121
|
-
<DayPicker {...props} {...common} mode="range" selected={{ from:
|
|
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={
|
|
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
|
-
|
|
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]:
|
|
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
|
|
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
|
-
}
|
|
56
|
+
}: FiltersProps) {
|
|
55
57
|
const location = useLocation()
|
|
56
|
-
const navigate = useNavigate()
|
|
57
58
|
const [lastUpdated, setLastUpdated] = useState(0)
|
|
58
|
-
const [
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
22
|
-
const [internalValue, setInternalValue] = useState(
|
|
23
|
-
const
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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={
|
|
46
|
-
onChange={
|
|
47
|
-
onBlur={() => !validHex(
|
|
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='',
|
|
57
|
-
const [hsva, setHsva] = useState(() => hexToHsva(validHex(value) ? value :
|
|
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
|
}
|