nitro-web 0.0.103 → 0.0.105

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
@@ -34,6 +34,7 @@ export { Modal } from '../components/partials/element/modal'
34
34
  export { Sidebar, type SidebarProps } from '../components/partials/element/sidebar'
35
35
  export { Tooltip } from '../components/partials/element/tooltip'
36
36
  export { Topbar } from '../components/partials/element/topbar'
37
+ export { Table, type TableColumn, type TableProps, type TableRow } from '../components/partials/element/table'
37
38
 
38
39
  // Component Form Elements
39
40
  export { Checkbox } from '../components/partials/form/checkbox'
@@ -0,0 +1,306 @@
1
+ import { JSX, useState, useCallback } from 'react'
2
+ import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
3
+ import { Checkbox, queryObject, queryString, twMerge } from 'nitro-web'
4
+
5
+ export interface TableColumn {
6
+ label: string
7
+ value: string
8
+ className?: string
9
+ disableSort?: boolean
10
+ innerClassName?: string
11
+ minWidth?: number
12
+ overflow?: boolean
13
+ rowLinesMax?: number
14
+ /** Use if the value is different from the sortBy */
15
+ sortByValue?: string
16
+ align?: 'left' | 'center' | 'right'
17
+ }
18
+
19
+ export type TableRow = {
20
+ _id?: string
21
+ }
22
+
23
+ export type TableProps<T> = {
24
+ columns: TableColumn[]
25
+ rows: T[]
26
+ generateTd: (col: TableColumn, row: T, i: number, isLast: boolean) => JSX.Element | null
27
+ generateCheckboxActions?: (selectedRowIds: string[]) => JSX.Element | null
28
+ headerHeightMin?: number
29
+ rowHeightMin?: number
30
+ rowContentHeightMax?: number
31
+ rowLinesMax?: number
32
+ rowSideColor?: (row?: T) => { className: string, width: number }
33
+ rowGap?: number
34
+ columnGap?: number
35
+ columnPaddingX?: number
36
+ className?: string
37
+ tableClassName?: string
38
+ columnClassName?: string
39
+ columnSelectedClassName?: string
40
+ columnHeaderClassName?: string
41
+ checkboxClassName?: string
42
+ checkboxSize?: number
43
+ }
44
+
45
+ export function Table<T extends TableRow>({
46
+ rows,
47
+ columns: columnsProp,
48
+ generateTd,
49
+ generateCheckboxActions,
50
+ headerHeightMin=40,
51
+ rowHeightMin=48,
52
+ rowContentHeightMax,
53
+ rowLinesMax,
54
+ rowSideColor,
55
+ rowGap=0,
56
+ columnGap=11,
57
+ columnPaddingX=11,
58
+ // Class names
59
+ className,
60
+ tableClassName,
61
+ columnClassName,
62
+ columnSelectedClassName,
63
+ columnHeaderClassName,
64
+ checkboxClassName,
65
+ checkboxSize=16,
66
+ }: TableProps<T>) {
67
+ const location = useLocation()
68
+ const [selectedRowIds, setSelectedRowIds] = useState<string[]>([])
69
+ const _columnClassName = 'table-cell py-1 align-middle text-sm border-y border-border ' +
70
+ 'first:border-l last:border-r border-t-0 box-border'
71
+
72
+ const columns = useMemo(() => {
73
+ const checkboxCol: TableColumn = { value: 'checkbox', label: '', disableSort: true }
74
+ const cols = (generateCheckboxActions ? [checkboxCol, ...columnsProp] : columnsProp).map((col, _i) => ({
75
+ ...col,
76
+ rowLinesMax: typeof col.rowLinesMax != 'undefined' ? col.rowLinesMax : rowLinesMax,
77
+ sortByValue: col.sortByValue || col.value,
78
+ align: col.align || 'left',
79
+ }))
80
+ return cols
81
+ }, [columnsProp])
82
+
83
+ const onSelect = useCallback((idOrAll: string, checked: boolean) => {
84
+ setSelectedRowIds((o) => {
85
+ if (idOrAll == 'all' && checked) return rows.map(row => row?._id||'')
86
+ else if (idOrAll == 'all' && !checked) return []
87
+ else if (o.includes(idOrAll) && !checked) return o.filter(id => id != idOrAll)
88
+ else if (!o.includes(idOrAll) && checked) return [...o, idOrAll]
89
+ else return o
90
+ })
91
+ }, [selectedRowIds, rows])
92
+
93
+ const getAlignClass = useCallback((align: TableColumn['align'], _returnJustify?: boolean) => {
94
+ if (_returnJustify) return align == 'left' ? '' : align == 'center' ? 'justify-center' : 'justify-end'
95
+ else return align == 'left' ? '' : align == 'center' ? 'text-center' : 'text-right'
96
+ }, [])
97
+
98
+ // Reset selected rows when the location changes
99
+ useEffect(() => setSelectedRowIds([]), [location.key])
100
+
101
+ // --- Sorting ---
102
+
103
+ const navigate = useNavigate()
104
+ const query = useMemo(() => ({ ...queryObject(location.search) }), [location.search])
105
+ const sortBy = useMemo(() => query.sortBy || 'createdAt', [query.sortBy])
106
+ const sort = useMemo(() => !query.sort && query.sortBy == 'createdAt' ? '-1' : (query.sort || '1'), [query.sort])
107
+
108
+ const onSort = useCallback((item: TableColumn) => {
109
+ const queryStr = queryString({
110
+ ...query,
111
+ sort: sortBy == item.sortByValue ? (sort == '1' ? '-1' : '1') : '1',
112
+ sortBy: item.sortByValue,
113
+ })
114
+ navigate(location.pathname + queryStr, { replace: true })
115
+ }, [location.pathname, query, sort, sortBy])
116
+
117
+ return (
118
+ <div
119
+ style={{ marginTop: -rowGap }}
120
+ className={twMerge('overflow-x-auto thin-scrollbar', className)}
121
+ >
122
+ <div
123
+ style={{ borderSpacing: `0 ${rowGap}px` }}
124
+ className={twMerge('table w-full border-separate', tableClassName)}
125
+ >
126
+ {/* Thead row */}
127
+ <div className="table-row relative">
128
+ {
129
+ columns.map((col, j) => {
130
+ const disableSort = col.disableSort || selectedRowIds.length
131
+ const sideColor = j == 0 && rowSideColor ? rowSideColor(undefined) : undefined
132
+ const pl = j == 0 ? columnPaddingX : columnGap
133
+ const pr = j == columns.length - 1 ? columnPaddingX : columnGap
134
+ return (
135
+ <div
136
+ key={j}
137
+ onClick={disableSort ? undefined : () => onSort(col)}
138
+ style={{ height: headerHeightMin, minWidth: col.minWidth, paddingLeft: pl, paddingRight: pr }}
139
+ className={twMerge(
140
+ _columnClassName,
141
+ 'h-auto text-sm font-medium border-t-1',
142
+ disableSort ? '' : 'cursor-pointer select-none',
143
+ getAlignClass(col.align),
144
+ columnClassName,
145
+ columnHeaderClassName,
146
+ col.className
147
+ )}
148
+ >
149
+ <div
150
+ style={{
151
+ maxHeight: rowContentHeightMax,
152
+ paddingLeft: sideColor && rows.length > 0 ? sideColor.width + 5 : 0,
153
+ }}
154
+ className={twMerge(
155
+ rowContentHeightMax ? 'overflow-hidden' : '',
156
+ getLineClampClassName(col.rowLinesMax),
157
+ col.overflow ? 'overflow-visible' : '',
158
+ col.innerClassName
159
+ )}
160
+ >
161
+ {
162
+ col.value == 'checkbox'
163
+ ? <>
164
+ <Checkbox
165
+ size={checkboxSize}
166
+ name="checkbox-all"
167
+ className='!m-0'
168
+ checkboxClassName={twMerge('border-foreground shadow-[0_1px_2px_0px_#0000001c]', checkboxClassName)}
169
+ onChange={(e) => onSelect('all', e.target.checked)}
170
+ />
171
+ <div
172
+ className={`${selectedRowIds.length ? 'block' : 'hidden'} [&>*]:absolute [&>*]:inset-y-0 [&>*]:left-[68px] [&>*]:z-10`}
173
+ >
174
+ {generateCheckboxActions && generateCheckboxActions(selectedRowIds)}
175
+ </div>
176
+ </>
177
+ : <span className={twMerge(
178
+ 'flex items-center gap-x-2 transition-opacity',
179
+ selectedRowIds.length ? 'opacity-0' : '',
180
+ getAlignClass(col.align, true)
181
+ )}>
182
+ <span>{col.label}</span>
183
+ {
184
+ (!col.disableSort && sortBy == col.sortByValue)
185
+ ? (sort == '1'
186
+ ? <ChevronDownIcon class='shrink-0 size-[16px]' />
187
+ : <ChevronUpIcon class='shrink-0 size-[16px]' />
188
+ )
189
+ : col.align == 'left' && <div class='size-[16px] shrink-0' /> // prevent layout shift on sort
190
+ }
191
+ </span>
192
+ }
193
+ </div>
194
+ </div>
195
+ )
196
+ })
197
+ }
198
+ </div>
199
+ {/* Tbody rows */}
200
+ {
201
+ rows.map((row: T, i: number) => {
202
+ const isSelected = selectedRowIds.includes(row?._id||'')
203
+ return (
204
+ <div
205
+ key={`${row._id}-${i}`}
206
+ className="table-row relative"
207
+ >
208
+ {
209
+ columns.map((col, j) => {
210
+ const sideColor = j == 0 && rowSideColor ? rowSideColor(row) : undefined
211
+ const pl = j == 0 ? columnPaddingX : columnGap
212
+ const pr = j == columns.length - 1 ? columnPaddingX : columnGap
213
+ return (
214
+ <div
215
+ key={j}
216
+ style={{ height: rowHeightMin, paddingLeft: pl, paddingRight: pr }}
217
+ className={twMerge(
218
+ _columnClassName,
219
+ getAlignClass(col.align),
220
+ columnClassName,
221
+ col.className,
222
+ isSelected && `bg-gray-50 ${columnSelectedClassName||''}`
223
+ )}
224
+ >
225
+ <div
226
+ style={{
227
+ maxHeight: rowContentHeightMax,
228
+ paddingLeft: sideColor ? sideColor.width + 5 : 0,
229
+ }}
230
+ className={twMerge(
231
+ rowContentHeightMax ? 'overflow-hidden' : '',
232
+ getLineClampClassName(col.rowLinesMax),
233
+ col.overflow ? 'overflow-visible' : '',
234
+ col.innerClassName
235
+ )}
236
+ >
237
+ {
238
+ // Side color
239
+ sideColor &&
240
+ <div
241
+ className={`absolute top-0 left-0 h-full ${sideColor?.className||''}`}
242
+ style={{ width: sideColor.width }}
243
+ />
244
+ }
245
+ {
246
+ col.value == 'checkbox'
247
+ ? <Checkbox
248
+ size={checkboxSize}
249
+ name={`checkbox-${row._id}`}
250
+ onChange={(e) => onSelect(row?._id || '', e.target.checked)}
251
+ checked={selectedRowIds.includes(row?._id || '')}
252
+ className='!m-0'
253
+ checkboxClassName={twMerge('border-foreground shadow-[0_1px_2px_0px_#0000001c]', checkboxClassName)}
254
+ />
255
+ : generateTd(col, row, i, i == rows.length - 1)
256
+ }
257
+ </div>
258
+ </div>
259
+ )
260
+ })
261
+ }
262
+ </div>
263
+ )
264
+ })
265
+ }
266
+ {
267
+ rows.length == 0 &&
268
+ <div className='table-row relative'>
269
+ {
270
+ columns.map((col, j) => {
271
+ const pl = j == 0 ? columnPaddingX : columnGap
272
+ const pr = j == columns.length - 1 ? columnPaddingX : columnGap
273
+ return (
274
+ <div
275
+ key={j}
276
+ style={{ height: rowHeightMin, paddingLeft: pl, paddingRight: pr }}
277
+ className={twMerge(_columnClassName, columnClassName, col.className)}
278
+ >
279
+ <div
280
+ className={twMerge(
281
+ 'absolute top-0 h-full flex items-center justify-center text-sm text-gray-500',
282
+ col.innerClassName
283
+ )}
284
+ >
285
+ { j == 0 && 'No records found.' }
286
+ </div>
287
+ </div>
288
+ )
289
+ })
290
+ }
291
+ </div>
292
+ }
293
+ </div>
294
+ </div>
295
+ )
296
+ }
297
+
298
+ function getLineClampClassName(num?: number) {
299
+ // Splayed out for tailwind to pick up we are using the classNames below
300
+ if (num == 1) return 'line-clamp-1'
301
+ else if (num == 2) return 'line-clamp-2'
302
+ else if (num == 3) return 'line-clamp-3'
303
+ else if (num == 4) return 'line-clamp-4'
304
+ else if (num == 5) return 'line-clamp-5'
305
+ else if (num == 6) return 'line-clamp-6'
306
+ }
@@ -1,19 +1,36 @@
1
1
  import {
2
2
  Drop, Dropdown, Field, Select, Button as ButtonNitro, Checkbox, GithubLink, Modal, Calendar, injectedConfig,
3
- Filters, FiltersHandleType, FilterType,
3
+ Filters, FiltersHandleType, FilterType, Table, TableColumn,
4
4
  } from 'nitro-web'
5
- import { getCountryOptions, getCurrencyOptions, onChange, ucFirst } from 'nitro-web/util'
6
- import { Check, FileEditIcon } from 'lucide-react'
5
+ import { date, getCountryOptions, getCurrencyOptions, onChange, ucFirst } from 'nitro-web/util'
6
+ import { Check, EllipsisVerticalIcon, FileEditIcon } from 'lucide-react'
7
+
8
+ const perPage = 10
9
+ const statusColors = function(status: string) {
10
+ return {
11
+ pending: 'bg-yellow-400',
12
+ approved: 'bg-green-400',
13
+ rejected: 'bg-red-400',
14
+ }[status]
15
+ }
7
16
 
8
17
  type StyleguideProps = {
9
18
  className?: string
10
- elements?: {
11
- Button?: typeof ButtonNitro
12
- }
19
+ elements?: { Button?: typeof ButtonNitro }
13
20
  children?: React.ReactNode
14
21
  }
15
22
 
23
+ type QuoteExample = {
24
+ _id?: string
25
+ freightType: string
26
+ destination: { code: string }
27
+ date: number
28
+ weight: number
29
+ status: string
30
+ }
31
+
16
32
  export function Styleguide({ className, elements, children }: StyleguideProps) {
33
+ const Button = elements?.Button || ButtonNitro
17
34
  const [customerSearch, setCustomerSearch] = useState('')
18
35
  const [showModal1, setShowModal1] = useState(false)
19
36
  const [state, setState] = useState({
@@ -28,10 +45,12 @@ export function Styleguide({ className, elements, children }: StyleguideProps) {
28
45
  'date-time': Date.now(),
29
46
  calendar: [Date.now(), Date.now() + 1000 * 60 * 60 * 24 * 8],
30
47
  firstName: 'Bruce',
48
+ tableFilter: '',
31
49
  errors: [
32
50
  { title: 'address', detail: 'Address is required' },
33
51
  ],
34
52
  })
53
+
35
54
  const [filterState, setFilterState] = useState({})
36
55
  const filtersRef = useRef<FiltersHandleType>(null)
37
56
  const filters = useMemo(() => {
@@ -79,9 +98,23 @@ export function Styleguide({ className, elements, children }: StyleguideProps) {
79
98
  { label: 'Delete' },
80
99
  ], [])
81
100
 
82
- const Button = elements?.Button || ButtonNitro
101
+ const thead: TableColumn[] = useMemo(() => [
102
+ { value: 'freightType', label: 'Freight Type' },
103
+ { value: 'destination.code', label: 'Destination Code' },
104
+ { value: 'date', label: 'Date' },
105
+ { value: 'weight', label: 'Weight', align: 'center' },
106
+ { value: 'status', label: 'Status' },
107
+ { value: 'actions', label: 'Actions', disableSort: true, overflow: true, minWidth: 100, align: 'right' },
108
+ ], [])
109
+
110
+ const rows: QuoteExample[] = useMemo(() => [
111
+ { _id: '1', freightType: 'air', destination: { code: 'nz' }, date: new Date().getTime(), weight: 100, status: 'pending' },
112
+ { _id: '2', freightType: 'sea', destination: { code: 'nz' }, date: new Date().getTime(), weight: 200, status: 'approved' },
113
+ { _id: '3', freightType: 'road', destination: { code: 'au' }, date: new Date().getTime(), weight: 300, status: 'rejected' },
114
+ // normally you should filter the rows on the api using the query string
115
+ ].filter((row) => row.freightType.match(new RegExp(state.tableFilter, 'i'))), [state.tableFilter])
83
116
 
84
- function onCustomerInputChange (e: { target: { name: string, value: unknown } }) {
117
+ const onCustomerInputChange = (e: { target: { name: string, value: unknown } }) => {
85
118
  if (e.target.name == 'customer' && e.target.value == '0') {
86
119
  setCustomerSearch('')
87
120
  e.target.value = null // clear the select's selected value
@@ -90,10 +123,45 @@ export function Styleguide({ className, elements, children }: StyleguideProps) {
90
123
  onChange(setState, e)
91
124
  }
92
125
 
93
- function onCustomerSearch (search: string) {
126
+ const onCustomerSearch = (search: string) => {
94
127
  setCustomerSearch(search || '')
95
128
  }
96
129
 
130
+ const generateCheckboxActions = useCallback((selectedRowIds: string[]) => {
131
+ return <div class='flex items-center gap-x-2'>
132
+ <Button size='xs' color='dark' onClick={() => { console.log('set', selectedRowIds) }}>Set Status</Button>
133
+ <Button size='xs' color='dark' onClick={() => { console.log('remove', selectedRowIds) }}>Delete</Button>
134
+ </div>
135
+ }, [])
136
+
137
+ const generateTd = useCallback((col: TableColumn, row: QuoteExample, i: number) => {
138
+ switch (col.value) {
139
+ case 'freightType':
140
+ return <div>{ucFirst(row.freightType)}</div>
141
+ case 'destination.code':
142
+ return <div>{row.destination.code.toUpperCase()}</div>
143
+ case 'date':
144
+ return <div>{date(row.date, 'dd mmm, yyyy')}</div>
145
+ case 'weight':
146
+ return <div>{row.weight}</div>
147
+ case 'status':
148
+ return <div>{ucFirst(row.status)}</div>
149
+ case 'actions':
150
+ return (
151
+ <Dropdown
152
+ options={[{ label: 'Set Status' }, { label: 'Delete' }]}
153
+ dir={rows.slice(0, perPage).length - 3 < i ? 'top-right' : 'bottom-right'}
154
+ minWidth={100}
155
+ >
156
+ <Button color='clear' className='ring-0' size='sm' IconCenter={<EllipsisVerticalIcon size={18} strokeWidth={1.5} />} />
157
+ </Dropdown>
158
+ )
159
+ default:
160
+ console.error(`Error: unexpected thead value: ${col.value}`)
161
+ return null
162
+ }
163
+ }, [rows.length])
164
+
97
165
  // Example of updating state
98
166
  // useEffect(() => {
99
167
  // setTimeout(() => {
@@ -103,6 +171,24 @@ export function Styleguide({ className, elements, children }: StyleguideProps) {
103
171
 
104
172
  return (
105
173
  <div class={`text-left max-w-[1100px] ${className}`}>
174
+ <Modal show={showModal1} setShow={setShowModal1}>
175
+ <h3 class="h3">Edit Profile</h3>
176
+ <p class="mb-5">An example modal containing a basic form for editing profiles.</p>
177
+ <form class="mb-8 text-left">
178
+ <div>
179
+ <label for="firstName2">First Name</label>
180
+ <Field name="firstName2" state={state} onChange={(e) => onChange(setState, e)} />
181
+ </div>
182
+ <div>
183
+ <label for="email2">Email Address</label>
184
+ <Field name="email2" type="email" placeholder="Your email address..."/>
185
+ </div>
186
+ </form>
187
+ <div class="flex justify-end">
188
+ <Button color="primary" onClick={() => setShowModal1(false)}>Save</Button>
189
+ </div>
190
+ </Modal>
191
+
106
192
  <GithubLink filename={__filename} />
107
193
  <div class="mb-7">
108
194
  <h1 class="h1">{injectedConfig.isDemo ? 'Design System' : 'Style Guide'}</h1>
@@ -367,26 +453,8 @@ export function Styleguide({ className, elements, children }: StyleguideProps) {
367
453
  </div>
368
454
  </div>
369
455
 
370
- <Modal show={showModal1} setShow={setShowModal1}>
371
- <h3 class="h3">Edit Profile</h3>
372
- <p class="mb-5">An example modal containing a basic form for editing profiles.</p>
373
- <form class="mb-8 text-left">
374
- <div>
375
- <label for="firstName2">First Name</label>
376
- <Field name="firstName2" state={state} onChange={(e) => onChange(setState, e)} />
377
- </div>
378
- <div>
379
- <label for="email2">Email Address</label>
380
- <Field name="email2" type="email" placeholder="Your email address..."/>
381
- </div>
382
- </form>
383
- <div class="flex justify-end">
384
- <Button color="primary" onClick={() => setShowModal1(false)}>Save</Button>
385
- </div>
386
- </Modal>
387
-
388
456
  <h2 class="h3">File Inputs & Calendar</h2>
389
- <div class="grid grid-cols-3 gap-x-6 mb-4 last:mb-0">
457
+ <div class="grid grid-cols-3 gap-x-6 mb-4">
390
458
  <div>
391
459
  <label for="avatar">Avatar</label>
392
460
  <Drop class="is-small" name="avatar" state={state} onChange={(e) => onChange(setState, e)} awsUrl={injectedConfig.awsUrl} />
@@ -401,6 +469,45 @@ export function Styleguide({ className, elements, children }: StyleguideProps) {
401
469
  </div>
402
470
  </div>
403
471
 
472
+ <div class="flex justify-between items-start">
473
+ <h2 class="h3">Tables</h2>
474
+ <Field
475
+ name="tableFilter"
476
+ type="search"
477
+ state={state}
478
+ placeholder="Basic table filter..."
479
+ onChange={(e) => onChange(setState, e)}
480
+ className="!my-0 [&>input]:font-normal [&>input]:text-xs [&>input]:py-1.5" /////todo: need to allow twmerge here
481
+ />
482
+ </div>
483
+ <div class="grid mb-4 last:mb-0">
484
+ <Table
485
+ rows={rows.slice(0, perPage)}
486
+ columns={thead}
487
+ rowSideColor={(row) => ({ className: row?.status == 'pending' ? 'bg-yellow-400' : '', width: 5 })}
488
+ generateCheckboxActions={generateCheckboxActions}
489
+ generateTd={generateTd}
490
+ className="mb-6"
491
+ />
492
+ <Table
493
+ rows={rows.slice(0, 2)}
494
+ columns={thead}
495
+ rowLinesMax={1}
496
+ headerHeightMin={35}
497
+ rowGap={8}
498
+ rowHeightMin={42}
499
+ rowSideColor={(row) => ({ className: `rounded-l-xl ${statusColors(row?.status as string)}`, width: 10 })}
500
+ generateCheckboxActions={generateCheckboxActions}
501
+ generateTd={generateTd}
502
+ tableClassName="rounded-3px"
503
+ columnClassName="border-t-1 first:rounded-l-xl last:rounded-r-xl"
504
+ columnSelectedClassName="bg-indigo-50 border-indigo-300"
505
+ columnHeaderClassName="text-gray-500 text-2xs uppercase border-none"
506
+ checkboxClassName="rounded-[2px] shadow-none"
507
+ className="mb-5"
508
+ />
509
+ </div>
510
+
404
511
  {children}
405
512
  </div>
406
513
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nitro-web",
3
- "version": "0.0.103",
3
+ "version": "0.0.105",
4
4
  "repository": "github:boycce/nitro-web",
5
5
  "homepage": "https://boycce.github.io/nitro-web/",
6
6
  "description": "Nitro is a battle-tested, modular base project to turbocharge your projects, styled using Tailwind 🚀",