Merge pull request #259 from reuters-graphics/mf-table

Updates Table
This commit is contained in:
MinamiFunakoshiTR 2025-04-15 12:21:08 -05:00 committed by GitHub
commit 58c0036b52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 495 additions and 489 deletions

View 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} />

View file

@ -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',
}}

View file

@ -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;

View file

Before

Width:  |  Height:  |  Size: 427 B

After

Width:  |  Height:  |  Size: 427 B

View file

@ -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));
}
}

View file

Before

Width:  |  Height:  |  Size: 427 B

After

Width:  |  Height:  |  Size: 427 B

View file

@ -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>

View file

@ -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>
```

View file

@ -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>
```

View file

@ -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>
```

View file

@ -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>
```

View file

@ -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'}"
/>
```

View file

@ -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>
```

View file

@ -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>
```

View file

@ -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>
```

View file

@ -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>
```

View file

@ -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>
```

View file

@ -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 }));
}

View 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;
}
}