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.
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
+ }
@@ -1,6 +1,7 @@
1
- import { JSX, useState, useCallback, Fragment } from 'react'
1
+ import { JSX, useState, useCallback, Fragment, useMemo, useEffect } from 'react'
2
2
  import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
3
3
  import { Checkbox, queryObject, queryString, twMerge } from 'nitro-web'
4
+ import { useLocation, useNavigate } from 'react-router-dom'
4
5
 
5
6
  export type TableRowType = 'row' | 'loading' | 'empty' | 'thead'
6
7
 
@@ -78,6 +79,11 @@ export function Table<T extends TableRow>({
78
79
  'first:border-l last:border-r border-t-0 box-border'
79
80
  const [rand] = useState(() => new Date().getTime() + Math.random())
80
81
 
82
+ const rowsToRender = useMemo(() => {
83
+ // 1) Only show the first row when loading (content hidden), 2) an empty row when there are no records, or all rows
84
+ return rows.length > 0 ? (isLoading ? rows.slice(0, 1) : rows) : [{ _id: '' }] as unknown as T[]
85
+ }, [rows, isLoading])
86
+
81
87
  const columns = useMemo(() => {
82
88
  const checkboxCol: TableColumn = { value: 'checkbox', label: '', disableSort: true }
83
89
  const cols = (generateCheckboxActions ? [checkboxCol, ...columnsProp] : columnsProp).map((col, _i) => ({
@@ -92,7 +98,7 @@ export function Table<T extends TableRow>({
92
98
 
93
99
  const onSelect = useCallback((idOrAll: string, checked: boolean) => {
94
100
  setSelectedRowIds((o) => {
95
- if (idOrAll == 'all' && checked) return (rows ?? []).map(row => row?._id||'')
101
+ if (idOrAll == 'all' && checked) return (rows ?? []).map(row => row._id || '')
96
102
  else if (idOrAll == 'all' && !checked) return []
97
103
  else if (o.includes(idOrAll) && !checked) return o.filter(id => id != idOrAll)
98
104
  else if (!o.includes(idOrAll) && checked) return [...o, idOrAll]
@@ -106,7 +112,7 @@ export function Table<T extends TableRow>({
106
112
  }, [])
107
113
 
108
114
  // Reset selected rows when the location changes, or the number of rows changed (e.g. when a row is removed)
109
- useEffect(() => setSelectedRowIds([]), [location.key, (rows ?? []).map(row => row?._id||'').join(',')])
115
+ useEffect(() => setSelectedRowIds([]), [location.key, (rows ?? []).map(row => row._id || '').join(',')])
110
116
 
111
117
  // --- Sorting ---
112
118
 
@@ -124,8 +130,8 @@ export function Table<T extends TableRow>({
124
130
  navigate(location.pathname + queryStr, { replace: true })
125
131
  }, [location.pathname, query, sort, sortBy])
126
132
 
127
- const getColumnPadding = useCallback((j: number, row: T|undefined, type: TableRowType) => {
128
- const sideColor = j == 0 && rowSideColor ? rowSideColor(row, type) : undefined
133
+ const getColumnPadding = useCallback((j: number, row: T|undefined, rowType: TableRowType) => {
134
+ const sideColor = j == 0 && rowSideColor ? rowSideColor(row, rowType) : undefined
129
135
  const sideColorPadding = sideColor /*&& rows.length > 0*/ ? sideColor.width + 5 : 0
130
136
  const pl = sideColorPadding + (j == 0 ? columnPaddingX : columnGap)
131
137
  const pr = j == columns.length - 1 ? columnPaddingX : columnGap
@@ -214,8 +220,8 @@ export function Table<T extends TableRow>({
214
220
  </div>
215
221
  {/* Tbody rows */}
216
222
  {
217
- !isLoading && rows.map((row: T, i: number) => {
218
- const isSelected = selectedRowIds.includes(row?._id||'')
223
+ rowsToRender.map((row: T, i: number) => {
224
+ const isSelected = selectedRowIds.includes(row._id || '')
219
225
  return (
220
226
  <div
221
227
  key={`${row._id}-${i}`}
@@ -227,7 +233,8 @@ export function Table<T extends TableRow>({
227
233
  >
228
234
  {
229
235
  columns.map((col, j) => {
230
- const { pl, pr, sideColor } = getColumnPadding(j, row, 'row')
236
+ const rowType = row._id ? 'row' : isLoading ? 'loading' : 'empty'
237
+ const { pl, pr, sideColor } = getColumnPadding(j, isLoading ? undefined : row, rowType)
231
238
  if (col.isHidden) return <Fragment key={j} />
232
239
  return (
233
240
  <div
@@ -260,18 +267,31 @@ export function Table<T extends TableRow>({
260
267
  />
261
268
  }
262
269
  {
263
- col.value == 'checkbox'
264
- ? <Checkbox
265
- size={checkboxSize}
266
- name={`checkbox-${row._id}`}
267
- onChange={(e) => onSelect(row?._id || '', e.target.checked)}
268
- checked={selectedRowIds.includes(row?._id || '')}
269
- onClick={(e) => e.stopPropagation()}
270
- hitboxPadding={5}
271
- className='!m-0 py-[5px]' // py-5 is required for hitbox (restricted to tabel cell height)
272
- checkboxClassName={twMerge('border-foreground shadow-[0_1px_2px_0px_#0000001c]', checkboxClassName)}
273
- />
274
- : generateTd(col, row, i, i == rows.length - 1)
270
+ // Rows (content hidden when loading)
271
+ row._id &&
272
+ <div className={isLoading ? 'opacity-0 pointer-events-none' : ''}>
273
+ {
274
+ col.value == 'checkbox'
275
+ ? <Checkbox
276
+ size={checkboxSize}
277
+ name={`checkbox-${row._id}`}
278
+ onChange={(e) => onSelect(row?._id || '', e.target.checked)}
279
+ checked={selectedRowIds.includes(row?._id || '')}
280
+ onClick={(e) => e.stopPropagation()}
281
+ hitboxPadding={5}
282
+ className='!m-0 py-[5px]' // py-5 is required for hitbox (restricted to tabel cell height)
283
+ checkboxClassName={twMerge('border-foreground shadow-[0_1px_2px_0px_#0000001c]', checkboxClassName)}
284
+ />
285
+ : generateTd(col, row, i, i == rows.length - 1)
286
+ }
287
+ </div>
288
+ }
289
+ {
290
+ // Show "loading" or "no records" text in the first column
291
+ j == 0 && (isLoading || !row._id) &&
292
+ <div className={'absolute top-0 h-full flex items-center justify-center text-sm text-gray-500'}>
293
+ { isLoading ? <>Loading<span className="relative ml-[2px] loading-dots" /></> : 'No records found.' }
294
+ </div>
275
295
  }
276
296
  </div>
277
297
  </div>
@@ -282,39 +302,6 @@ export function Table<T extends TableRow>({
282
302
  )
283
303
  })
284
304
  }
285
- {
286
- (isLoading || rows.length == 0) &&
287
- <div className='table-row relative'>
288
- {
289
- columns.map((col, j) => {
290
- const { pl, pr, sideColor } = getColumnPadding(j, undefined, isLoading ? 'loading' : 'empty')
291
- return (
292
- <div
293
- key={j}
294
- style={{ height: rowHeightMin, paddingLeft: pl, paddingRight: pr }}
295
- className={twMerge(_columnClassName, columnClassName, col.className)}
296
- >
297
- {
298
- sideColor &&
299
- <div
300
- className={`absolute top-0 left-0 h-full ${sideColor?.className||''}`}
301
- style={{ width: sideColor.width }}
302
- />
303
- }
304
- <div
305
- className={twMerge(
306
- 'absolute top-0 h-full flex items-center justify-center text-sm text-gray-500',
307
- col.innerClassName
308
- )}
309
- >
310
- { j == 0 && (isLoading ? <>Loading<span className="relative ml-[2px] loading-dots" /></> : 'No records found.') }
311
- </div>
312
- </div>
313
- )
314
- })
315
- }
316
- </div>
317
- }
318
305
  </div>
319
306
  </div>
320
307
  )