diff --git a/packages/ncids-js/package.json b/packages/ncids-js/package.json
index 299053a75..3762e2138 100644
--- a/packages/ncids-js/package.json
+++ b/packages/ncids-js/package.json
@@ -87,6 +87,16 @@
"import": "./lib/esm/components/usa-modal/index.js",
"types": "./lib/esm/components/usa-modal/index.d.js",
"require": "./lib/cjs/components/usa-modal/index.js"
+ },
+ "./usa-table": {
+ "import": "./lib/esm/components/usa-table/index.js",
+ "types": "./lib/esm/components/usa-table/index.d.js",
+ "require": "./lib/cjs/components/usa-table/index.js"
+ },
+ "./usa-table/auto-init": {
+ "import": "./lib/esm/components/usa-table/index.js",
+ "types": "./lib/esm/components/usa-table/index.d.js",
+ "require": "./lib/cjs/components/usa-table/index.js"
}
},
"scripts": {
diff --git a/packages/ncids-js/src/components/usa-table/__tests__/usa-table-sortable.dom.ts b/packages/ncids-js/src/components/usa-table/__tests__/usa-table-sortable.dom.ts
new file mode 100644
index 000000000..bddcf6e7e
--- /dev/null
+++ b/packages/ncids-js/src/components/usa-table/__tests__/usa-table-sortable.dom.ts
@@ -0,0 +1,474 @@
+export const usaTableSortableDOM = (): HTMLElement => {
+ const div = document.createElement('div');
+
+ div.innerHTML = `
+
+
+
+
+ `;
+ return div;
+};
+
+export const usaTableSortableSortedDOM = (): HTMLElement => {
+ const div = document.createElement('div');
+
+ div.innerHTML = `
+
+
+
+
+ `;
+ return div;
+};
diff --git a/packages/ncids-js/src/components/usa-table/__tests__/usa-table-sortable.test.ts b/packages/ncids-js/src/components/usa-table/__tests__/usa-table-sortable.test.ts
new file mode 100644
index 000000000..31489dc6b
--- /dev/null
+++ b/packages/ncids-js/src/components/usa-table/__tests__/usa-table-sortable.test.ts
@@ -0,0 +1,156 @@
+import '@testing-library/jest-dom';
+import '@testing-library/jest-dom/extend-expect';
+import userEvent from '@testing-library/user-event';
+
+import { SortableTableOptions } from '../usa-table-sortable-options';
+import {
+ usaTableSortableDOM,
+ usaTableSortableSortedDOM,
+} from './usa-table-sortable.dom';
+import { USATableSortable } from '../usa-table-sortable.component';
+
+describe('USATable', () => {
+ let container: HTMLElement;
+ let sortableTable: USATableSortable;
+ const options: SortableTableOptions = {
+ sortable: true,
+ };
+
+ beforeEach(() => {
+ container = usaTableSortableDOM();
+ document.body.appendChild(container);
+ sortableTable = USATableSortable.create(container, options);
+ });
+
+ afterEach(() => {
+ document.getElementsByTagName('body')[0].innerHTML = '';
+ });
+
+ it('creates a usa table with sortable columns', () => {
+ expect(sortableTable).toBeDefined();
+ const headerRow = container.querySelector('thead') as HTMLElement;
+ const headings = headerRow.querySelectorAll('th');
+ expect(headings[0]).toHaveAttribute('data-sortable', '');
+ });
+
+ it('creates a usa table with sortable columns when there is a pre-sorted column', () => {
+ document.body.innerHTML = '';
+ container = usaTableSortableSortedDOM();
+ document.body.appendChild(container);
+ const sortedTable = USATableSortable.create(container, options);
+ expect(sortedTable).toBeDefined();
+ const headerRow = container.querySelector('thead') as HTMLElement;
+ const headings = headerRow.querySelectorAll('th');
+ expect(headings[0]).toHaveAttribute('data-sortable', '');
+ });
+
+ it('creates a usa table with sortable columns when using createAll()', () => {
+ document.body.innerHTML = '';
+ container = usaTableSortableDOM();
+ document.body.appendChild(container);
+ USATableSortable.createAll();
+ const headerRow = container.querySelector('thead') as HTMLElement;
+ const headings = headerRow.querySelectorAll('th');
+ expect(headings[0]).toHaveAttribute('data-sortable', '');
+ });
+
+ it('creates a usa table with sortable columns when utilizing the autoInit function', () => {
+ document.body.innerHTML = '';
+ container = usaTableSortableDOM();
+ document.body.appendChild(container);
+ USATableSortable.autoInit();
+ // Simulate DOMContentLoaded event
+ document.dispatchEvent(new Event('DOMContentLoaded'));
+ const headerRow = container.querySelector('thead') as HTMLElement;
+ const headings = headerRow.querySelectorAll('th');
+ expect(headings[0]).toHaveAttribute('data-sortable', '');
+ });
+
+ it('sorts a column when the header is clicked', async () => {
+ const user = userEvent.setup();
+ const firstRowFirstCell = container.querySelector(
+ 'tbody tr:first-child th'
+ );
+ expect(firstRowFirstCell?.textContent).toBe('Hawaii');
+
+ const nameHeading = container.querySelector(
+ 'thead th:first-child'
+ ) as HTMLElement;
+ const sortHeaderButton = nameHeading.querySelector('button') as HTMLElement;
+
+ await user.click(sortHeaderButton);
+ const newFirstRowFirstCell = container.querySelector(
+ 'tbody tr:first-child th'
+ );
+ expect(newFirstRowFirstCell?.textContent).toBe('Alaska');
+ });
+
+ it('sorts a column with numbers correctly', async () => {
+ const user = userEvent.setup();
+ const orderHeading = container.querySelectorAll(
+ 'thead th'
+ )[1] as HTMLElement;
+ const sortHeaderButton = orderHeading.querySelector(
+ 'button'
+ ) as HTMLElement;
+
+ await user.click(sortHeaderButton);
+ const firstRowOrderCell = container.querySelector(
+ 'tbody tr:first-child td:nth-child(2)'
+ );
+ expect(firstRowOrderCell?.textContent).toBe('45th');
+ });
+
+ it('sorts a column with dates correctly', async () => {
+ const user = userEvent.setup();
+ const orderHeading = container.querySelectorAll(
+ 'thead th'
+ )[2] as HTMLElement;
+ const sortHeaderButton = orderHeading.querySelector(
+ 'button'
+ ) as HTMLElement;
+
+ await user.click(sortHeaderButton);
+ const firstRowOrderCell = container.querySelector(
+ 'tbody tr:first-child td:nth-child(3)'
+ );
+ expect(firstRowOrderCell?.textContent).toBe('Nov. 5, 1895');
+ });
+
+ it('toggles sort direction when the header is clicked multiple times', async () => {
+ const user = userEvent.setup();
+ const nameHeading = container.querySelector(
+ 'thead th:first-child'
+ ) as HTMLElement;
+ const sortHeaderButton = nameHeading.querySelector('button') as HTMLElement;
+
+ // First click - ascending
+ await user.click(sortHeaderButton);
+ let firstRowFirstCell = container.querySelector('tbody tr:first-child th');
+ expect(firstRowFirstCell?.textContent).toBe('Alaska');
+
+ // Second click - descending
+ await user.click(sortHeaderButton);
+ firstRowFirstCell = container.querySelector('tbody tr:first-child th');
+ expect(firstRowFirstCell?.textContent).toBe('Utah');
+ });
+
+ it('sets aria-sort attribute correctly on header', async () => {
+ const user = userEvent.setup();
+ const nameHeading = container.querySelector(
+ 'thead th:first-child'
+ ) as HTMLElement;
+ const sortHeaderButton = nameHeading.querySelector('button') as HTMLElement;
+
+ // Initial state
+ expect(nameHeading).not.toHaveAttribute('aria-sort');
+
+ // First click - ascending
+ await user.click(sortHeaderButton);
+ expect(nameHeading).toHaveAttribute('aria-sort', 'ascending');
+
+ // Second click - descending
+ await user.click(sortHeaderButton);
+ expect(nameHeading).toHaveAttribute('aria-sort', 'descending');
+ });
+});
diff --git a/packages/ncids-js/src/components/usa-table/auto-init.ts b/packages/ncids-js/src/components/usa-table/auto-init.ts
new file mode 100644
index 000000000..8a60e7a10
--- /dev/null
+++ b/packages/ncids-js/src/components/usa-table/auto-init.ts
@@ -0,0 +1,7 @@
+import { USATableSortable } from './usa-table-sortable.component';
+
+/*
+ * Auto initialize usa-accordion when dom is ready.
+ * @packageDocumentation
+ */
+USATableSortable.autoInit();
diff --git a/packages/ncids-js/src/components/usa-table/index.ts b/packages/ncids-js/src/components/usa-table/index.ts
new file mode 100644
index 000000000..74658b055
--- /dev/null
+++ b/packages/ncids-js/src/components/usa-table/index.ts
@@ -0,0 +1,4 @@
+/**
+ * @packageDocumentation
+ */
+export { USATableSortable } from './usa-table-sortable.component';
diff --git a/packages/ncids-js/src/components/usa-table/usa-table-sortable-options.ts b/packages/ncids-js/src/components/usa-table/usa-table-sortable-options.ts
new file mode 100644
index 000000000..0e096372b
--- /dev/null
+++ b/packages/ncids-js/src/components/usa-table/usa-table-sortable-options.ts
@@ -0,0 +1,8 @@
+/**
+ * USA Table Options
+ * Options used for initialization of the sortable usa-table
+ */
+export type SortableTableOptions = {
+ /** Determines if the table is sortable */
+ sortable: boolean;
+};
diff --git a/packages/ncids-js/src/components/usa-table/usa-table-sortable.component.ts b/packages/ncids-js/src/components/usa-table/usa-table-sortable.component.ts
new file mode 100644
index 000000000..7aaffbe84
--- /dev/null
+++ b/packages/ncids-js/src/components/usa-table/usa-table-sortable.component.ts
@@ -0,0 +1,311 @@
+import { SortableTableOptions } from './usa-table-sortable-options';
+/**
+ * USA (Sortable) Table
+ * Component for creating sortable tables
+ * This component should eventually come from the NCIDS
+ * For now, we're exporting it as a class here to mimick how it
+ * would come from the NCIDS
+ */
+export class USATableSortable {
+ /** The Table Element */
+ protected tableElement: HTMLElement;
+ /** Optional settings for the component */
+ protected options: SortableTableOptions;
+ /** Default options settings */
+ private static optionDefaults: SortableTableOptions = {
+ sortable: false,
+ };
+
+ /** Callback for handling table heading button click */
+ private tableSortToggleClickEventListener: EventListener = (event: Event) =>
+ this.handleTableToggleClick(event);
+
+ /**
+ * Sets component properties and initializes component.
+ *
+ * @param htmlElement container of content being created as a sortable table
+ * @param options Table options used for component creation
+ */
+ protected constructor(
+ htmlElement: HTMLElement,
+ options: Partial
+ ) {
+ this.tableElement = htmlElement;
+ this.options = {
+ ...USATableSortable.optionDefaults,
+ ...options,
+ };
+
+ this.initialize();
+ }
+
+ /**
+ * Instantiates this component of the given element.
+ *
+ * @param element Element to initialize.
+ * @param options AccordionOptions for initialization (allow multiple sections, open sections on initialization)
+ */
+ public static create(
+ element: HTMLElement,
+ options: SortableTableOptions
+ ): USATableSortable {
+ return new this(element, options);
+ }
+
+ /**
+ * Initializes the sortable table based on DOM
+ */
+ private initialize(): void {
+ // Determine if table is sortable based on options and
+ // add the appropriate class
+ if (this.options.sortable) {
+ // The table head element (the row with the headings)
+ const tableHead = this.tableElement.querySelector('thead') as HTMLElement;
+ // All of the table's headings
+ const tableHeadings = Array.from(tableHead.querySelectorAll('th'));
+ // Make each heading sortable
+ tableHeadings.forEach((heading) => {
+ // Only make sortable if not marked with data-fixed
+ if (!heading.hasAttribute('data-fixed')) {
+ heading.setAttribute('data-sortable', '');
+ this.createHeaderButton(heading as HTMLElement);
+ }
+ // If the heading has aria-sort already set,
+ // sort by that column on initialization
+ // (aria-sort="descending" can be set in the HTML
+ // to sort descending on init) Defaults to ascending sort
+ const isAscending =
+ !heading.getAttribute('aria-sort') ||
+ heading.getAttribute('aria-sort') === 'ascending';
+ if (heading.hasAttribute('aria-sort')) {
+ this.sortTable(heading as HTMLElement, isAscending);
+ }
+ });
+ }
+ }
+
+ /**
+ * Creates the button element in the heading cell for sorting
+ * With the SVGs used by usa-table-sortable in USWDS
+ * @param header the heading cell of the column being sorted
+ */
+ private createHeaderButton = (header: HTMLElement) => {
+ const buttonEl = document.createElement('button');
+ buttonEl.setAttribute('tabindex', '0');
+ buttonEl.classList.add('usa-table__header__button');
+ // ICON_SOURCE
+ buttonEl.innerHTML = `
+
+ `;
+ buttonEl.addEventListener('click', this.tableSortToggleClickEventListener);
+ header.appendChild(buttonEl);
+ };
+
+ /**
+ * Fuction to update aria-sort attribute on all headings in the table
+ * when a heading is clicked to sort
+ * @param tableHeading the heading cell of the column being sorted
+ * @param isAscending whether the sorting is ascending or descending
+ */
+ private updateAllHeadingSortAttributes = (
+ tableHeading: HTMLElement,
+ isAscending: boolean
+ ) => {
+ tableHeading.setAttribute(
+ 'aria-sort',
+ isAscending ? 'ascending' : 'descending'
+ );
+
+ const table = tableHeading.closest('table') as HTMLElement;
+ const tableHeadings = Array.from(table.querySelectorAll('th'));
+ tableHeadings.forEach((th) => {
+ if (th !== tableHeading) {
+ th.removeAttribute('aria-sort');
+ }
+ });
+ };
+
+ /**
+ * Function to sort the table row based on the content
+ * @param row the row being sorted
+ * @param columnIndex when column is being sorted relative to other columns
+ * @param isAscending whether the sorting is ascending or descending
+ */
+ private sortTableByColumn = (
+ row: HTMLElement,
+ columnIndex: number,
+ isAscending: boolean
+ ) => {
+ // Get the table and tbody elements for DOM manipulation
+ const table = row.closest('table') as HTMLTableElement;
+ const tbody = table.querySelector('tbody') as HTMLTableSectionElement;
+
+ // Create an array from the rows for sorting
+ const rowsArray = Array.from(tbody.rows);
+
+ // Sort the rows based on the content of the specified column
+ // The sorting logic checks for numbers, then dates, then strings
+ rowsArray.sort((a, b) => {
+ // Get the cell elements to check for data-sortable-type attribute
+ const aCellElement = a.cells[columnIndex];
+ const bCellElement = b.cells[columnIndex];
+
+ // Get the header cell for the column being sorted
+ // to check for data-sortable-type attribute
+ const sortedColumnHeader =
+ table.querySelector('thead')?.rows[0]?.cells[columnIndex];
+
+ // Get the text content of the cells for sorting
+ const aCell = aCellElement?.textContent?.trim() ?? '';
+ const bCell = bCellElement?.textContent?.trim() ?? '';
+
+ // Check if header has data-sortable-type="date" attribute
+ if (
+ sortedColumnHeader?.hasAttribute('data-sortable-type') &&
+ sortedColumnHeader.getAttribute('data-sortable-type') === 'date'
+ ) {
+ // Try parsing as dates
+ const aDate = new Date(aCell).getTime();
+ const bDate = new Date(bCell).getTime();
+
+ // Compare timestamps if both cells were valid dates
+ if (!isNaN(aDate) && !isNaN(bDate)) {
+ return isAscending ? aDate - bDate : bDate - aDate;
+ }
+ }
+
+ // Check if the cells are numbers by removing '$' and ',' characters
+ // and trying to parse as float
+ const isNumber = (cellValue: string) => {
+ const removeCurrency = cellValue.replace(/[$,]/g, '');
+ return !isNaN(parseFloat(removeCurrency));
+ };
+
+ // If the cell values are numbers, compare as numbers
+ // Strip all non-numeric characters for comparison
+ if (isNumber(aCell) && isNumber(aCell)) {
+ const aNum = parseFloat(aCell.replace(/[^0-9.-]/g, ''));
+ const bNum = parseFloat(bCell.replace(/[^0-9.-]/g, ''));
+ return isAscending ? aNum - bNum : bNum - aNum;
+ }
+
+ // Fallback to string comparison
+ return isAscending
+ ? aCell.localeCompare(bCell)
+ : bCell.localeCompare(aCell);
+ });
+
+ // Re-append rows in sorted order
+ for (const sortedRow of rowsArray) {
+ tbody.appendChild(sortedRow);
+
+ // Remove data-sort-active attribute from all cells in the row
+ // to clear previous sort indicators
+ sortedRow
+ .querySelectorAll('td, th')
+ .forEach((td) => td.removeAttribute('data-sort-active'));
+
+ // Add attribute to the cell in the sorted column to indicate active sort
+ // for styling purposes
+ sortedRow.children[columnIndex].setAttribute('data-sort-active', '');
+ }
+ };
+
+ /**
+ * Get the column index of the heading being sorted
+ * and call the function to sort the rows based on that column
+ * @param heading the heading cell of the column being sorted
+ * @param isAscending whether the sorting is ascending or descending
+ */
+ private sortTable = (heading: HTMLElement, isAscending: boolean) => {
+ // Need to cast as HTMLTableCellElement to get its index among the other headings
+ const tableHeading = heading as HTMLTableCellElement;
+ // Get the table so we can find all the headings
+ const table = tableHeading.closest('table') as HTMLElement;
+ // Get all the headings in the table and find the index of this heading
+ // among them
+ const allHeadings = Array.from(table.querySelectorAll('th'));
+ const thisHeadingIndex = allHeadings.indexOf(tableHeading);
+
+ // Sort the row associated with this heading
+ this.sortTableByColumn(heading, thisHeadingIndex, isAscending);
+
+ // Update the aria-sort attribute on all headings
+ this.updateAllHeadingSortAttributes(heading, isAscending);
+ };
+
+ /**
+ * Handles click on header sort button in table header
+ * @param e Event passed on from click
+ */
+ private handleTableToggleClick(e: Event): void {
+ const heading = (e.currentTarget as HTMLElement).closest(
+ 'th'
+ ) as HTMLElement;
+
+ // Determine if currently ascending or descending
+ // to toggle the sort direction
+ const isAscending = heading.getAttribute('aria-sort') === 'ascending';
+
+ // If no aria-sort attribute (not sorted), default to ascending sort
+ // Otherwise, toggle the sort direction
+ if (!heading.hasAttribute('aria-sort')) {
+ this.sortTable(heading, true);
+ } else {
+ this.sortTable(heading, !isAscending);
+ }
+ }
+
+ /**
+ * Auto initializes sortable table component with default sources.
+ * @internal
+ */
+ public static autoInit(): void {
+ document.addEventListener('DOMContentLoaded', () => {
+ const tables = Array.from(document.getElementsByClassName('usa-table'));
+
+ tables.forEach((element) => {
+ const htmlElement = element as HTMLElement;
+ // Check if Table is Sortable
+ const isSortable = htmlElement.hasAttribute('data-sortable');
+ // Return table with default options
+ // set whether table is sortable
+ return this.create(htmlElement, {
+ ...this.optionDefaults,
+ sortable: isSortable,
+ });
+ });
+ });
+ }
+
+ /**
+ * Public function for creating sortable table component with default sources from HTML.
+ * @return array of initialized sortable tables
+ */
+ public static createAll(): USATableSortable[] {
+ const tables = Array.from(document.querySelectorAll('.usa-table'));
+
+ const instances = tables.map((element) => {
+ const htmlElement = element as HTMLElement;
+ // Check if Table is Sortable
+ const isSortable = htmlElement.hasAttribute('data-sortable');
+ // Return table with default options
+ // set whether table is sortable
+ return this.create(htmlElement, {
+ ...this.optionDefaults,
+ sortable: isSortable,
+ });
+ });
+ return instances;
+ }
+}
diff --git a/packages/ncids-js/src/index.ts b/packages/ncids-js/src/index.ts
index d45763b4d..198092bb8 100644
--- a/packages/ncids-js/src/index.ts
+++ b/packages/ncids-js/src/index.ts
@@ -5,3 +5,4 @@ export * from 'src/components/usa-combo-box';
export * from 'src/components/usa-footer';
export * from 'src/components/usa-modal';
export * from 'src/components/usa-site-alert';
+export * from 'src/components/usa-table';
diff --git a/testing/ncids-css-testing/src/stories/components/usa-table/src/index.scss b/testing/ncids-css-testing/src/stories/components/usa-table/src/index.scss
new file mode 100644
index 000000000..271469767
--- /dev/null
+++ b/testing/ncids-css-testing/src/stories/components/usa-table/src/index.scss
@@ -0,0 +1,3 @@
+@use "styles/ncids";
+@forward "uswds-utilities";
+@forward "usa-table";
diff --git a/testing/ncids-css-testing/src/stories/components/usa-table/src/usa-table--ncids-sortable/usa-table--sortable.twig b/testing/ncids-css-testing/src/stories/components/usa-table/src/usa-table--ncids-sortable/usa-table--sortable.twig
new file mode 100644
index 000000000..11d72b33d
--- /dev/null
+++ b/testing/ncids-css-testing/src/stories/components/usa-table/src/usa-table--ncids-sortable/usa-table--sortable.twig
@@ -0,0 +1,217 @@
+
+
+
+
+
diff --git a/testing/ncids-css-testing/src/stories/components/usa-table/src/usa-table.ncids-sortable.stories.jsx b/testing/ncids-css-testing/src/stories/components/usa-table/src/usa-table.ncids-sortable.stories.jsx
new file mode 100644
index 000000000..09304b6ff
--- /dev/null
+++ b/testing/ncids-css-testing/src/stories/components/usa-table/src/usa-table.ncids-sortable.stories.jsx
@@ -0,0 +1,15 @@
+import Component from './usa-table--ncids-sortable/usa-table--sortable.twig';
+import css from './index.scss?inline';
+
+import { USATableSortable } from '@nciocpl/ncids-js/usa-table';
+
+export default {
+ title: 'Components/Table',
+ component: Component,
+ parameters: {
+ ncidsInitJs: () => USATableSortable.createAll(),
+ css,
+ },
+};
+
+export const NcidsSortable = {};
diff --git a/testing/ncids-css-testing/src/stories/components/usa-table/src/usa-table.twig b/testing/ncids-css-testing/src/stories/components/usa-table/src/usa-table.twig
new file mode 100755
index 000000000..4f07741f9
--- /dev/null
+++ b/testing/ncids-css-testing/src/stories/components/usa-table/src/usa-table.twig
@@ -0,0 +1,41 @@
+{% if scrollable %}
+
+ * in billions of dollars. Data for illustration purposes only.
+{% endif %}