finished table

This commit is contained in:
MinamiFunakoshiTR 2025-03-31 16:32:33 -07:00
commit 46a84609ed
Failed to extract signature
10 changed files with 297 additions and 119 deletions

View file

@ -12,16 +12,18 @@ The `SearchInput` component creates a search bar.
<script>
import { SearchInput } from '@reuters-graphics/graphics-components';
function handleSearchInput(event) {
const searchText = event.detail.value;
// Here's where you might update a variable,
// filter a dataset or make an API call
// based on the user's input.
console.log(`Search for ${searchText}`);
function filterData(newSearchText: string) {
/** This function would typically filter a dataset based on the search input.*/
console.log('Filtering data with:', newSearchText);
}
function handleSearchInput(newSearchText: string) {
/** Here's where you might update a variable,
filter a dataset or make an API call based on the user's input.*/
filterData(newSearchText);
}
</script>
<SearchInput on:search={handleSearchInput} />
<SearchInput onsearch={handleSearchInput} />
```
<Canvas of={SearchInputStories.Demo} />
<Canvas of={SearchInputStories.Demo} />

View file

@ -8,4 +8,17 @@
});
</script>
<Story name="Demo" />
<script lang="ts">
function filterData(newSearchText: string) {
/** This function would typically filter a dataset based on the search input.*/
console.log('Filtering data with:', newSearchText);
}
function handleSearchInput(newSearchText: string) {
/** Here's where you might update a variable, filter a dataset or make an API call based on the user's input.*/
filterData(newSearchText);
}
</script>
<Story name="Demo">
<SearchInput onsearch={handleSearchInput} />
</Story>

View file

@ -6,7 +6,7 @@
interface Props {
/** The placeholder text that appears in the search box.*/
searchPlaceholder?: string;
/** Optional function that runs when the input value changes. */
onsearch?: (newValue: string) => void;
}

View file

@ -6,14 +6,172 @@ import * as TableStories from './Table.stories.svelte';
# Table
The `Table` component TKTK
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 />
<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

@ -18,8 +18,9 @@
import pressFreedom from './demo/pressFreedom.json';
import homeRuns from './demo/homeRuns.json';
import richestWomen from './demo/richestWomen.json';
import type { Formatter } from './utils';
const currencyFormat = (v: number) => '$' + v.toFixed(1);
const currencyFormat: Formatter<number> = (v: number) => '$' + v.toFixed(1);
</script>
<Story
@ -31,9 +32,9 @@
/>
<Story
name="Metadata"
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.',
@ -43,7 +44,7 @@
/>
<Story
name="Truncate"
name="Truncated"
args={{
data: homeRuns,
truncated: true,
@ -52,21 +53,23 @@
/>
<Story
name="Paginate"
name="Paginated"
args={{
data: pressFreedom,
title: 'Press Freedom Index',
paginated: true,
title: 'Press Freedom Index',
source: 'Source: Reporters Without Borders',
}}
/>
<Story
name="Search"
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',
}}
@ -78,6 +81,7 @@
data: pressFreedom,
paginated: true,
filterField: 'Region',
filterLabel: 'regions',
title: 'Press Freedom Index',
notes: 'Source: Reporters Without Borders',
}}
@ -85,11 +89,13 @@
<Story
name="Search and filter"
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',
@ -101,9 +107,9 @@
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',
@ -114,20 +120,12 @@
name="Format"
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"
args={{
id: 'custom-table',
data: richestWomen,
title: 'The Richest Women in the World',
source: 'Source: Forbes',
}}

View file

@ -6,7 +6,17 @@
import Select from './components/Select.svelte';
import SortArrow from './components/SortArrow.svelte';
import SearchInput from '../SearchInput/SearchInput.svelte';
import { filterArray, paginateArray, getOptions } from './utils';
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. */
@ -47,7 +57,7 @@
/** 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?: object;
fieldFormatters?: FieldFormatters<T>;
/** Width of the component within the text well. */
width?: 'normal' | 'wide' | 'wider' | 'widest' | 'fluid';
/** Add an ID to target with SCSS. */
@ -75,33 +85,12 @@
sortField = Object.keys(data[0])[0],
sortableFields = Object.keys(data[0]).filter((f) => f !== 'searchStr'),
sortDirection = $bindable('ascending'),
fieldFormatters = {},
fieldFormatters,
width = 'normal',
id = '',
class: cls = '',
}: Props<Record<string, unknown>> = $props();
/** Derived variables */
// let includedFieldsDerived = $derived.by(() => {
// if (includedFields) return includedFields;
// if (data.length > 0)
// return Object.keys(data[0]).filter((f) => f !== 'searchStr');
// return [];
// });
// let sortableFieldsDerived = $derived.by(() => {
// if (sortableFields) return sortableFields;
// if (data.length > 0)
// return Object.keys(data[0]).filter((f) => f !== 'searchStr');
// return [];
// });
// let sortFieldDerived = $derived.by(() => {
// if (sortField) return sortField;
// if (data.length > 0) return Object.keys(data[0])[0];
// return '';
// });
/** Set truncate, filtering and pagination configuration */
let showAll = $state(false);
let pageNumber = $state(1);
@ -109,61 +98,31 @@
let filterList = $derived(
filterField ? getOptions(data, filterField) : undefined
);
let filterValue = '';
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: CustomEvent<string>) {
// searchText = event.detail;
// console.log('searchText', searchText);
// pageNumber = 1;
// }
/** 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;
}
});
}
function formatValue(item, field) {
const value = item[field];
if (field in fieldFormatters) {
const func = fieldFormatters[field];
return func(value);
} else {
return value;
}
}
/** Add the `searchStr` field to data */
let searchableData = $derived.by(() => {
return data.map((d) => {
@ -182,7 +141,7 @@
);
let sortedData = $derived.by(() =>
sortArray(filteredData, sortField, sortDirection)
sortArray(filteredData, sortField, sortDirection, sortable)
);
let currentPageData = $derived.by(() => {
@ -212,8 +171,8 @@
<div class="table--header--filter">
<Select
label={filterLabel || filterField}
options={filterList}
on:select={handleFilterInput}
options={filterList as Option[]}
onselect={handleFilterInput}
/>
</div>
{/if}
@ -270,7 +229,7 @@
data-field={field}
data-value={item[field]}
>
{@html formatValue(item, field)}
{@html formatValue(item, field, fieldFormatters)}
</td>
{/each}
</tr>

View file

@ -6,22 +6,18 @@
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;
}
@ -29,8 +25,8 @@
let {
pageNumber = $bindable(1),
pageSize = $bindable(25),
pageLength = $bindable(null),
n = $bindable(null),
pageLength = $bindable(1),
n = $bindable(1),
}: Props = $props();
let minRow = $derived(pageNumber * pageSize - pageSize + 1);

View file

@ -1,42 +1,37 @@
<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"
id="select--input"
oninput="{input}"
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

@ -26,11 +26,11 @@
class="avoid-clicks"
>
<path
class:active="{sortDirection === 'descending' && active}"
class:active={sortDirection === 'descending' && active}
d="M6.76474 20.2244L0.236082 13.4649C-0.0786943 13.139 -0.0786943 12.6104 0.236082 12.2845C0.550521 11.959 1.19794 11.96 1.51305 12.2845L7.33483 12.2845L13 12.2845C13.43 11.8545 14.1195 11.9593 14.4339 12.2849C14.7487 12.6107 14.7487 13.1394 14.4339 13.4653L7.90492 20.2244C7.59015 20.5503 7.07952 20.5503 6.76474 20.2244Z"
></path>
<path
class:active="{sortDirection === 'ascending' && active}"
class:active={sortDirection === 'ascending' && active}
d="M7.90518 0.244414L14.4338 7.00385C14.7486 7.32973 14.7486 7.85838 14.4338 8.18427C14.1194 8.50981 13.472 8.50876 13.1569 8.18427L7.33509 8.18427L1.66992 8.18427C1.23992 8.61427 0.550443 8.50946 0.236003 8.18392C-0.0787725 7.85803 -0.0787725 7.32938 0.236003 7.0035L6.765 0.244414C7.07978 -0.0814713 7.5904 -0.0814713 7.90518 0.244414Z"
></path>
</svg>

View file

@ -63,3 +63,60 @@ export function getOptions<T>(data: T[], attr: keyof T) {
// 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;
}
}