commit
58c0036b52
23 changed files with 495 additions and 489 deletions
177
src/components/Table/Table.mdx
Normal file
177
src/components/Table/Table.mdx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { Meta, Canvas } from '@storybook/blocks';
|
||||
|
||||
import * as TableStories from './Table.stories.svelte';
|
||||
|
||||
<Meta of={TableStories} />
|
||||
|
||||
# Table
|
||||
|
||||
The `Table` component presents data as a table that you can make searchable, filtereable, sortable, or paginated.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { Table } from '@reuters-graphics/graphics-components';
|
||||
|
||||
import data from './data.json'; // Import your data
|
||||
</script>
|
||||
|
||||
<Table {data} />
|
||||
```
|
||||
|
||||
<Canvas of={TableStories.Demo} />
|
||||
|
||||
## Text elements
|
||||
|
||||
Set the `title`, `dek`, `notes` and `source` options to add supporting metadata above and below the table.
|
||||
|
||||
```svelte
|
||||
<Table
|
||||
data={yourData}
|
||||
title='Career home run leaders'
|
||||
dek=`In baseball,
|
||||
a home run (also known as a "dinger" or "tater") occurs when a batter hits the
|
||||
ball over the outfield fence. When a home run is hit, the batter and any runners
|
||||
on base are able to score.`
|
||||
notes='Note: As of Opening Day 2023'
|
||||
source='Source: Baseball Reference'
|
||||
/>
|
||||
```
|
||||
|
||||
<Canvas of={TableStories.Text} />
|
||||
|
||||
## Truncated
|
||||
|
||||
When your table has 10 or more rows, consider clipping it by setting the `truncated` option. When it is enabled, the table is clipped and readers must click a button below the table to see all rows.
|
||||
|
||||
By default, this configuration will limit the table to 5 records. Change the cutoff point by adjusting the `truncateLength` option.
|
||||
|
||||
This is a good option for simple tables with between 10 and 30 rows. It works best when the table doesn't require interactivity.
|
||||
|
||||
```svelte
|
||||
<Table data={yourData} truncated={true} source="Source: Baseball Reference" />
|
||||
```
|
||||
|
||||
<Canvas of={TableStories.Truncated} />
|
||||
|
||||
## Paginated
|
||||
|
||||
When your table has many rows, you should consider breaking it up into pages by setting `paginated` to `true`. When it is enabled, readers can leaf through the data using a set of buttons below the table.
|
||||
|
||||
By default, there are 25 records per page. Change the number by adjusting the `pageSize` option.
|
||||
|
||||
This is a good option when publishing large tables for readers to explore. It works well with interactive features like searching and filters.
|
||||
|
||||
```svelte
|
||||
<Table
|
||||
data={yourData}
|
||||
paginated={true}
|
||||
title="Press Freedom Index"
|
||||
source="Reporters Without Borders"
|
||||
/>
|
||||
```
|
||||
|
||||
<Canvas of={TableStories.Paginated} />
|
||||
|
||||
## Search bar
|
||||
|
||||
Allow users to search the table by setting the optional `searchable` option to `true`. Modify the default text that appears in the box by setting `searchPlaceholder` to a different placeholder text.
|
||||
|
||||
```svelte
|
||||
<Table
|
||||
data={yourData}
|
||||
searchable={true}
|
||||
paginated={true}
|
||||
searchPlaceholder="Search press freedom data"
|
||||
,
|
||||
title="Press Freedom Index"
|
||||
notes="Source: Reporters Without Borders"
|
||||
/>
|
||||
```
|
||||
|
||||
<Canvas of={TableStories.Search} />
|
||||
|
||||
## Filter
|
||||
|
||||
Allow users to filter the table by providing one of the attributes as the `filterField`. This works best with categorical columns.
|
||||
|
||||
Set `filterLabel` to make the category name more readable. For example, if the column is `Region`, set `filterLabel` to `regions` or `regions of the world`.
|
||||
|
||||
```svelte
|
||||
<Table
|
||||
data={yourData}
|
||||
filterField="Region"
|
||||
filterLabel="regions"
|
||||
paginated={true}
|
||||
title="Press Freedom Index"
|
||||
notes="Source: Reporters Without Borders"
|
||||
/>
|
||||
```
|
||||
|
||||
<Canvas of={TableStories.Filter} />
|
||||
|
||||
## Search and filter
|
||||
|
||||
Feel free to both search and filter.
|
||||
|
||||
```svelte
|
||||
<Table
|
||||
data={yourData}
|
||||
searchable={true}
|
||||
paginated={true}
|
||||
filterField="Region"
|
||||
filterLabel="regions"
|
||||
title="Press Freedom Index"
|
||||
dek="Reporters Without Borders ranks countries based on their level of press freedom using criteria such as the degree of media pluralism and violence against journalists."
|
||||
source="Source: Reporters Without Borders"
|
||||
/>
|
||||
```
|
||||
|
||||
<Canvas of={TableStories.SearchAndFilter} />
|
||||
```
|
||||
|
||||
## Sort
|
||||
|
||||
Allow users to sort the table by setting `sortable` to `true`. Specify the starting order by setting `sortField` to a column name and `sortDirection` to `ascending` or `descending`.
|
||||
|
||||
By default, all fields are sortable. If you'd like to limit the columns where sorting is allowed, provide a list to the `sortableFields` option.
|
||||
|
||||
```svelte
|
||||
<Table
|
||||
data={yourData}
|
||||
sortable={true}
|
||||
paginated={true}
|
||||
sortField="Score"
|
||||
sortDirection="descending"
|
||||
title="Press Freedom Index"
|
||||
source="Source: Reporters Without Borders"
|
||||
/>
|
||||
```
|
||||
|
||||
<Canvas of={TableStories.Sort} />
|
||||
|
||||
## Format
|
||||
|
||||
Format column values by supplying functions keyed to field names with the `fieldFormatters` option. Columns are still sorted using the raw, underlying values.
|
||||
|
||||
Among other things, this feature can be used to provide a unit of measurement, such as `$` or `%`, with numeric fields.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
const fieldFormatters = {
|
||||
// The key must match the column name in the data exactly
|
||||
'Net worth (in billions)': (v) => '$' + v.toFixed(1),
|
||||
};
|
||||
</script>
|
||||
|
||||
<Table
|
||||
data={yourData}
|
||||
{fieldFormatters}
|
||||
sortable={true}
|
||||
sortField="Net worth (in billions)"
|
||||
sortDirection="descending"
|
||||
title="The Richest Women in the World"
|
||||
source="Source: Forbes"
|
||||
/>
|
||||
```
|
||||
|
||||
<Canvas of={TableStories.Format} />
|
||||
|
|
@ -1,60 +1,30 @@
|
|||
<script module lang="ts">
|
||||
// @ts-ignore raw
|
||||
import componentDocs from './stories/docs/component.md?raw';
|
||||
// @ts-ignore raw
|
||||
import metadataDocs from './stories/docs/metadata.md?raw';
|
||||
// @ts-ignore raw
|
||||
import truncateDocs from './stories/docs/truncate.md?raw';
|
||||
// @ts-ignore raw
|
||||
import paginateDocs from './stories/docs/paginate.md?raw';
|
||||
// @ts-ignore raw
|
||||
import searchDocs from './stories/docs/search.md?raw';
|
||||
// @ts-ignore raw
|
||||
import filterDocs from './stories/docs/filter.md?raw';
|
||||
// @ts-ignore raw
|
||||
import bothDocs from './stories/docs/both.md?raw';
|
||||
// @ts-ignore raw
|
||||
import sortDocs from './stories/docs/sort.md?raw';
|
||||
// @ts-ignore raw
|
||||
import formatDocs from './stories/docs/format.md?raw';
|
||||
// @ts-ignore raw
|
||||
import styleDocs from './stories/docs/style.md?raw';
|
||||
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Table from './Table.svelte';
|
||||
|
||||
import { withComponentDocs, withStoryDocs } from '$docs/utils/withParams.js';
|
||||
|
||||
export const meta = {
|
||||
title: 'Components/Text elements/Table',
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/Graphics/Table',
|
||||
component: Table,
|
||||
...withComponentDocs(componentDocs),
|
||||
argTypes: {
|
||||
width: {
|
||||
control: 'select',
|
||||
options: ['normal', 'wide', 'wider', 'widest', 'fluid'],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Template, Story } from '@storybook/addon-svelte-csf';
|
||||
import pressFreedom from './demo/pressFreedom.json';
|
||||
import homeRuns from './demo/homeRuns.json';
|
||||
import richestWomen from './demo/richestWomen.json';
|
||||
import type { Formatter } from './utils';
|
||||
|
||||
import pressFreedom from './stories/pressFreedom.json';
|
||||
import homeRuns from './stories/homeRuns.json';
|
||||
import richestWomen from './stories/richestWomen.json';
|
||||
|
||||
const currencyFormat = (v: number) => '$' + v.toFixed(1);
|
||||
const currencyFormat: Formatter<number> = (v: number) => '$' + v.toFixed(1);
|
||||
</script>
|
||||
|
||||
<Template>
|
||||
{#snippet children({ args })}
|
||||
<Table {...args} />
|
||||
{/snippet}
|
||||
</Template>
|
||||
|
||||
<Story
|
||||
name="Default"
|
||||
name="Demo"
|
||||
args={{
|
||||
width: 'normal',
|
||||
data: homeRuns,
|
||||
|
|
@ -62,10 +32,9 @@
|
|||
/>
|
||||
|
||||
<Story
|
||||
name="Metadata"
|
||||
{...withStoryDocs(metadataDocs)}
|
||||
name="Text elements"
|
||||
exportName="Text"
|
||||
args={{
|
||||
width: 'normal',
|
||||
data: homeRuns,
|
||||
title: 'Career home run leaders',
|
||||
dek: 'In baseball, a home run (also known as a "dinger" or "tater") occurs when a batter hits the ball over the outfield fence. When a home run is hit, the batter and any runners on base are able to score.',
|
||||
|
|
@ -75,8 +44,7 @@
|
|||
/>
|
||||
|
||||
<Story
|
||||
name="Truncate"
|
||||
{...withStoryDocs(truncateDocs)}
|
||||
name="Truncated"
|
||||
args={{
|
||||
data: homeRuns,
|
||||
truncated: true,
|
||||
|
|
@ -85,23 +53,23 @@
|
|||
/>
|
||||
|
||||
<Story
|
||||
name="Paginate"
|
||||
{...withStoryDocs(paginateDocs)}
|
||||
name="Paginated"
|
||||
args={{
|
||||
data: pressFreedom,
|
||||
title: 'Press Freedom Index',
|
||||
paginated: true,
|
||||
title: 'Press Freedom Index',
|
||||
source: 'Source: Reporters Without Borders',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Search"
|
||||
{...withStoryDocs(searchDocs)}
|
||||
name="Search bar"
|
||||
exportName="Search"
|
||||
args={{
|
||||
data: pressFreedom,
|
||||
searchable: true,
|
||||
paginated: true,
|
||||
searchPlaceholder: 'Search press freedom data',
|
||||
title: 'Press Freedom Index',
|
||||
source: 'Source: Reporters Without Borders',
|
||||
}}
|
||||
|
|
@ -109,11 +77,11 @@
|
|||
|
||||
<Story
|
||||
name="Filter"
|
||||
{...withStoryDocs(filterDocs)}
|
||||
args={{
|
||||
data: pressFreedom,
|
||||
paginated: true,
|
||||
filterField: 'Region',
|
||||
filterLabel: 'regions',
|
||||
title: 'Press Freedom Index',
|
||||
notes: 'Source: Reporters Without Borders',
|
||||
}}
|
||||
|
|
@ -121,12 +89,13 @@
|
|||
|
||||
<Story
|
||||
name="Search and filter"
|
||||
{...withStoryDocs(bothDocs)}
|
||||
exportName="SearchAndFilter"
|
||||
args={{
|
||||
data: pressFreedom,
|
||||
searchable: true,
|
||||
filterField: 'Region',
|
||||
paginated: true,
|
||||
filterField: 'Region',
|
||||
filterLabel: 'regions',
|
||||
title: 'Press Freedom Index',
|
||||
dek: 'Reporters Without Borders ranks countries based on their level of press freedom using criteria such as the degree of media pluralism and violence against journalists.',
|
||||
source: 'Source: Reporters Without Borders',
|
||||
|
|
@ -135,13 +104,12 @@
|
|||
|
||||
<Story
|
||||
name="Sort"
|
||||
{...withStoryDocs(sortDocs)}
|
||||
args={{
|
||||
data: pressFreedom,
|
||||
sortable: true,
|
||||
paginated: true,
|
||||
sortField: 'Score',
|
||||
sortDirection: 'descending',
|
||||
paginated: true,
|
||||
title: 'Press Freedom Index',
|
||||
notes: 'Note: data as of 2018',
|
||||
source: 'Source: Reporters Without Borders',
|
||||
|
|
@ -150,24 +118,14 @@
|
|||
|
||||
<Story
|
||||
name="Format"
|
||||
{...withStoryDocs(formatDocs)}
|
||||
args={{
|
||||
data: richestWomen,
|
||||
title: 'The Richest Women in the World',
|
||||
source: 'Source: Forbes',
|
||||
fieldFormatters: {
|
||||
'Net worth (in billions)': currencyFormat as Formatter<unknown>,
|
||||
},
|
||||
sortable: true,
|
||||
sortField: 'Net worth (in billions)',
|
||||
sortDirection: 'descending',
|
||||
fieldFormatters: { 'Net worth (in billions)': currencyFormat },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Style"
|
||||
{...withStoryDocs(styleDocs)}
|
||||
args={{
|
||||
id: 'custom-table',
|
||||
data: richestWomen,
|
||||
title: 'The Richest Women in the World',
|
||||
source: 'Source: Forbes',
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,224 +1,164 @@
|
|||
<!-- @migration-task Error while migrating Svelte code: Cannot set properties of undefined (setting 'next') -->
|
||||
<!-- @component `Table` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-text-elements-table--docs) -->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
/**
|
||||
* A source for the data.
|
||||
* @type []
|
||||
* @required
|
||||
*/
|
||||
export let data: [];
|
||||
|
||||
/**
|
||||
* A title that runs above the table.
|
||||
* @type {string}
|
||||
*/
|
||||
export let title: string | null = null;
|
||||
|
||||
/**
|
||||
* A block of text that runs above the table.
|
||||
* @type {string}
|
||||
*/
|
||||
export let dek: string | null = null;
|
||||
|
||||
/**
|
||||
* A footnote that runs below the table.
|
||||
* @type {string}
|
||||
*/
|
||||
export let notes: string | null = null;
|
||||
|
||||
/**
|
||||
* A source line that runs below the table.
|
||||
* @type {string}
|
||||
*/
|
||||
export let source: string | null = null;
|
||||
|
||||
/**
|
||||
* list of the fields to include in the table. By default everything goes.
|
||||
* @type []
|
||||
*/
|
||||
export let includedFields: string[] = Object.keys(data[0]).filter(
|
||||
(f) => f !== 'searchStr'
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether or not the table is cutoff after a set number of rows.
|
||||
* @type {boolean}
|
||||
*/
|
||||
export let truncated: boolean = false;
|
||||
|
||||
/**
|
||||
* If the table is truncated, how many rows to allow before the cutoff.
|
||||
* @type {number}
|
||||
*/
|
||||
export let truncateLength: number = 5;
|
||||
|
||||
/**
|
||||
* Whether or not the table is paginated after a set number of rows.
|
||||
* @type {boolean}
|
||||
*/
|
||||
export let paginated: boolean = false;
|
||||
|
||||
/**
|
||||
* The default page size.
|
||||
* @type {number}
|
||||
*/
|
||||
export let pageSize: number = 25;
|
||||
|
||||
/**
|
||||
* Whether or not searches are allowed.
|
||||
* @type {boolean}
|
||||
*/
|
||||
export let searchable: boolean = false;
|
||||
|
||||
/**
|
||||
* The placeholder text that appears in the search box.
|
||||
* @type {string}
|
||||
*/
|
||||
export let searchPlaceholder: string = 'Search in table';
|
||||
|
||||
/**
|
||||
* A field to offer uses as an interactive filter.
|
||||
* @type {string}
|
||||
*/
|
||||
export let filterField: string;
|
||||
|
||||
/**
|
||||
* The label to place above the filter box
|
||||
* @type {string}
|
||||
*/
|
||||
export let filterLabel: string;
|
||||
|
||||
/**
|
||||
* Whether or not sorts are allowed.
|
||||
* @type {boolean}
|
||||
*/
|
||||
export let sortable: boolean = false;
|
||||
|
||||
/**
|
||||
* The column to sort by. By default it's the first header.
|
||||
* @type {string}
|
||||
*/
|
||||
export let sortField: string = Object.keys(data[0])[0];
|
||||
|
||||
/**
|
||||
* The columns that are allowed to sort. It's all of them by default.
|
||||
* @type {string}
|
||||
*/
|
||||
export let sortableFields: string[] = Object.keys(data[0]).filter(
|
||||
(f) => f !== 'searchStr'
|
||||
);
|
||||
|
||||
/**
|
||||
* The direction of the sort. By default it's ascending.
|
||||
* @type {SortDirection}
|
||||
*/
|
||||
type SortDirection = 'ascending' | 'descending';
|
||||
export let sortDirection: SortDirection = 'ascending';
|
||||
|
||||
/**
|
||||
* Custom field formatting functions. Should be keyed to the name of the field.
|
||||
* @type {object}
|
||||
*/
|
||||
export let fieldFormatters: object = {};
|
||||
|
||||
/** Width of the component within the text well. */
|
||||
type ContainerWidth = 'normal' | 'wide' | 'wider' | 'widest' | 'fluid';
|
||||
export let width: ContainerWidth = 'normal';
|
||||
|
||||
/** Add an ID to target with SCSS. */
|
||||
export let id: string = '';
|
||||
|
||||
/** Add a class to target with SCSS. */
|
||||
let cls: string = '';
|
||||
export { cls as class };
|
||||
|
||||
/** Import local helpers */
|
||||
import Block from '../Block/Block.svelte';
|
||||
import Pagination from './Pagination.svelte';
|
||||
import Pagination from './components/Pagination.svelte';
|
||||
import Select from './components/Select.svelte';
|
||||
import SortArrow from './components/SortArrow.svelte';
|
||||
import SearchInput from '../SearchInput/SearchInput.svelte';
|
||||
import Select from './Select.svelte';
|
||||
import SortArrow from './SortArrow.svelte';
|
||||
import { filterArray, paginateArray, getOptions } from './utils.js';
|
||||
import {
|
||||
filterArray,
|
||||
paginateArray,
|
||||
getOptions,
|
||||
sortArray,
|
||||
formatValue,
|
||||
type FieldFormatters,
|
||||
} from './utils';
|
||||
|
||||
// Types
|
||||
import type { Option } from '$lib/components/@types/global';
|
||||
|
||||
interface Props<T extends Record<string, unknown>> {
|
||||
/** Data for the table as an array. */
|
||||
data: T[];
|
||||
|
||||
/** A title that runs above the table. */
|
||||
title?: string;
|
||||
/** A block of text that runs above the table. */
|
||||
dek?: string;
|
||||
/** A footnote that runs below the table. */
|
||||
notes?: string;
|
||||
/** A source line that runs below the table. */
|
||||
source?: string;
|
||||
/** List of the fields to include in the table. By default everything goes. */
|
||||
includedFields?: string[];
|
||||
/** Whether or not the table is cutoff after a set number of rows. */
|
||||
truncated?: boolean;
|
||||
/** If the table is truncated, how many rows to allow before the cutoff. */
|
||||
truncateLength?: number;
|
||||
/** Whether or not the table is paginated after a set number of rows. */
|
||||
paginated?: boolean;
|
||||
/** The default page size. */
|
||||
pageSize?: number;
|
||||
/** Whether or not searches are allowed. */
|
||||
searchable?: boolean;
|
||||
/** The placeholder text that appears in the search box. */
|
||||
searchPlaceholder?: string;
|
||||
/** A field to offer uses as an interactive filter. */
|
||||
filterField?: string;
|
||||
/** The label to place above the filter box. */
|
||||
filterLabel?: string;
|
||||
/** Whether or not sorts are allowed. */
|
||||
sortable?: boolean;
|
||||
/** The column to sort by. By default it's the first header. */
|
||||
sortField?: string;
|
||||
/** The columns that are allowed to sort. It's all of them by default. */
|
||||
sortableFields?: string[];
|
||||
/** The direction of the sort. By default it's ascending. */
|
||||
sortDirection?: 'ascending' | 'descending';
|
||||
/** Custom field formatting functions. Should be keyed to the name of the field. */
|
||||
fieldFormatters?: FieldFormatters<T>;
|
||||
/** Width of the component within the text well. */
|
||||
width?: 'normal' | 'wide' | 'wider' | 'widest' | 'fluid';
|
||||
/** Add an ID to target with SCSS. */
|
||||
id?: string;
|
||||
/** Add a class to target with SCSS. */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
data,
|
||||
title,
|
||||
dek,
|
||||
notes,
|
||||
source,
|
||||
includedFields = Object.keys(data[0]).filter((f) => f !== 'searchStr'),
|
||||
truncated = false,
|
||||
truncateLength = 5,
|
||||
paginated = false,
|
||||
pageSize = 25,
|
||||
searchable = false,
|
||||
searchPlaceholder = 'Search in table',
|
||||
filterField = '',
|
||||
filterLabel,
|
||||
sortable = false,
|
||||
sortField = Object.keys(data[0])[0],
|
||||
sortableFields = Object.keys(data[0]).filter((f) => f !== 'searchStr'),
|
||||
sortDirection = $bindable('ascending'),
|
||||
fieldFormatters,
|
||||
width = 'normal',
|
||||
id = '',
|
||||
class: cls = '',
|
||||
}: Props<Record<string, unknown>> = $props();
|
||||
|
||||
/** Set truncate, filtering and pagination configuration */
|
||||
let showAll = false;
|
||||
let pageNumber = 1;
|
||||
let searchText = '';
|
||||
const filterList = filterField ? getOptions(data, filterField) : undefined;
|
||||
let filterValue = '';
|
||||
$: filteredData = filterArray(data, searchText, filterField, filterValue);
|
||||
$: sortedData = sortArray(filteredData, sortField, sortDirection);
|
||||
$: currentPageData =
|
||||
truncated ?
|
||||
showAll ? sortedData
|
||||
: sortedData.slice(0, truncateLength + 1)
|
||||
: paginated ? paginateArray(sortedData, pageSize, pageNumber)
|
||||
: sortedData;
|
||||
let showAll = $state(false);
|
||||
let pageNumber = $state(1);
|
||||
let searchText = $state('');
|
||||
let filterList = $derived(
|
||||
filterField ? getOptions(data, filterField) : undefined
|
||||
);
|
||||
let filterValue = $state('');
|
||||
|
||||
/** Helper functions that modify variables within this component */
|
||||
//* * Handle show all, search, filter, sort and pagination events */
|
||||
function toggleTruncate(_event) {
|
||||
function toggleTruncate() {
|
||||
showAll = !showAll;
|
||||
}
|
||||
|
||||
function handleSearchInput(event) {
|
||||
searchText = event.detail.value;
|
||||
/** Filters table data based on the input value in the search bar */
|
||||
function handleSearchInput(newSearchText: string) {
|
||||
searchText = newSearchText;
|
||||
pageNumber = 1;
|
||||
}
|
||||
|
||||
function handleFilterInput(event) {
|
||||
const value = event.detail.value;
|
||||
filterValue = value === 'All' ? '' : value;
|
||||
function handleFilterInput(newSearchText: string) {
|
||||
filterValue = newSearchText === 'All' ? '' : newSearchText;
|
||||
pageNumber = 1;
|
||||
}
|
||||
|
||||
function handleSort(event) {
|
||||
function handleSort(event: MouseEvent) {
|
||||
if (!sortable) return;
|
||||
sortField = event.target.getAttribute('data-field');
|
||||
sortField = (event.target as HTMLElement).getAttribute('data-field') || '';
|
||||
sortDirection = sortDirection === 'ascending' ? 'descending' : 'ascending';
|
||||
}
|
||||
|
||||
function sortArray(array, column, direction) {
|
||||
if (!sortable) return array;
|
||||
return array.sort((a, b) => {
|
||||
if (a[column] < b[column]) {
|
||||
return direction === 'ascending' ? -1 : 1;
|
||||
} else if (a[column] > b[column]) {
|
||||
return direction === 'ascending' ? 1 : -1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
/** Add the `searchStr` field to data */
|
||||
let searchableData = $derived.by(() => {
|
||||
return data.map((d) => {
|
||||
return {
|
||||
...d,
|
||||
searchStr: includedFields
|
||||
.map((field) => d[field])
|
||||
.join(' ')
|
||||
.toLowerCase(),
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
/** Set up the data pipeline */
|
||||
let filteredData = $derived.by(() =>
|
||||
filterArray(searchableData, searchText, filterField, filterValue)
|
||||
);
|
||||
|
||||
function formatValue(item, field) {
|
||||
const value = item[field];
|
||||
if (field in fieldFormatters) {
|
||||
const func = fieldFormatters[field];
|
||||
return func(value);
|
||||
} else {
|
||||
return value;
|
||||
let sortedData = $derived.by(() =>
|
||||
sortArray(filteredData, sortField, sortDirection, sortable)
|
||||
);
|
||||
|
||||
let currentPageData = $derived.by(() => {
|
||||
if (truncated) {
|
||||
return showAll ? sortedData : sortedData.slice(0, truncateLength + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Boot it up. */
|
||||
onMount(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data.forEach((d: any) => {
|
||||
// Compose the string we will allow users to search
|
||||
d.searchStr = includedFields
|
||||
.map((field) => d[field])
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
});
|
||||
if (paginated) {
|
||||
return paginateArray(sortedData, pageSize, pageNumber);
|
||||
}
|
||||
return sortedData;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Block {width} {id} class="fmy-6 {cls}">
|
||||
<article class="table-wrapper">
|
||||
<div class="table-wrapper">
|
||||
{#if title || dek || searchable || filterList}
|
||||
<header class="table--header w-full">
|
||||
<div class="table--header w-full">
|
||||
{#if title}
|
||||
<h3 class="table--header--title">{@html title}</h3>
|
||||
{/if}
|
||||
|
|
@ -231,28 +171,27 @@
|
|||
<div class="table--header--filter">
|
||||
<Select
|
||||
label={filterLabel || filterField}
|
||||
options={filterList}
|
||||
on:select={handleFilterInput}
|
||||
options={filterList as Option[]}
|
||||
onselect={handleFilterInput}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if searchable}
|
||||
<div class="table--header--search">
|
||||
<SearchInput
|
||||
bind:searchPlaceholder
|
||||
on:search={handleSearchInput}
|
||||
/>
|
||||
<SearchInput {searchPlaceholder} onsearch={handleSearchInput} />
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
{/if}
|
||||
</header>
|
||||
</div>
|
||||
{/if}
|
||||
<section class="table w-full">
|
||||
<div class="table w-full">
|
||||
<table
|
||||
class="w-full"
|
||||
class:paginated
|
||||
class:truncated={truncated && !showAll && data.length > truncateLength}
|
||||
class:truncated={truncated &&
|
||||
!showAll &&
|
||||
searchableData.length > truncateLength}
|
||||
>
|
||||
<thead class="table--thead">
|
||||
<tr>
|
||||
|
|
@ -268,15 +207,12 @@
|
|||
sortField === field &&
|
||||
sortDirection === 'descending'}
|
||||
data-field={field}
|
||||
on:click={handleSort}
|
||||
onclick={handleSort}
|
||||
>
|
||||
{field}
|
||||
{#if sortable && sortableFields.includes(field)}
|
||||
<div class="table--thead--sortarrow fml-1 avoid-clicks">
|
||||
<SortArrow
|
||||
bind:sortDirection
|
||||
active={sortField === field}
|
||||
/>
|
||||
<SortArrow {sortDirection} active={sortField === field} />
|
||||
</div>
|
||||
{/if}
|
||||
</th>
|
||||
|
|
@ -293,7 +229,7 @@
|
|||
data-field={field}
|
||||
data-value={item[field]}
|
||||
>
|
||||
{@html formatValue(item, field)}
|
||||
{@html formatValue(item, field, fieldFormatters)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
|
|
@ -329,16 +265,20 @@
|
|||
</tfoot>
|
||||
{/if}
|
||||
</table>
|
||||
</section>
|
||||
{#if truncated && data.length > truncateLength}
|
||||
</div>
|
||||
{#if truncated && searchableData.length > truncateLength}
|
||||
<nav
|
||||
aria-label="Show all button"
|
||||
class="show-all flex items-center justify-center fmt-2"
|
||||
>
|
||||
<button class="body-caption" on:click={toggleTruncate}
|
||||
>{#if showAll}Show fewer rows{:else}Show {data.length -
|
||||
truncateLength} more rows{/if}</button
|
||||
>
|
||||
<button class="body-caption" onclick={toggleTruncate}>
|
||||
{#if showAll}
|
||||
Show fewer rows
|
||||
{:else}
|
||||
Show {searchableData.length - truncateLength}
|
||||
more rows
|
||||
{/if}
|
||||
</button>
|
||||
</nav>
|
||||
{/if}
|
||||
{#if paginated}
|
||||
|
|
@ -348,16 +288,16 @@
|
|||
bind:pageLength={currentPageData.length}
|
||||
bind:n={sortedData.length}
|
||||
/>{/if}
|
||||
</article>
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../../scss/mixins' as mixins;
|
||||
|
||||
section.table {
|
||||
.table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
section.table table {
|
||||
.table table {
|
||||
background-color: transparent;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 427 B After Width: | Height: | Size: 427 B |
|
|
@ -6,31 +6,27 @@
|
|||
interface Props {
|
||||
/**
|
||||
* The current page number.
|
||||
* @type {number}
|
||||
*/
|
||||
pageNumber?: number;
|
||||
/**
|
||||
* The default page size.
|
||||
* @type {number}
|
||||
*/
|
||||
pageSize?: number;
|
||||
/**
|
||||
* The number of records in the current page.
|
||||
* @type {number}
|
||||
*/
|
||||
pageLength?: number;
|
||||
/**
|
||||
* The total number of records in the data set.
|
||||
* @type {number}
|
||||
*/
|
||||
n?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
pageNumber = $bindable(1),
|
||||
pageSize = 25,
|
||||
pageLength = null,
|
||||
n = null,
|
||||
pageSize = $bindable(25),
|
||||
pageLength = $bindable(1),
|
||||
n = $bindable(1),
|
||||
}: Props = $props();
|
||||
|
||||
let minRow = $derived(pageNumber * pageSize - pageSize + 1);
|
||||
|
|
@ -73,7 +69,7 @@
|
|||
</nav>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../scss/mixins';
|
||||
@use '../../../scss/mixins' as mixins;
|
||||
|
||||
nav.pagination {
|
||||
display: flex;
|
||||
|
|
@ -83,8 +79,8 @@
|
|||
button {
|
||||
border: 1px solid var(--theme-colour-text-secondary, var(--tr-light-grey));
|
||||
border-radius: 50%;
|
||||
@include bg;
|
||||
@include text-secondary;
|
||||
@include mixins.bg;
|
||||
@include mixins.text-secondary;
|
||||
cursor: pointer;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
|
|
@ -103,7 +99,7 @@
|
|||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
&:hover {
|
||||
@include text-primary;
|
||||
@include mixins.text-primary;
|
||||
border-color: var(--theme-colour-text-primary, var(--tr-medium-grey));
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 427 B After Width: | Height: | Size: 427 B |
|
|
@ -1,34 +1,29 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { Option } from '../@types/global';
|
||||
import type { Option } from '$lib/components/@types/global';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* The label that appears above the select input.
|
||||
* @type {string}
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* The label that appears above the select input.
|
||||
* @type {Array}
|
||||
*/
|
||||
options?: Option[];
|
||||
/** Optional function that runs when the selected value changes. */
|
||||
onselect?: (newValue: string) => void;
|
||||
}
|
||||
|
||||
let { label = '', options = [] }: Props = $props();
|
||||
let { label = '', options = [], onselect }: Props = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
function input(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
|
||||
function input(event) {
|
||||
const value = event.target.value;
|
||||
dispatch('select', { value });
|
||||
if (onselect) onselect(value); // Call the prop to update the parent when selected
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="select">
|
||||
{#if label}
|
||||
<label class="body-caption block" for="select--input">{label}</label>
|
||||
{/if}
|
||||
<select
|
||||
class="select--input body-caption fpx-2"
|
||||
name="select--input"
|
||||
|
|
@ -36,7 +31,7 @@
|
|||
oninput={input}
|
||||
>
|
||||
{#each options as obj}
|
||||
<option value={obj.value}>{obj.text}</option>
|
||||
<option value={obj.value}>{obj.text} {label.toLowerCase()}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
Feel free to both search and filter.
|
||||
|
||||
```svelte
|
||||
<table
|
||||
data="{yourData}"
|
||||
searchable="{true}"
|
||||
filterField="{'Region'}"
|
||||
paginated="{true}"
|
||||
title="{'Press Freedom Index'}"
|
||||
dek="{'Reporters Without Borders ranks countries based on their level of press freedom using criteria such as the degree of media pluralism and violence against journalists.'}"
|
||||
source="{'Source: Reporters Without Borders'}"
|
||||
></table>
|
||||
```
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
Present structured data in a table. Consider making it interactive.
|
||||
|
||||
---
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { Table } from '@reuters-graphics/graphics-components';
|
||||
|
||||
// Import your data as JSON, or otherwise structure it
|
||||
import yourData from './stories/homeRuns.json';
|
||||
</script>
|
||||
|
||||
<table data="{yourData}"></table>
|
||||
```
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
Allow users to filter the table by providing one of the attributes as the `filterField`. This works best with categorical columns.
|
||||
|
||||
```svelte
|
||||
<table
|
||||
data="{yourData}"
|
||||
filterField="{'Region'}"
|
||||
paginated="{true}"
|
||||
title="{'Press Freedom Index'}"
|
||||
notes="{'Source: Reporters Without Borders'}"
|
||||
></table>
|
||||
```
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
Format column values by supplying functions keyed to field names with the `fieldFormatters` option. Columns are still sorted using the raw, underlying values.
|
||||
|
||||
Among other things, this feature can be used to provide a unit of measurement with numeric fields.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
const fieldFormatters = {
|
||||
'Net worth (in billions)': (v) => '$' + v.toFixed(1),
|
||||
};
|
||||
</script>
|
||||
|
||||
<table
|
||||
data="{yourData}"
|
||||
fieldFormatters="{fieldFormatters}"
|
||||
sortable="{true}"
|
||||
sortField="{'Score'}"
|
||||
sortDirection="{'descending'}"
|
||||
title="{'The Richest Women in the World'}"
|
||||
source="{'Source: Forbes'}"
|
||||
></table>
|
||||
```
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
Set the `title`, `dek`, `notes` and `source` options to add supporting metadata above and below the table.
|
||||
|
||||
```svelte
|
||||
<Table
|
||||
data="{yourData}"
|
||||
title="{'Career home run leaders'}"
|
||||
dek="{`In baseball,
|
||||
a home run (also known as a "dinger" or "tater") occurs when a batter hits the
|
||||
ball over the outfield fence. When a home run is hit, the batter and any runners
|
||||
on base are able to score.`}"
|
||||
notes="{'Note: As of Opening Day 2023'}"
|
||||
source="{'Source: Baseball Reference'}"
|
||||
/>
|
||||
```
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
When your table has lots of rows you should consider breaking it up into pages. This can be done by setting the `paginated` option.
|
||||
|
||||
When it is enabled, readers can leaf through the data using a set of buttons below the table. By default there are 25 records per page. You can change the number by adjusting the `pageSize` option.
|
||||
|
||||
This is a good option when publishing large tables for readers to explore. It works well with interactive features like searching and filters.
|
||||
|
||||
```svelte
|
||||
<table
|
||||
data="{yourData}"
|
||||
paginated="{true}"
|
||||
title="{'Press Freedom Index'}"
|
||||
source="{'Reporters Without Borders'}"
|
||||
></table>
|
||||
```
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
Allow users to search the table by setting the optional `searchable` variable. Modify the default text that appears in the box by supplying the `searchPlaceholder` option.
|
||||
|
||||
```svelte
|
||||
<table
|
||||
data="{yourData}"
|
||||
searchable="{true}"
|
||||
paginated="{true}"
|
||||
title="{'Press Freedom Index'}"
|
||||
notes="{'Source: Reporters Without Borders'}"
|
||||
></table>
|
||||
```
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
Allow users to sort the table by setting the `sortable` input. Specify the starting order by setting `sortField` to a column name and `sortDirection` to `ascending` or `descending`.
|
||||
|
||||
By default, all fields are sortable. If you'd like to limit the columns where sorting is allowed, provide a list to the `sortableFields` option.
|
||||
|
||||
```svelte
|
||||
<table
|
||||
data="{yourData}"
|
||||
sortable="{true}"
|
||||
sortField="{'Score'}"
|
||||
sortDirection="{'descending'}"
|
||||
title="{'Press Freedom Index'}"
|
||||
source="{'Source: Reporters Without Borders'}"
|
||||
></table>
|
||||
```
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
You can tailor the table's appearance by crafting CSS that targets specific elements.
|
||||
|
||||
Like other components, you can apply labels by providing the `id` of `cls` options, which allow you to make broad changes that remain limited to your element.
|
||||
|
||||
Each column has a `data-field` attribute that contains the field's name. Use it to apply different styles to different fields. One common use is setting different text alignments on different columns.
|
||||
|
||||
```svelte
|
||||
<table
|
||||
id="{'custom-table'}"
|
||||
data="{yourData}"
|
||||
title="{'The Richest Women in the World'}"
|
||||
source="{'Source: Forbes'}"
|
||||
></table>
|
||||
|
||||
<style lang="scss">
|
||||
/* Here we right align the table's numeric column. */
|
||||
#custom-table {
|
||||
[data-field='Net worth (in billions)'] {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
When your table has 10 or more rows, consider clipping it by setting the `truncated` option. When it is enabled, the table is clipped and readers must request to see all rows by clicking a button below the table. By default this configuration will limit the table to five records. You can change the cutoff point by adjusting the `truncateLength` option.
|
||||
|
||||
This is a good option for simple tables with row counts between 10 and 30. It works best when the table doesn't require interactivity.
|
||||
|
||||
```svelte
|
||||
<table
|
||||
data="{yourData}"
|
||||
truncated="{true}"
|
||||
source="{'Source: Baseball Reference'}"
|
||||
></table>
|
||||
```
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
export function filterArray(data, searchText, filterField, filterValue) {
|
||||
if (searchText) {
|
||||
data = data.filter((item) => {
|
||||
return item.searchStr.includes(searchText.toLowerCase());
|
||||
});
|
||||
}
|
||||
if (filterValue && filterValue) {
|
||||
data = data.filter((item) => {
|
||||
return item[filterField] === filterValue;
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function paginateArray(array, pageSize, pageNumber) {
|
||||
return array.slice((pageNumber - 1) * pageSize, pageNumber * pageSize);
|
||||
}
|
||||
|
||||
function uniqueAttr(array, attr) {
|
||||
return array.map((e) => e[attr]).filter(unique);
|
||||
}
|
||||
|
||||
function unique(value, index, array) {
|
||||
return array.indexOf(value) === index;
|
||||
}
|
||||
|
||||
export function getOptions(data, attr) {
|
||||
// Get all the unique values in the provided field. Sort it.
|
||||
const attrList = uniqueAttr(data, attr).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
// Tack 'All' as the front as the first option.
|
||||
attrList.unshift('All');
|
||||
|
||||
// Convert the list into Option typed objects ready for our Select component
|
||||
return attrList.map((a) => ({ text: a, value: a }));
|
||||
}
|
||||
122
src/components/Table/utils.ts
Normal file
122
src/components/Table/utils.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
type FilterableDatum<T extends Record<string, unknown>> = T & {
|
||||
searchStr: string;
|
||||
};
|
||||
|
||||
export function filterArray<T extends Record<string, unknown>>(
|
||||
data: FilterableDatum<T>[],
|
||||
searchText: string,
|
||||
filterField: keyof FilterableDatum<T>,
|
||||
filterValue: FilterableDatum<T>[keyof FilterableDatum<T>]
|
||||
) {
|
||||
if (searchText) {
|
||||
data = data.filter((item) => {
|
||||
return item.searchStr.includes(searchText.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
if (filterValue) {
|
||||
data = data.filter((item) => {
|
||||
if (!filterField) return true; // or handle the undefined case as appropriate
|
||||
|
||||
return item[filterField] === filterValue;
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
export function paginateArray<T>(
|
||||
array: T[],
|
||||
pageSize: number,
|
||||
pageNumber: number
|
||||
) {
|
||||
return array.slice((pageNumber - 1) * pageSize, pageNumber * pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* We specify the output type here by adding `string` to the union because we want to explicitly define the output array as accepting strings.
|
||||
*
|
||||
* This is to get rid of the type error from `attrList.unshift('All')`
|
||||
*/
|
||||
function uniqueAttr<T>(array: T[], attr: keyof T): (T[keyof T] | string)[] {
|
||||
return array.map((e) => e[attr]).filter(unique);
|
||||
}
|
||||
|
||||
function unique<T>(value: T, index: number, array: T[]) {
|
||||
return array.indexOf(value) === index;
|
||||
}
|
||||
|
||||
export function getOptions<T>(data: T[], attr: keyof T) {
|
||||
// Get all the unique values in the provided field. Sort it.
|
||||
const attrList = uniqueAttr(data, attr).sort((a, b) => {
|
||||
// Throw errors if a and b are not strings.
|
||||
// a and b should be strings since they are keys of T.
|
||||
if (typeof a !== 'string' || typeof b !== 'string') {
|
||||
throw new Error(`Expected string, got ${typeof a} and ${typeof b}`);
|
||||
}
|
||||
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// Tack 'All' at the start of `attrList`, making it the first option.
|
||||
attrList.unshift('All');
|
||||
|
||||
// Convert the list into Option typed objects ready for our Select component
|
||||
return attrList.map((a) => ({ text: a, value: a }));
|
||||
}
|
||||
interface SortableItem {
|
||||
[key: string]: unknown; // Or more specific types if known
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts an array of objects based on a specified column and direction.
|
||||
*/
|
||||
export function sortArray<T extends SortableItem>(
|
||||
/** The array to sort. */
|
||||
array: T[],
|
||||
/** The column to sort by. */
|
||||
column: keyof T,
|
||||
/** The sorting direction ('ascending' or 'descending'). */
|
||||
direction: 'ascending' | 'descending',
|
||||
/** Whether or not sorting is turned on */
|
||||
sortable: boolean
|
||||
) {
|
||||
if (!sortable) return array;
|
||||
|
||||
const sorted = [...array].sort((a, b) => {
|
||||
if (a[column] < b[column]) {
|
||||
return direction === 'ascending' ? -1 : 1;
|
||||
} else if (a[column] > b[column]) {
|
||||
return direction === 'ascending' ? 1 : -1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export type Formatter<T> = (value: T) => string;
|
||||
|
||||
export type FieldFormatters<T> = {
|
||||
[K in keyof T]?: Formatter<T[K]>;
|
||||
};
|
||||
/**
|
||||
* Formats a value based on a field and a dictionary of formatters.
|
||||
*/
|
||||
export function formatValue<T extends Record<string, unknown>>(
|
||||
/** The object containing the field. */
|
||||
item: FilterableDatum<T>,
|
||||
/** The field to format. */
|
||||
field: keyof T,
|
||||
/** An optional dictionary of formatters. */
|
||||
fieldFormatters?: FieldFormatters<T>
|
||||
) {
|
||||
const value = item[field];
|
||||
|
||||
if (fieldFormatters && field in fieldFormatters && fieldFormatters[field]) {
|
||||
const formatter = fieldFormatters[field];
|
||||
return formatter(value);
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue