diff --git a/src/components/Table/Table.mdx b/src/components/Table/Table.mdx new file mode 100644 index 00000000..1af6c21b --- /dev/null +++ b/src/components/Table/Table.mdx @@ -0,0 +1,177 @@ +import { Meta, Canvas } from '@storybook/blocks'; + +import * as TableStories from './Table.stories.svelte'; + + + +# Table + +The `Table` component presents data as a table that you can make searchable, filtereable, sortable, or paginated. + +```svelte + + + +``` + + + +## Text elements + +Set the `title`, `dek`, `notes` and `source` options to add supporting metadata above and below the table. + +```svelte +
+``` + + + +## 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 +
+``` + + + +## 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 +
+``` + + + +## 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 +
+``` + + + +## 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 +
+``` + + + +## Search and filter + +Feel free to both search and filter. + +```svelte +
+``` + + +``` + +## 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 +
+``` + + + +## 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 + + +
+``` + + diff --git a/src/components/Table/Table.stories.svelte b/src/components/Table/Table.stories.svelte index d27f6859..7b230b9a 100644 --- a/src/components/Table/Table.stories.svelte +++ b/src/components/Table/Table.stories.svelte @@ -1,60 +1,30 @@ - - , + }, sortable: true, sortField: 'Net worth (in billions)', sortDirection: 'descending', - fieldFormatters: { 'Net worth (in billions)': currencyFormat }, - }} -/> - - -
+
{#if title || dek || searchable || filterList} -
+
{#if title}

{@html title}

{/if} @@ -231,28 +171,27 @@
truncateLength} + class:truncated={truncated && + !showAll && + searchableData.length > truncateLength} > @@ -268,15 +207,12 @@ sortField === field && sortDirection === 'descending'} data-field={field} - on:click={handleSort} + onclick={handleSort} > {field} {#if sortable && sortableFields.includes(field)}
- +
{/if} @@ -293,7 +229,7 @@ data-field={field} data-value={item[field]} > - {@html formatValue(item, field)} + {@html formatValue(item, field, fieldFormatters)} {/each} @@ -329,16 +265,20 @@ {/if}
- - {#if truncated && data.length > truncateLength} + + {#if truncated && searchableData.length > truncateLength} {/if} {#if paginated} @@ -348,16 +288,16 @@ bind:pageLength={currentPageData.length} bind:n={sortedData.length} />{/if} - + -``` diff --git a/src/components/Table/stories/docs/truncate.md b/src/components/Table/stories/docs/truncate.md deleted file mode 100644 index d36a230f..00000000 --- a/src/components/Table/stories/docs/truncate.md +++ /dev/null @@ -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 -
-``` diff --git a/src/components/Table/utils.js b/src/components/Table/utils.js deleted file mode 100644 index 0eb2b6b8..00000000 --- a/src/components/Table/utils.js +++ /dev/null @@ -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 })); -} diff --git a/src/components/Table/utils.ts b/src/components/Table/utils.ts new file mode 100644 index 00000000..51b36a7a --- /dev/null +++ b/src/components/Table/utils.ts @@ -0,0 +1,122 @@ +type FilterableDatum> = T & { + searchStr: string; +}; + +export function filterArray>( + data: FilterableDatum[], + searchText: string, + filterField: keyof FilterableDatum, + filterValue: FilterableDatum[keyof FilterableDatum] +) { + 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( + 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(array: T[], attr: keyof T): (T[keyof T] | string)[] { + return array.map((e) => e[attr]).filter(unique); +} + +function unique(value: T, index: number, array: T[]) { + return array.indexOf(value) === index; +} + +export function getOptions(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( + /** 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 = (value: T) => string; + +export type FieldFormatters = { + [K in keyof T]?: Formatter; +}; +/** + * Formats a value based on a field and a dictionary of formatters. + */ +export function formatValue>( + /** The object containing the field. */ + item: FilterableDatum, + /** The field to format. */ + field: keyof T, + /** An optional dictionary of formatters. */ + fieldFormatters?: FieldFormatters +) { + const value = item[field]; + + if (fieldFormatters && field in fieldFormatters && fieldFormatters[field]) { + const formatter = fieldFormatters[field]; + return formatter(value); + } else { + return value; + } +}