diff --git a/packages/unity-react-core/src/components/Tables/Tables.stories.tsx b/packages/unity-react-core/src/components/Tables/Tables.stories.tsx new file mode 100644 index 0000000000..245a4dbccf --- /dev/null +++ b/packages/unity-react-core/src/components/Tables/Tables.stories.tsx @@ -0,0 +1,31 @@ +import React, { useEffect } from "react"; +import { Table } from "./Tables"; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: "Components/Table", + component: Table, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; + +export const BasicTable: StoryObj = { + args: { + columns: 5, + fixed: false + } +}; + +export const FixedTable: StoryObj = { + args: { + columns: 12, + fixed: true + } +}; + +export default meta; diff --git a/packages/unity-react-core/src/components/Tables/Tables.test.tsx b/packages/unity-react-core/src/components/Tables/Tables.test.tsx new file mode 100644 index 0000000000..800c1d9695 --- /dev/null +++ b/packages/unity-react-core/src/components/Tables/Tables.test.tsx @@ -0,0 +1,131 @@ +import { render, cleanup, RenderResult } from "@testing-library/react"; +import React from "react"; +import { expect, describe, it, afterEach, beforeEach } from 'vitest'; +import { Table } from "./Tables"; + +describe("Table Component Tests", () => { + let component: RenderResult; + const defaultProps = { + columns: 5 + }; + + const renderComponent = (props = defaultProps) => { + return render( +
+ + + ); + }; + + beforeEach(() => { + component = renderComponent(); + }); + + afterEach(cleanup); + + it("should render the table component", () => { + expect(component).toBeDefined(); + }); + + it("should have correct number of columns", () => { + const headerCells = component.container.querySelectorAll('thead th'); + expect(headerCells.length).toBe(defaultProps.columns + 1); + }); + + it("should display correct year range", () => { + const currentYear = 2024; + const headerCells = component.container.querySelectorAll('thead th'); + const years = Array.from(headerCells) + .slice(1) + .map(cell => cell.textContent); + + const expectedYears = new Array(defaultProps.columns) + .fill(null) + .map((_, i) => `Fall ${currentYear - (defaultProps.columns - 1) + i}`); + + expect(years).toEqual(expectedYears); + }); + + it("should render all campus rows", () => { + const campuses = [ + "Tempe", + "Downtown", + "Polytechnic", + "West", + "Thunderbird", + "Skysong Campus" + ]; + + campuses.forEach(campus => { + expect(component.getByText(campus)).toBeInTheDocument(); + }); + }); + + it("should have correct table structure", () => { + expect(component.container.querySelector('table')).toBeInTheDocument(); + expect(component.container.querySelector('thead')).toBeInTheDocument(); + expect(component.container.querySelector('tbody')).toBeInTheDocument(); + }); + + it("should render example link in first row", () => { + const link = component.container.querySelector('a'); + expect(link).toBeInTheDocument(); + expect(link?.textContent).toBe('example link'); + }); + + describe("with different column counts", () => { + it("should render with minimum columns", () => { + const minColumns = 4; + const minComponent = renderComponent({ columns: minColumns }); + const headerCells = minComponent.container.querySelectorAll('thead th'); + expect(headerCells.length).toBe(minColumns + 1); + }); + + it("should render with maximum columns", () => { + const maxColumns = 14; + const maxComponent = renderComponent({ columns: maxColumns }); + const headerCells = maxComponent.container.querySelectorAll('thead th'); + expect(headerCells.length).toBe(maxColumns + 1); + }); + }); + + describe("data calculation tests", () => { + it("should generate numbers for each cell", () => { + const firstDataRow = component.container.querySelectorAll('tbody tr')[0]; + const dataCells = firstDataRow.querySelectorAll('td'); + + dataCells.forEach(cell => { + expect(cell.textContent).toMatch(/^\d{1,3}(,\d{3})*$/); // Format like 1,234 + }); + }); + + it("should maintain consistent data structure across rows", () => { + const rows = component.container.querySelectorAll('tbody tr'); + const expectedCellCount = defaultProps.columns + 1; // columns + header cell + + rows.forEach(row => { + const cells = row.querySelectorAll('th, td'); + expect(cells.length).toBe(expectedCellCount); + }); + }); + }); + + describe("accessibility tests", () => { + it("should have proper scope attributes on headers", () => { + const columnHeaders = component.container.querySelectorAll('thead th'); + columnHeaders.forEach(header => { + expect(header).toHaveAttribute('scope', 'col'); + }); + + const rowHeaders = component.container.querySelectorAll('tbody th'); + rowHeaders.forEach(header => { + expect(header).toHaveAttribute('scope', 'row'); + }); + }); + + it("should have tabIndex on container", () => { + const container = component.container.querySelector('.uds-table'); + expect(container).toHaveAttribute('tabIndex', '0'); + }); + }); +}); diff --git a/packages/unity-react-core/src/components/Tables/Tables.tsx b/packages/unity-react-core/src/components/Tables/Tables.tsx new file mode 100644 index 0000000000..316e57fa99 --- /dev/null +++ b/packages/unity-react-core/src/components/Tables/Tables.tsx @@ -0,0 +1,137 @@ +import React, { useEffect } from "react"; +import { initializeFixedTable } from "./fixedTable"; + +const makingUpFakeNumbers = (a, b, c) => + Math.round(a * (b + c)).toLocaleString("en-US"); + +interface TableProps { + columns: number; + fixed?: boolean; +} + +const BaseTable = ({ columns }) => { + let year = 2024; + const arr = new Array(columns) + .fill(null) + .map((v, i) => year - i) + .reverse(); + return ( +
+ + + + {arr.map((v, i) => ( + + ))} + + + + + + {arr.map((v, i) => ( + + ))} + + + + {arr.map((v, i) => ( + + ))} + + + + {arr.map((v, i) => ( + + ))} + + + + {arr.map((v, i) => ( + + ))} + + + + {arr.map((v, i) => ( + + ))} + + + + {arr.map((v, i) => ( + + ))} + + + + {arr.map((v, i) => ( + + ))} + + + + {arr.map((v, i) => ( + + ))} + + +
Enrollment + Fall {v} +
+

+ use of <a> in cells{" "} + example link +

+ Metropolitan campus population +
{makingUpFakeNumbers(v, 35, i)}
+ Tempe + {makingUpFakeNumbers(v, 25, i)}
+ Downtown + {makingUpFakeNumbers(v, 7, i)}
+ Polytechnic + {makingUpFakeNumbers(v, 1.6, i / 2)}
+ West + {makingUpFakeNumbers(v, 0.8, i / 4)}
+ Thunderbird + {makingUpFakeNumbers(v, 0.1, i / 10)}
+ Skysong Campus + {makingUpFakeNumbers(v, 5, i / 5)}
Total{makingUpFakeNumbers(v, 50, i)}
+ ); +}; + +export const Table: React.FC = ({ columns, fixed = false }) => { + useEffect(() => { + if (fixed) { + initializeFixedTable(); + } + }, []); + + if (!fixed) { + return ( +
+ +
+ ); + } + return ( +
+
+ +
+ +
+ +
+ +
+ +
+
+ ); +}; diff --git a/packages/unity-react-core/src/components/Tables/fixedTable.js b/packages/unity-react-core/src/components/Tables/fixedTable.js new file mode 100644 index 0000000000..cf7234f0ad --- /dev/null +++ b/packages/unity-react-core/src/components/Tables/fixedTable.js @@ -0,0 +1,61 @@ +function initializeFixedTable() { + function setPreButtonPosition() { + const wrapperSelector = '.uds-table-fixed-wrapper'; + const tableSelector = '.uds-table.uds-table-fixed table'; + const prevScrollSelector = '.scroll-control.previous'; + + const wrappers = document.querySelectorAll(wrapperSelector); + wrappers.forEach((wrapper, index) => { + /** @type {HTMLTableElement} */ + const table = wrapper.querySelector(tableSelector); + table.setAttribute('id', 'uds-table-' + index); + /** @type {HTMLTableCellElement} */ + const firstCol = table.querySelector('tbody tr > *'); + /** @type {HTMLElement} */ + const prevButton = wrapper.querySelector(prevScrollSelector); + prevButton.style.left = firstCol.offsetWidth + 'px'; + }); + } + + function setButtonLiListeners() { + const containerSelector = '.uds-table-fixed'; + const wrapperSelector = '.uds-table-fixed-wrapper'; + const prevScrollSelector = '.scroll-control.previous'; + const nextScrollSelector = '.scroll-control.next'; + + const wrappers = document.querySelectorAll(wrapperSelector); + wrappers.forEach((wrapper, index) => { + const container = wrapper.querySelector(containerSelector); + const prevButton = wrapper.querySelector(prevScrollSelector); + const nextButton = wrapper.querySelector(nextScrollSelector); + + ['click', 'focus'].forEach((eventName) => { + prevButton.addEventListener(eventName, function () { + /* Scroll can't go beyond it's bounds, it won't go lower than 0 */ + container.scrollLeft -= 100; + }); + + nextButton.addEventListener(eventName, function () { + container.scrollLeft += 100; + }); + }); + }); + } + + function debounce(func, timeout) { + let timerId; + return (...args) => { + clearTimeout(timerId); + timerId = setTimeout(() => { + func.apply(this, args); + }, timeout); + }; + } + setPreButtonPosition(); + setButtonLiListeners(); + window.addEventListener('resize', function () { + debounce(setPreButtonPosition, 100)(); + }); +}; + +export { initializeFixedTable };