Skip to content

Commit

Permalink
Sortable table (#1358)
Browse files Browse the repository at this point in the history
* initial sort working

* sort working

* make sortable table not stack

* address pr comments

* tweak sort order check

* fix sort bug

* switch span to button

* fix tests

* remove obsolete attr

* add tests for sort utils

---------

Co-authored-by: Kerry Powell <[email protected]>
  • Loading branch information
it-harrison and powellkerry authored Oct 23, 2024
1 parent ad2c161 commit 266e660
Show file tree
Hide file tree
Showing 11 changed files with 841 additions and 43 deletions.
91 changes: 79 additions & 12 deletions packages/storybook/stories/va-table-uswds.stories.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default {
}
}

const columns = ['Document title', 'Description', 'Year'];
const defaultColumns = ['Document title', 'Description', 'Year'];
const data = [
[
'Declaration of Independence',
Expand Down Expand Up @@ -44,10 +44,12 @@ const Template = (args) => {
'table-title': tableTitle,
'table-type': tableType,
rows = data,
sortable,
columns
} = args;

return (
<va-table uswds table-title={tableTitle} stacked={args.stacked} table-type={tableType}>
<va-table table-title={tableTitle} stacked={args.stacked} table-type={tableType} sortable={!!sortable}>
<va-table-row>
{columns.map((col, i) => (
<span key={`header-default-${i}`}>{col}</span>
Expand All @@ -69,9 +71,10 @@ const CustomComponentsTemplate = (args) => {
const {
'table-title': tableTitle,
rows,
columns
} = args;
return (
<va-table uswds table-title={tableTitle}>
<va-table table-title={tableTitle}>
<va-table-row>
{columns.map((col, i) => (
<span key={`header-default-${i}`}>{col}</span>
Expand Down Expand Up @@ -265,10 +268,7 @@ const Pagination = (args) => {

return (
<main>
<va-table
table-title={tableTitle}
uswds
>
<va-table table-title={tableTitle} >
<va-table-row>
{columns.map((col, index) => (
<span key={`table-header-${index}`}>{col}</span>
Expand Down Expand Up @@ -306,7 +306,8 @@ const missingData = [
export const Default = Template.bind(null);
Default.args = {
'table-title': "This is a borderless table.",
rows: data
rows: data,
columns: defaultColumns
}
Default.argTypes = propStructure(vaTableDocs);

Expand All @@ -315,22 +316,25 @@ export const Bordered = Template.bind(null);
Bordered.args = {
'table-title': "This is a stacked bordered table.",
'table-type': 'bordered',
rows: data
rows: data,
columns: defaultColumns
}
Bordered.argTypes = propStructure(vaTableDocs);

export const NonStacked = Template.bind(null);
NonStacked.args = {
'table-title': "This table is not stacked. It will not change on a mobile screen.",
stacked: false,
rows: data
rows: data,
columns: defaultColumns
}
NonStacked.argTypes = propStructure(vaTableDocs);

export const WithCustomMarkup = CustomComponentsTemplate.bind(null);
WithCustomMarkup.args = {
'table-title': "This table has custom markup in some of its cells.",
rows: data
rows: data,
columns: defaultColumns
}

export const WithPagination = Pagination.bind(null);
Expand All @@ -342,5 +346,68 @@ WithPagination.args = {
export const WithMissingData = Template.bind(null);
WithMissingData.args = {
'table-title': "This table has some cells without data",
rows: missingData
rows: missingData,
columns: defaultColumns
}

const sortColumns = [
'Integer/Float',
'Percent',
'Currency',
'Ordinal mixed',
'Ordinal',
'Month only',
'Full date',
'Alphabetical'
]

const sortData = [
[
'3',
'60%',
'$2,500',
'4th',
'Ninth',
'August',
'June 3, 1903',
"Lorem ipsum dolor sit,"
],
[
'8.9',
'1%',
'$17,000',
'3rd',
'Second',
"February",
'October 25, 1415',
"amet consectetur adipisicing elit."
],
[
'-5',
'60.01%',
'$100,000',
"8th",
'Fifth',
"November",
'December 10, 1621',
"Alias nam eum minima",
],
[
'99',
'44%',
'$1,100',
"5th",
'First',
"April",
'September 30, 1885',
"delectus explicabo"
]
]

export const Sortable = Template.bind(null);
Sortable.args = {
'table-title': "This is a sortable table",
rows: sortData,
columns: sortColumns,
sortable: true
}
39 changes: 37 additions & 2 deletions packages/web-components/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1439,6 +1439,10 @@ export namespace Components {
interface VaSummaryBox {
}
interface VaTable {
/**
* Is the table sortable
*/
"sortable": boolean;
/**
* Convert to a stacked table when screen size is small True by default, must specify if false if this is unwanted
*/
Expand All @@ -1448,7 +1452,7 @@ export namespace Components {
*/
"tableTitle"?: string;
/**
* If uswds is true, the type of table
* The type of table
*/
"tableType"?: 'borderless';
}
Expand All @@ -1463,6 +1467,10 @@ export namespace Components {
*/
"cols"?: number;
"rows"?: number;
/**
* Is this a sortable table
*/
"sortable"?: boolean;
/**
* If true convert to a stacked table when screen size is small
*/
Expand Down Expand Up @@ -1869,6 +1877,10 @@ export interface VaStatementOfTruthCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLVaStatementOfTruthElement;
}
export interface VaTableInnerCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLVaTableInnerElement;
}
export interface VaTelephoneCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLVaTelephoneElement;
Expand Down Expand Up @@ -2810,12 +2822,23 @@ declare global {
prototype: HTMLVaTableElement;
new (): HTMLVaTableElement;
};
interface HTMLVaTableInnerElementEventMap {
"sortTable": any;
}
/**
* @componentName Table
* @maturityCategory use
* @maturityLevel best_practice
*/
interface HTMLVaTableInnerElement extends Components.VaTableInner, HTMLStencilElement {
addEventListener<K extends keyof HTMLVaTableInnerElementEventMap>(type: K, listener: (this: HTMLVaTableInnerElement, ev: VaTableInnerCustomEvent<HTMLVaTableInnerElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
removeEventListener<K extends keyof HTMLVaTableInnerElementEventMap>(type: K, listener: (this: HTMLVaTableInnerElement, ev: VaTableInnerCustomEvent<HTMLVaTableInnerElementEventMap[K]>) => any, options?: boolean | EventListenerOptions): void;
removeEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}
var HTMLVaTableInnerElement: {
prototype: HTMLVaTableInnerElement;
Expand Down Expand Up @@ -4622,6 +4645,10 @@ declare namespace LocalJSX {
interface VaSummaryBox {
}
interface VaTable {
/**
* Is the table sortable
*/
"sortable"?: boolean;
/**
* Convert to a stacked table when screen size is small True by default, must specify if false if this is unwanted
*/
Expand All @@ -4631,7 +4658,7 @@ declare namespace LocalJSX {
*/
"tableTitle"?: string;
/**
* If uswds is true, the type of table
* The type of table
*/
"tableType"?: 'borderless';
}
Expand All @@ -4645,7 +4672,15 @@ declare namespace LocalJSX {
* The number of columns in the table
*/
"cols"?: number;
/**
* Fires when the component is closed by clicking on the close icon. This fires only when closeable is true.
*/
"onSortTable"?: (event: VaTableInnerCustomEvent<any>) => void;
"rows"?: number;
/**
* Is this a sortable table
*/
"sortable"?: boolean;
/**
* If true convert to a stacked table when screen size is small
*/
Expand Down
15 changes: 15 additions & 0 deletions packages/web-components/src/components/va-table/sort/alpha.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { CompareFuncReturn } from "./utils";

// this function returns a function that sorts strings alphabetically and which can be passed to Array.sort
// the "sordir" argument controls whether the function that is returned sorts ascending or descending
// strings will be sorted alphabetically using local language if "lang" attribute added to the va-table element, otherwise defaults to en-us
export function alphaSort(sortdir: string): CompareFuncReturn {
let locale = this?.el?.getAttribute('lang');
locale = !!locale ? locale : 'en-US';
return function alphabeticalSort(a: string, b: string): number {
const isAsc = sortdir === 'ascending';
const _a = isAsc ? a : b;
const _b = isAsc ? b : a;
return new Intl.Collator(locale, { sensitivity: 'base' }).compare(_a, _b);
}
}
28 changes: 28 additions & 0 deletions packages/web-components/src/components/va-table/sort/date.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { months } from '../../../utils/constants';
import { CompareFuncReturn, difference } from "./utils";

// conventional date representation or month only will be valid
export function isValidDate(string: string): boolean {
const date = new Date(string);
return !isNaN(date.getTime()) || !!months[string.toLowerCase()];
}

// get a numerical representation for a date
function getDateValue(string: string): number {
// treat empty string as earliest date
if (string === '') {
return -Infinity;
}
const month = months[string.toLowerCase()];
return month ? month : new Date(string).getTime();
}

// this function returns a function that sorts date strings and which can be passed to Array.sort
// the "sordir" argument controls whether the function that is returned sorts ascending or descending
export function dateSort(sortdir: string): CompareFuncReturn {
return function datesSort(a: string, b: string): number {
const aTime = getDateValue(a);
const bTime = getDateValue(b);
return difference(aTime, bTime, sortdir);
}
}
40 changes: 40 additions & 0 deletions packages/web-components/src/components/va-table/sort/numerical.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { isNumeric } from '../../../utils/utils'
import { CompareFuncReturn, difference } from "./utils";
import { ordinals } from '../../../utils/constants';

const dateRegex = /^(\d{1,2}\/){1,2}(\d{2}|\d{4})$/;

// check if a string is numeric
export function _isNumeric(string: string): boolean {
const value = cleanString(string);
// isNumeric will return true for a string like 04/02/2024 but we want to treat it like a date
if (dateRegex.test(string)) {
return false
}
if (typeof value === 'number') {
return true;
}
return isNumeric(value);
}

// make sure that strings with commas/$ (e.g. $2,400) or percent signs
// (e.g. 87 %) or ordinals are treated like numbers
function cleanString(string: string): string | number {
// treat empty string as smallest
if (string === '') {
return -Infinity;
}
const _string = string.toLowerCase();
if (_string in ordinals) {
return ordinals[_string];
}
return string.replace(/[$%,]|(th)|(st)|(rd)|(nd)/g, '');
}

// this function returns a function that sorts numbers and which can be passed to Array.sort
// the "sordir" argument controls whether the function that is returned sorts ascending or descending
export function numSort(sortdir: string): CompareFuncReturn {
return function numberSort(a: string, b: string): number {
return difference(+cleanString(a), +cleanString(b), sortdir);
}
}
35 changes: 35 additions & 0 deletions packages/web-components/src/components/va-table/sort/utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { numSort, _isNumeric as isNumeric } from './numerical';
import { dateSort, isValidDate } from './date';
import { alphaSort } from './alpha';

export type CompareFuncReturn = (a: string, b: string) => number;

// used in numerical or date sorts
export function difference(a: number, b: number, sortdir: string): number {
return sortdir === 'ascending' ? a - b : b - a;
}

// return the sort function for the data type of the sort
export function _getCompareFunc(a: string, sortdir: string) {
let func: CompareFuncReturn;
if (isNumeric(a)) {
func = numSort(sortdir);
} else if (isValidDate(a)) {
func = dateSort(sortdir);
} else {
func = alphaSort.bind(this)(sortdir);
}
return func;
}

// for the first non empty piece of data in a column, find the appropriate sort function
// if all values are empty strings, return null to signify do not sort
export function getCompareFunc(rows: Element[], index: number, sortdir: string): CompareFuncReturn | null {
for (const row of rows) {
const cellContents = row.children[index].innerHTML.trim();
if (cellContents !== '') {
return _getCompareFunc.bind(this)(cellContents, sortdir);
}
}
return null;
}
Loading

0 comments on commit 266e660

Please sign in to comment.