Skip to content

Commit bd84616

Browse files
committed
✨ Add DataTable component for Svelte
1 parent 755be14 commit bd84616

File tree

7 files changed

+386
-65
lines changed

7 files changed

+386
-65
lines changed

src/components/DataTable/DataTable.astro

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ const hasPagination = data?.length && itemsPerPage
103103
itemGroups={columnToggleItems}
104104
position="bottom-end"
105105
value={columnToggleLabel}
106-
placeholder={columnToggleLabel}
106+
updateValue={false}
107107
/>
108108
)}
109109
</div>
@@ -286,18 +286,16 @@ const hasPagination = data?.length && itemsPerPage
286286

287287
listen('selectOnChange', event => {
288288
const eventName = event.name.toLowerCase().replace(/\s/g, '')
289-
const table = event.selectElement
290-
.closest('section')
291-
.querySelector('table')
289+
const table = (document.querySelector(`[data-id="w-select-${event.select}"]`)
290+
?.closest('section') as HTMLElement)
291+
.querySelector('table') as HTMLTableElement
292292

293293
const affectedTableCells = Array.from(table.querySelectorAll(`[data-name=${eventName}]`)) as HTMLElement[]
294294

295295
const columnToggleListElement = Array.from(event.list.children)
296296
.find(child => (child as HTMLLIElement).dataset.name === event.name) as HTMLLIElement
297297
const svgIcon = columnToggleListElement.children[0] as HTMLElement
298298

299-
event.selectElement.value = event.selectElement.placeholder
300-
301299
svgIcon.style.opacity = svgIcon.style.opacity === '0'
302300
? '1'
303301
: '0'
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,269 @@
11
<script lang="ts">
2+
import type { HeadingObject, SvelteDataTableProps } from './datatable'
23
4+
import Button from '../Button/Button.svelte'
5+
import Input from '../Input/Input.svelte'
6+
import Pagination from '../Pagination/Pagination.svelte'
7+
import Select from '../Select/Select.svelte'
8+
9+
import { classNames } from '../../utils/classNames'
10+
import { debounce } from '../../utils/debounce'
11+
12+
import checkIcon from '../../icons/check.svg?raw'
13+
import orderIcon from '../../icons/order.svg?raw'
14+
import searchIcon from '../../icons/search.svg?raw'
15+
16+
import styles from './datatable.module.scss'
17+
18+
import type { ListEventType } from '../List/list'
19+
20+
export let headings: SvelteDataTableProps['headings'] = []
21+
export let filterPlaceholder: SvelteDataTableProps['filterPlaceholder'] = 'Filter entries'
22+
export let showFilterIcon: SvelteDataTableProps['showFilterIcon'] = false
23+
export let noResultsLabel: SvelteDataTableProps['noResultsLabel'] = 'No results.'
24+
export let itemsPerPage: SvelteDataTableProps['itemsPerPage'] = null
25+
export let subText: SvelteDataTableProps['subText'] = ''
26+
export let columnToggleLabel: SvelteDataTableProps['columnToggleLabel'] = 'Columns'
27+
export let pagination: SvelteDataTableProps['pagination'] = {}
28+
export let data: SvelteDataTableProps['data'] = []
29+
export let hover: SvelteDataTableProps['hover'] = false
30+
export let striped: SvelteDataTableProps['striped'] = null
31+
export let offsetStripe: SvelteDataTableProps['offsetStripe'] = false
32+
export let compact: SvelteDataTableProps['compact'] = false
33+
export let maxHeight: SvelteDataTableProps['maxHeight'] = ''
34+
export let className: SvelteDataTableProps['className'] = ''
35+
export let onFilter: SvelteDataTableProps['onFilter'] = () => {}
36+
37+
let filteredData: any = data
38+
let toggledData: any = filteredData
39+
let filteredHeadings: any = headings
40+
let page: number = 1
41+
let hasActiveFilter: boolean = false
42+
let sortOrder = 1
43+
44+
const classes = classNames([
45+
styles.table,
46+
hover && styles.hover,
47+
striped && styles[`striped-${striped}s`],
48+
offsetStripe && styles.offset,
49+
compact && styles.compact,
50+
maxHeight && styles.scroll
51+
])
52+
53+
const footerClasses = classNames([
54+
styles.footer,
55+
subText && styles.between
56+
])
57+
58+
const showColumnToggle = headings?.some(heading => {
59+
return typeof heading === 'string' ? false : heading.toggleable
60+
})
61+
62+
const columnToggleItems = [{
63+
items: headings?.length ? headings
64+
.filter(heading => typeof heading !== 'string' && heading.toggleable)
65+
.map(heading => ({
66+
icon: checkIcon,
67+
name: (heading as HeadingObject).name,
68+
value: String(headings.findIndex(h => {
69+
return (h as HeadingObject).name === (heading as HeadingObject).name
70+
}))
71+
})) : []
72+
}]
73+
74+
const columnFilterIndexes = headings?.map(heading => (heading as HeadingObject).filterable)
75+
.map((heading, index) => heading ? index : null)
76+
.filter(heading => heading !== null) || []
77+
78+
const hasPagination = data?.length && itemsPerPage
79+
? data.length > itemsPerPage
80+
: false
81+
82+
const filter = debounce((event: Event) => {
83+
const target = event.target as HTMLInputElement
84+
85+
hasActiveFilter = !!target.value
86+
87+
filteredData = toggledData?.filter((row: string[]) => {
88+
const rowValue = row.filter((_, index) => columnFilterIndexes.includes(index))
89+
.join('')
90+
.toLowerCase()
91+
92+
return rowValue.includes(target.value.toLowerCase())
93+
})
94+
95+
onFilter?.({
96+
results: filteredData,
97+
numberOfResults: filteredData.length
98+
})
99+
}, 400)
100+
101+
const toggleColumns = (event: ListEventType) => {
102+
const columnToggleListElement = Array.from(event.list.children)
103+
.find(child => (child as HTMLLIElement).dataset.name === event.name) as HTMLLIElement
104+
const svgIcon = columnToggleListElement.children[0] as HTMLElement
105+
106+
svgIcon.style.opacity = svgIcon.style.opacity === '0'
107+
? '1'
108+
: '0'
109+
110+
if (svgIcon.style.opacity === '0') {
111+
filteredData = filteredData?.map((row: string[]) => {
112+
return row.map((column, index) => index === Number(event.value) ? null : column)
113+
})
114+
115+
filteredHeadings = filteredHeadings.map((heading: HeadingObject | string) => {
116+
return ((heading as HeadingObject)?.name || heading) === event.name ? null : heading
117+
})
118+
} else {
119+
filteredData = filteredData?.map((row: string[], x: number) => {
120+
return row.map((column, y) => y === Number(event.value) ? data?.[x][y] : column)
121+
})
122+
123+
filteredHeadings = filteredHeadings.map((heading: HeadingObject | string, index: number) => {
124+
return ((headings?.[index] as HeadingObject)?.name || headings?.[index]) === event.name
125+
? headings?.[index]
126+
: heading
127+
})
128+
}
129+
130+
toggledData = filteredData
131+
}
132+
133+
const sort = (index: number) => {
134+
filteredData = filteredData.sort((a: string[], b: string[]) => {
135+
let aValue: string | number = a[index]
136+
let bValue: string | number = b[index]
137+
138+
if (!isNaN(aValue as any)) {
139+
aValue = Number(aValue)
140+
}
141+
142+
if (!isNaN(bValue as any)) {
143+
bValue = Number(bValue)
144+
}
145+
146+
return aValue > bValue
147+
? sortOrder * -1
148+
: sortOrder
149+
})
150+
151+
sortOrder = sortOrder === 1 ? -1 : 1
152+
}
153+
154+
$: isNextPage = (index: number) => {
155+
if (hasPagination && itemsPerPage && !hasActiveFilter) {
156+
const currentPage = Math.ceil((index + 1) / itemsPerPage)
157+
158+
return currentPage !== page ? 'true' : undefined
159+
}
160+
161+
if (hasActiveFilter && itemsPerPage) {
162+
return index >= itemsPerPage ? 'true' : undefined
163+
}
164+
165+
return undefined
166+
}
3167
</script>
168+
169+
<section class={className}>
170+
{#if columnFilterIndexes?.length || showColumnToggle}
171+
<div class={styles.filters}>
172+
{#if columnFilterIndexes?.length}
173+
{#if showFilterIcon}
174+
<Input
175+
type="search"
176+
placeholder={filterPlaceholder}
177+
onInput={filter}
178+
>
179+
{@html searchIcon}
180+
</Input>
181+
{:else}
182+
<Input
183+
type="search"
184+
placeholder={filterPlaceholder}
185+
onInput={filter}
186+
/>
187+
{/if}
188+
{/if}
189+
{#if showColumnToggle}
190+
<Select
191+
name={`data-table-${crypto.randomUUID()}`}
192+
itemGroups={columnToggleItems}
193+
position="bottom-end"
194+
value={columnToggleLabel}
195+
onChange={toggleColumns}
196+
updateValue={false}
197+
/>
198+
{/if}
199+
</div>
200+
{/if}
201+
202+
<div
203+
class={classes}
204+
style={maxHeight ? `max-height:${maxHeight}` : undefined}
205+
>
206+
<table>
207+
{#if filteredHeadings?.length}
208+
<thead>
209+
<tr>
210+
{#each filteredHeadings as heading, index}
211+
{#if heading}
212+
<th>
213+
{#if heading.sortable}
214+
<Button theme="flat" slot="wrapper" onClick={() => sort(index)}>
215+
{heading.name || heading}
216+
{@html orderIcon}
217+
</Button>
218+
{:else}
219+
{heading.name || heading}
220+
{/if}
221+
</th>
222+
{/if}
223+
{/each}
224+
</tr>
225+
</thead>
226+
{/if}
227+
228+
<tbody>
229+
{#if filteredData?.length}
230+
{#each filteredData as row, index}
231+
<tr data-hidden={isNextPage(index)}>
232+
{#each row as column}
233+
{#if column}
234+
<td>
235+
{@html column}
236+
</td>
237+
{/if}
238+
{/each}
239+
</tr>
240+
{/each}
241+
{/if}
242+
<slot />
243+
</tbody>
244+
{#if columnFilterIndexes?.length && !filteredData.length}
245+
<tfoot>
246+
<tr>
247+
<td colspan={data?.[0].length} class={styles['no-results']}>
248+
{noResultsLabel}
249+
</td>
250+
</tr>
251+
</tfoot>
252+
{/if}
253+
</table>
254+
</div>
255+
{#if subText || hasPagination}
256+
<div class={footerClasses}>
257+
{#if subText}
258+
<span class={styles.subtext}>{subText}</span>
259+
{/if}
260+
{#if hasPagination && itemsPerPage && !hasActiveFilter}
261+
<Pagination
262+
{...pagination}
263+
totalPages={Math.ceil((data?.length || 0) / itemsPerPage)}
264+
onChange={event => page = event.page}
265+
/>
266+
{/if}
267+
</div>
268+
{/if}
269+
</section>

src/components/DataTable/datatable.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { PaginationProps } from '../Pagination/pagination'
22

3+
export type DataTableEventType = {
4+
results: string[][]
5+
numberOfResults: number
6+
}
7+
38
export type HeadingObject = {
49
name: string
510
sortable?: boolean
@@ -12,7 +17,7 @@ export type DataTableProps = {
1217
filterPlaceholder?: string
1318
showFilterIcon?: boolean
1419
noResultsLabel?: string
15-
itemsPerPage?: number
20+
itemsPerPage?: number | null
1621
subText?: string
1722
columnToggleLabel?: string
1823
pagination?: PaginationProps
@@ -24,3 +29,11 @@ export type DataTableProps = {
2429
maxHeight?: string
2530
className?: string
2631
}
32+
33+
export type SvelteDataTableProps = {
34+
onFilter?: (event: DataTableEventType) => void
35+
} & DataTableProps
36+
37+
export type ReactDataTableProps = {
38+
onFilter?: (event: DataTableEventType) => void
39+
} & DataTableProps

src/components/Table/Table.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
export let striped: TableProps['striped'] = null
1313
export let offsetStripe: TableProps['offsetStripe'] = false
1414
export let compact: TableProps['compact'] = false
15-
export let maxHeight: TableProps['maxHeight'] = 0
15+
export let maxHeight: TableProps['maxHeight'] = ''
1616
export let className: TableProps['className'] = ''
1717
1818
const classes = classNames([

src/data.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,3 +202,36 @@ export const pagesWithCustomLabels = [
202202
{ label: 'Latest' },
203203
{ label: 'Trending', active: true }
204204
]
205+
206+
export const headings = [
207+
{ name: 'User ID', toggleable: true, sortable: true, filterable: true },
208+
{ name: 'Score', toggleable: true, sortable: true, filterable: true },
209+
'Status'
210+
]
211+
212+
export const toggleableHeadings = [
213+
{ name: 'User ID', toggleable: true },
214+
{ name: 'Score', toggleable: true },
215+
'Status'
216+
]
217+
218+
export const filterableHeadings = [
219+
{ name: 'User ID', toggleable: true, filterable: true },
220+
{ name: 'Score', toggleable: true, filterable: true },
221+
'Status'
222+
]
223+
224+
export const dataTableEntries = [
225+
['#1', '47', 'suspended'],
226+
['#2', '195', 'inactive'],
227+
['#3', '177', 'inactive'],
228+
['#4', '4', 'inactive'],
229+
['#5', '145', 'active'],
230+
['#6', '299', 'suspended'],
231+
['#7', '150', 'active'],
232+
['#8', '23', 'active'],
233+
['#9', '92', 'active'],
234+
['#10', '68', 'inactive'],
235+
['#11', '121', 'inactive'],
236+
['#12', '160', 'inactive']
237+
]

0 commit comments

Comments
 (0)