Skip to content

Commit ec044f1

Browse files
committed
✨ Add DataTable component to React
1 parent bd84616 commit ec044f1

File tree

10 files changed

+335
-20
lines changed

10 files changed

+335
-20
lines changed

src/components/DataTable/DataTable.astro

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ const {
3131
offsetStripe,
3232
compact,
3333
maxHeight,
34-
className
34+
className,
35+
id
3536
} = Astro.props
3637
3738
const classes = [
@@ -80,7 +81,7 @@ const hasPagination = data?.length && itemsPerPage
8081
: false
8182
---
8283

83-
<section class={className}>
84+
<section class={className} id={id}>
8485
{(!!columnFilterItems?.length || showColumnToggle) && (
8586
<div class={styles.filters}>
8687
{!!columnFilterItems?.length && (
@@ -99,7 +100,7 @@ const hasPagination = data?.length && itemsPerPage
99100

100101
{showColumnToggle && (
101102
<Select
102-
name={`data-table-${crypto.randomUUID()}`}
103+
name={`data-table-${id || crypto.randomUUID()}`}
103104
itemGroups={columnToggleItems}
104105
position="bottom-end"
105106
value={columnToggleLabel}

src/components/DataTable/DataTable.svelte

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
export let compact: SvelteDataTableProps['compact'] = false
3333
export let maxHeight: SvelteDataTableProps['maxHeight'] = ''
3434
export let className: SvelteDataTableProps['className'] = ''
35+
export let id: SvelteDataTableProps['id'] = ''
3536
export let onFilter: SvelteDataTableProps['onFilter'] = () => {}
3637
3738
let filteredData: any = data
@@ -108,15 +109,15 @@
108109
: '0'
109110
110111
if (svgIcon.style.opacity === '0') {
111-
filteredData = filteredData?.map((row: string[]) => {
112+
filteredData = (hasActiveFilter ? data : filteredData)?.map((row: string[]) => {
112113
return row.map((column, index) => index === Number(event.value) ? null : column)
113114
})
114115
115116
filteredHeadings = filteredHeadings.map((heading: HeadingObject | string) => {
116117
return ((heading as HeadingObject)?.name || heading) === event.name ? null : heading
117118
})
118119
} else {
119-
filteredData = filteredData?.map((row: string[], x: number) => {
120+
filteredData = (hasActiveFilter ? data : filteredData)?.map((row: string[], x: number) => {
120121
return row.map((column, y) => y === Number(event.value) ? data?.[x][y] : column)
121122
})
122123
@@ -127,6 +128,7 @@
127128
})
128129
}
129130
131+
hasActiveFilter = false
130132
toggledData = filteredData
131133
}
132134
@@ -166,7 +168,7 @@
166168
}
167169
</script>
168170

169-
<section class={className}>
171+
<section class={className || null} id={id || null}>
170172
{#if columnFilterIndexes?.length || showColumnToggle}
171173
<div class={styles.filters}>
172174
{#if columnFilterIndexes?.length}
@@ -188,7 +190,7 @@
188190
{/if}
189191
{#if showColumnToggle}
190192
<Select
191-
name={`data-table-${crypto.randomUUID()}`}
193+
name={`data-table-${id || crypto.randomUUID()}`}
192194
itemGroups={columnToggleItems}
193195
position="bottom-end"
194196
value={columnToggleLabel}
@@ -261,6 +263,7 @@
261263
<Pagination
262264
{...pagination}
263265
totalPages={Math.ceil((data?.length || 0) / itemsPerPage)}
266+
currentPage={page}
264267
onChange={event => page = event.page}
265268
/>
266269
{/if}
Lines changed: 275 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,287 @@
1-
import React from 'react'
2-
import type { DataTableProps } from './datatable'
1+
import React, { useState } from 'react'
2+
import type { HeadingObject, ReactDataTableProps } from './datatable'
3+
4+
import Button from '../Button/Button.tsx'
5+
import ConditionalWrapper from '../ConditionalWrapper/ConditionalWrapper.tsx'
6+
import Input from '../Input/Input.tsx'
7+
import Pagination from '../Pagination/Pagination.tsx'
8+
import Select from '../Select/Select.tsx'
39

410
import { classNames } from '../../utils/classNames'
11+
import { debounce } from '../../utils/debounce'
12+
13+
import checkIcon from '../../icons/check.svg?raw'
14+
import orderIcon from '../../icons/order.svg?raw'
15+
import searchIcon from '../../icons/search.svg?raw'
516

617
import styles from './datatable.module.scss'
718

19+
import type { ListEventType } from '../List/list'
20+
21+
// eslint-disable-next-line complexity
822
const DataTable = ({
9-
className
10-
}: DataTableProps) => {
23+
headings,
24+
filterPlaceholder = 'Filter entries',
25+
showFilterIcon,
26+
noResultsLabel = 'No results.',
27+
itemsPerPage,
28+
subText,
29+
columnToggleLabel = 'Columns',
30+
pagination,
31+
data,
32+
hover,
33+
striped,
34+
offsetStripe,
35+
compact,
36+
maxHeight,
37+
className,
38+
id,
39+
onFilter,
40+
children
41+
}: ReactDataTableProps) => {
42+
const [filteredData, setFilteredData] = useState<any>(data)
43+
const [toggledData, setToggledData] = useState(filteredData)
44+
const [filteredHeadings, setFilteredHeadings] = useState<any>(headings)
45+
const [page, setPage] = useState(1)
46+
const [hasActiveFilter, setHasActiveFilter] = useState(false)
47+
const [sortOrder, setSortOrder] = useState(1)
48+
1149
const classes = classNames([
12-
styles.datatable,
13-
className
50+
styles.table,
51+
hover && styles.hover,
52+
striped && styles[`striped-${striped}s`],
53+
offsetStripe && styles.offset,
54+
compact && styles.compact,
55+
maxHeight && styles.scroll
1456
])
1557

16-
return <div className={classes}>DataTable</div>
58+
const footerClasses = classNames([
59+
styles.footer,
60+
subText && styles.between
61+
])
62+
63+
const styleVariables = {
64+
...(maxHeight && { 'max-height': maxHeight })
65+
} as React.CSSProperties
66+
67+
const showColumnToggle = headings?.some(heading => {
68+
return typeof heading === 'string' ? false : heading.toggleable
69+
})
70+
71+
const columnToggleItems = [{
72+
items: headings?.length ? headings
73+
.filter(heading => typeof heading !== 'string' && heading.toggleable)
74+
.map(heading => ({
75+
icon: checkIcon,
76+
name: (heading as HeadingObject).name,
77+
value: String(headings.findIndex(h => {
78+
return (h as HeadingObject).name === (heading as HeadingObject).name
79+
}))
80+
})) : []
81+
}]
82+
83+
const columnFilterIndexes = headings?.map(heading => (heading as HeadingObject).filterable)
84+
.map((heading, index) => heading ? index : null)
85+
.filter(heading => heading !== null) || []
86+
87+
const hasPagination = data?.length && itemsPerPage
88+
? data.length > itemsPerPage
89+
: false
90+
91+
const filter = debounce((event: Event) => {
92+
const target = event.target as HTMLInputElement
93+
94+
setHasActiveFilter(!!target.value)
95+
96+
setFilteredData(toggledData?.filter((row: string[]) => {
97+
const rowValue = row.filter((_, index) => columnFilterIndexes.includes(index))
98+
.join('')
99+
.toLowerCase()
100+
101+
return rowValue.includes(target.value.toLowerCase())
102+
}))
103+
104+
onFilter?.({
105+
results: filteredData,
106+
numberOfResults: filteredData.length
107+
})
108+
}, 400)
109+
110+
const toggleColumns = (event: ListEventType) => {
111+
const columnToggleListElement = Array.from(event.list.children)
112+
.find(child => (child as HTMLLIElement).dataset.name === event.name) as HTMLLIElement
113+
const svgIcon = columnToggleListElement.children[0] as HTMLElement
114+
let mappedData
115+
116+
svgIcon.style.opacity = svgIcon.style.opacity === '0'
117+
? '1'
118+
: '0'
119+
120+
if (svgIcon.style.opacity === '0') {
121+
mappedData = (hasActiveFilter ? data : filteredData)?.map((row: string[]) => {
122+
return row.map((column, index) => index === Number(event.value) ? null : column)
123+
})
124+
125+
setFilteredData(mappedData)
126+
127+
setFilteredHeadings(filteredHeadings.map((heading: HeadingObject | string) => {
128+
return ((heading as HeadingObject)?.name || heading) === event.name ? null : heading
129+
}))
130+
} else {
131+
mappedData = (hasActiveFilter ? data : filteredData)?.map((row: string[], x: number) => {
132+
return row.map((column, y) => y === Number(event.value) ? data?.[x][y] : column)
133+
})
134+
135+
setFilteredData(mappedData)
136+
137+
setFilteredHeadings(filteredHeadings.map((heading: HeadingObject | string, index: number) => {
138+
return ((headings?.[index] as HeadingObject)?.name || headings?.[index]) === event.name
139+
? headings?.[index]
140+
: heading
141+
}))
142+
}
143+
144+
setToggledData(mappedData)
145+
}
146+
147+
const sort = (index: number) => {
148+
const sortedData = filteredData.sort((a: string[], b: string[]) => {
149+
let aValue: string | number = a[index]
150+
let bValue: string | number = b[index]
151+
152+
if (!isNaN(aValue as any)) {
153+
aValue = Number(aValue)
154+
}
155+
156+
if (!isNaN(bValue as any)) {
157+
bValue = Number(bValue)
158+
}
159+
160+
return aValue > bValue
161+
? sortOrder * -1
162+
: sortOrder
163+
})
164+
165+
setFilteredData(sortedData)
166+
setSortOrder(sortOrder === 1 ? -1 : 1)
167+
}
168+
169+
const isNextPage = (index: number) => {
170+
if (hasPagination && itemsPerPage && !hasActiveFilter) {
171+
const currentPage = Math.ceil((index + 1) / itemsPerPage)
172+
173+
return currentPage !== page ? 'true' : undefined
174+
}
175+
176+
if (hasActiveFilter && itemsPerPage) {
177+
return index >= itemsPerPage ? 'true' : undefined
178+
}
179+
180+
return undefined
181+
}
182+
183+
return (
184+
<section className={className} id={id}>
185+
{(!!columnFilterIndexes?.length || showColumnToggle) && (
186+
<div className={styles.filters}>
187+
{!!columnFilterIndexes?.length && (
188+
<Input
189+
type="search"
190+
placeholder={filterPlaceholder}
191+
onInput={filter}
192+
>
193+
{showFilterIcon && (
194+
<span dangerouslySetInnerHTML={{ __html: searchIcon }} />
195+
)}
196+
</Input>
197+
)}
198+
{showColumnToggle && (
199+
<Select
200+
name={`data-table-${id || crypto.randomUUID()}`}
201+
itemGroups={columnToggleItems}
202+
position="bottom-end"
203+
value={columnToggleLabel}
204+
onChange={toggleColumns}
205+
updateValue={false}
206+
/>
207+
)}
208+
</div>
209+
)}
210+
211+
<div className={classes} style={styleVariables}>
212+
<table>
213+
{!!filteredHeadings?.length && (
214+
<thead>
215+
<tr>
216+
{filteredHeadings?.map((heading: HeadingObject | string, index: number) => {
217+
if (!heading) {
218+
return null
219+
}
220+
221+
return (
222+
<th key={index}>
223+
<ConditionalWrapper
224+
condition={!!(heading as HeadingObject).sortable}
225+
wrapper={children => (
226+
<Button theme="flat" slot="wrapper" onClick={() => sort(index)}>
227+
{children}
228+
<span dangerouslySetInnerHTML={{ __html: orderIcon }} />
229+
</Button>
230+
)}
231+
>
232+
{(heading as HeadingObject).name || heading as string}
233+
</ConditionalWrapper>
234+
</th>
235+
)
236+
})}
237+
</tr>
238+
</thead>
239+
)}
240+
241+
<tbody>
242+
{filteredData?.map((row: string[], rowIndex: number) => (
243+
<tr key={rowIndex} data-hidden={isNextPage(rowIndex)}>
244+
{row.filter(Boolean).map((column, columnIndex) => (
245+
<td
246+
key={columnIndex}
247+
dangerouslySetInnerHTML={{ __html: column }}
248+
/>
249+
))}
250+
</tr>
251+
))}
252+
{children}
253+
</tbody>
254+
{!filteredData?.length && (
255+
<tfoot>
256+
<tr>
257+
<td
258+
colSpan={data?.[0].length}
259+
className={styles['no-results']}
260+
>
261+
{noResultsLabel}
262+
</td>
263+
</tr>
264+
</tfoot>
265+
)}
266+
</table>
267+
</div>
268+
{(subText || hasPagination) && (
269+
<div className={footerClasses}>
270+
{subText && (
271+
<span className={styles.subtext}>{subText}</span>
272+
)}
273+
{(hasPagination && itemsPerPage && !hasActiveFilter) && (
274+
<Pagination
275+
{...pagination}
276+
totalPages={Math.ceil((data?.length || 0) / itemsPerPage)}
277+
currentPage={page}
278+
onChange={event => setPage(event.page)}
279+
/>
280+
)}
281+
</div>
282+
)}
283+
</section>
284+
)
17285
}
18286

19287
export default DataTable

0 commit comments

Comments
 (0)