diff --git a/.gitignore b/.gitignore index 0bc773bdf..ea690ff07 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,4 @@ test/skills/* # Oobee a11y scan results results/ a11y-failures.log -**/.pnpm/* +**/.pnpm* diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 15a0825fd..7f0830c45 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -57,7 +57,7 @@ This method registers all SGDS elements up front in the Custom Elements Registry // it is recommended to load a particular version when using cdn e.g. https://cdn.jsdelivr.net/npm/@govtechsg/sgds-web-component@1.0.2 - + //or load a single component e.g. Masthead diff --git a/playground/DataTable.html b/playground/DataTable.html new file mode 100644 index 000000000..144cd99e3 --- /dev/null +++ b/playground/DataTable.html @@ -0,0 +1,977 @@ + + + + + + +
+ Night mode +
+ + +
+

Basic

+ + + # + First name + Last name + Username + + + 1 + John + Doe + @johndoe + + + 2 + Jane + Doe + @janedoe + + + 3 + Bob + Smith + @bobsmith + + + 4 + Alice + Tan + @alicetan + + + 5 + Charlie + Lee + @charlielee + + + 6 + Diana + Lim + @dianalim + + + 7 + Ethan + Ng + @ethanng + + + 8 + Fiona + Koh + @fionakoh + + + 9 + George + Wong + @georgewong + + + 10 + Hannah + Yeo + @hannahyeo + + + 11 + Ivan + Chua + @ivanchua + + + 12 + Julia + Goh + @juliagoh + + + 13 + Kevin + Teo + @kevinteo + + + 14 + Laura + Ong + @lauraong + + + 15 + Marcus + Chan + @marcuschan + + + 16 + Nina + Phua + @ninaphua + + + 17 + Oscar + Sim + @oscarsim + + + 18 + Priya + Rajan + @priyarajan + + + 19 + Quinn + Ho + @quinnho + + + 20 + Rachel + Wee + @rachelwee + + + 21 + Samuel + Tan + @samueltan + + + 22 + Tina + Loh + @tinaloh + + + 23 + Uma + Devi + @umadevi + + +
+ + +
+

No rows

+ + + # + First name + Last name + Username + + +
+ + +
+

Multi-select

+ + + # + First name + Last name + Username + + + 1 + John + Doe + @johndoe + + + 2 + Jane + Doe + @janedoe + + + 3 + Bob + Smith + @bobsmith + + +
+ + +
+

Multi-select (Pre-checked on load)

+ + + # + First name + Last name + Username + + + 1 + John + Doe + @johndoe + + + 2 + Jane + Doe + @janedoe + + + 3 + Bob + Smith + @bobsmith + + +
+ + +
+

Expandable rows

+ + + # + First name + Last name + Username + + + + 1 + John + Doe + @johndoe +
Department: Engineering · Join date: 01 Jan 2022 · Status: Active
+
+ + + 2 + Jane + Doe + @janedoe +
Department: Design · Join date: 15 Mar 2023 · Status: Active
+
+ + + 3 + Bob + Smith + @bobsmith + +
+
+ + +
+

Expandable + Multi-select

+ + + # + First name + Last name + Username + + + + 1 + John + Doe + @johndoe +
Department: Engineering
+
+ + + 2 + Jane + Doe + @janedoe + + + + 3 + Bob + Smith + @bobsmith + +
+
+ + +
+

Custom Footer Text (footerText)

+ + + ID + Name + + + 1 + Amy + + + 2 + Ben + + + 3 + Cara + + + 4 + Dan + + +
+ + +
+

Hide Footer (hideFooter)

+ + + ID + Status + + + 1001 + Pending + + + 1002 + Approved + + + 1003 + Rejected + + +
+ + +
+

Server Mode (mode="server")

+

+ This table fetches data per page. Use the pagination controls to load the next set of results. +

+ + + ID + Name + Region + + +
+
+ + +
+

Header and Cell Props (sorting, sortKey, width)

+ + + ID + Name + Role + Score + + + + 1 + Lina + Engineer + 82 + + + 2 + Adam + Engineer + 70 + + + 3 + Nora + Manager + 85 + + +
+ + +
+

Default Sort

+ + + ID + Name + Role + + + + 2 + Alice + Engineer + + + 4 + Ben + Analyst + + + 1 + Chloe + Manager + + + 3 + Darren + Designer + + +
+ + +
+

Row Colspan Example (5 data rows, 5 columns)

+ + + ID + Item + Category + Status + Amount + + + + 1 + Application fee + Service + Paid + 120.00 + + + + 2 + Support package (category merged) + Pending + 240.00 + + + + 3 + Licence renewal + Regulatory + Paid + 320.00 + + + + 4 + Bulk order adjustment (category merged) + Approved + 180.00 + + + + 5 + Maintenance + Service + In progress + 95.00 + + +
+ + +
+

Interactive Slotted Content (Event Hooks)

+ + + ID + Dropdown + Overflow + Button + Controls (Combobox + Input) + Badges + + + + 1 + + + + Action + + + Open + Archive + + + + + View + Edit + Delete + + + + Run + + +
+ + Option A + Option B + Option C + + +
+
+ +
+ New + Synced +
+
+
+ + + 2 + + + + Action + + + Approve + Reject + + + + + Copy + Export + + + + Retry + + +
+ + Priority 1 + Priority 2 + Priority 3 + + +
+
+ +
+ Review + Alert +
+
+
+
+ +
Event log ready.
+
+ + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a2aee0a6..b0fab540f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2073,63 +2073,54 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-arm64-gnu@0.1.53': resolution: {integrity: sha512-QKK+sykEiYwjwd+ogyLcpcnH38DNZ8KViBlnfEpoGA2Wa+21/cWQKfMxnbgb/rbvm5tazJinZcihFvH577WQ5g==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.100': resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@napi-rs/canvas-linux-arm64-musl@0.1.53': resolution: {integrity: sha512-2N41U0X8RnrTKzpTtPv1ozlYkJtPsUdbfF3uP/KEd/BsULGd8Y8ghkGMS6CM+821au4ex0dPrWOOdT9wC1rSqQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.100': resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.53': resolution: {integrity: sha512-7XjuTvDKCODtf/vMwF43VGDrjfgwYKgS91ggdcX3UrJaBYWyWu/+eqNvNj+zdXSe/0x+YOjf5jG4m8xIXdBMQA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.100': resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@napi-rs/canvas-linux-x64-musl@0.1.53': resolution: {integrity: sha512-970WEvB8vmj+uxvgdBZ+AGFV7uq9GJhXrqG5PGQ5lWciHX0P0d/OhS2F7TITgFR0LsKDQZ7XQgzMxsYOfwZ0FQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@napi-rs/canvas-win32-arm64-msvc@0.1.100': resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} @@ -2400,42 +2391,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -3068,28 +3053,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -6460,28 +6441,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} diff --git a/skills/sgds-components/SKILL.md b/skills/sgds-components/SKILL.md index b2441a914..873d5cca4 100644 --- a/skills/sgds-components/SKILL.md +++ b/skills/sgds-components/SKILL.md @@ -1,6 +1,6 @@ --- name: "sgds-components" -description: "Complete reference for all SGDS web components including installation and framework integration. Use when users ask about any component — accordion, alert, badge, breadcrumb, button, card, checkbox, close-button, combo-box, datepicker, description-list, divider, drawer, dropdown, file-upload, footer, icon, icon-button, icon-card, icon-list, image-card, input, link, mainnav, masthead, modal, overflow-menu, pagination, progress-bar, quantity-toggle, radio, select, sidebar, sidenav, skeleton, spinner, stepper, subnav, switch, system-banner, tab, table, table-of-contents, textarea, thumbnail-card, toast, or tooltip. Also covers React 19+, React ≤18, Vue, Angular, and Next.js integration." +description: "Complete reference for all SGDS web components including installation and framework integration. Use when users ask about any component — accordion, alert, badge, breadcrumb, button, card, checkbox, close-button, combo-box, data-table, datepicker, description-list, divider, drawer, dropdown, file-upload, footer, icon, icon-button, icon-card, icon-list, image-card, input, link, mainnav, masthead, modal, overflow-menu, pagination, progress-bar, quantity-toggle, radio, select, sidebar, sidenav, skeleton, spinner, stepper, subnav, switch, system-banner, tab, table, table-of-contents, textarea, thumbnail-card, toast, or tooltip. Also covers React 19+, React ≤18, Vue, Angular, and Next.js integration." metadata: author: singapore-design-system version: "0.0.0" @@ -210,6 +210,7 @@ When a user reports unexpected component behaviour (wrong event, property not re | **Content** | Image Card | [→ reference/image-card.md](reference/image-card.md) | | **Content** | Thumbnail Card | [→ reference/thumbnail-card.md](reference/thumbnail-card.md) | | **Content** | Description List | [→ reference/description-list.md](reference/description-list.md) | +| **Content** | Data Table | [→ reference/data-table.md](reference/data-table.md) | | **Content** | Icon | [→ reference/icon.md](reference/icon.md) | | **Content** | Icon List | [→ reference/icon-list.md](reference/icon-list.md) | | **Content** | Table | [→ reference/table.md](reference/table.md) | diff --git a/skills/sgds-components/reference/data-table.md b/skills/sgds-components/reference/data-table.md new file mode 100644 index 000000000..ef3d5373e --- /dev/null +++ b/skills/sgds-components/reference/data-table.md @@ -0,0 +1,164 @@ +# SGDS Data Table Component Skill + +`` displays structured tabular data using composed row, header, and cell elements. +It supports client-side pagination, server-driven pagination, sorting headers, row selection, expandable rows, and server loading state. + +## Usage Guideline + +### When to use + +- When your data needs table semantics with SGDS-consistent interactions and styling. +- When you need sorting columns based on slotted cells or rowData keys. +- When you need server-driven paging with loading feedback. +- When rows require selection (checkboxes) or expandable detail panels. + +### When NOT to use + +- For very simple key-value presentation with no tabular interactions. +- For dense data visualisation where charts are more appropriate. + +## Quick Decision Guide + +- Static list with local pagination and sorting: use `mode="client"` (default). +- Data from API with backend pagination: use `mode="server"` and listen to `sgds-page-change`. +- Data from API with backend sorting: set `serverSort` and listen to `sgds-sort`. +- Need loading indicator while fetching server data: set `isLoading` to `true`. +- Need numeric columns aligned right: set `textAlign="right"` on the corresponding `sgds-data-table-head`. +- Need only selected columns to sort: set `sorting` only on those headers. +- Need default sort indicator on first render: set `ariasort` on a sortable header. + +```html + + + ID + Name + Role + Amount + + + 1 + Amy + Analyst + 125.00 + + +``` + +## API Summary + +### `` + +| Attribute | Type | Default | Purpose | +| --- | --- | --- | --- | +| `multiSelect` | boolean | `false` | Shows a checkbox column for row selection | +| `dataLength` | number | `0` | Total number of rows (especially for server mode) | +| `currentPage` | number | `1` | Current page number | +| `itemsPerPage` | number | `5` | Rows per page | +| `footerText` | string | `""` | Replaces default summary text in footer | +| `hideFooter` | boolean | `false` | Hides summary and pagination footer | +| `mode` | `"client" \| "server"` | `"client"` | Pagination mode | +| `isLoading` | boolean | `false` | Shows loading state in server mode | +| `serverSort` | boolean | `false` | Emits `sgds-sort` for external sorting instead of local sorting | + +### `` + +| Attribute | Type | Default | Purpose | +| --- | --- | --- | --- | +| `sorting` | boolean | `true` | Enables sort toggle on click | +| `sortKey` | string | `""` | Row data key used for sorting | +| `textAlign` | `"left" \| "right"` | `"left"` | Alignment for header and same body column | +| `width` | string | — | Column width | +| `colspan` | number | — | Number of columns spanned | +| `rowspan` | number | — | Number of rows spanned | + +## Slots + +### Data Table Slots + +| Slot | Purpose | +| --- | --- | +| default | Accepts `sgds-data-table-row` entries for header and body rows | + +### Data Table Row Slots + +| Slot | Purpose | +| --- | --- | +| default | Accepts `sgds-data-table-head` and `sgds-data-table-cell` | +| `content` | Content displayed in expanded detail row | + +## Events + +| Event | When | +| --- | --- | +| `sgds-page-change` | Pagination page changes | +| `sgds-row-select` | Selection state changes for row checkboxes | +| `sgds-sort` | A sorting header changes sort direction (emitted by `sgds-data-table` when `serverSort` is true) | +| `sgds-show` | Expandable row starts opening | +| `sgds-after-show` | Expandable row finishes opening | +| `sgds-hide` | Expandable row starts closing | +| `sgds-after-hide` | Expandable row finishes closing | + +## Sorting Behavior Notes + +- In client mode, sorting affects only currently visible rows. +- When a sort cycles back to `none`, row order reverts to the initial slotted order captured at first non-server load. +- Sort controls are disabled when there are no body rows. + +## Server Mode Example + +```html + + + ID + Name + + + + +``` + +--- + +**For AI agents**: + +1. Always provide `dataLength`, `currentPage`, and `itemsPerPage` together for predictable pagination. +2. In server mode, manage `isLoading` around fetch calls and update row markup externally. +3. Use `textAlign` on header cells only; body alignment is inherited by column. +4. Keep non-sorting control columns (expand/checkbox) non-sorting. +5. Prefer setting `sortKey` when rowData is provided for stable sorting behavior. diff --git a/src/components/Checkbox/sgds-checkbox.ts b/src/components/Checkbox/sgds-checkbox.ts index 3bba01014..eb028aba9 100644 --- a/src/components/Checkbox/sgds-checkbox.ts +++ b/src/components/Checkbox/sgds-checkbox.ts @@ -9,6 +9,7 @@ import { defaultValue } from "../../utils/defaultvalue"; import { SgdsFormControl } from "../../utils/formSubmitController"; import { SgdsFormValidatorMixin } from "../../utils/validatorMixin"; import { watch } from "../../utils/watch"; +import { HasSlotController } from "../../utils/slot"; import checkboxStyle from "./checkbox.css"; import formCheckStyles from "../../styles/form-check.css"; @@ -28,6 +29,8 @@ import formCheckStyles from "../../styles/form-check.css"; export class SgdsCheckbox extends SgdsFormValidatorMixin(FormControlElement) implements SgdsFormControl { static styles = [...FormControlElement.styles, formCheckStyles, checkboxStyle]; + private readonly hasSlotController = new HasSlotController(this, "[default]"); + /**@internal */ static dependencies = { "sgds-icon": SgdsIcon @@ -183,6 +186,7 @@ export class SgdsCheckbox extends SgdsFormValidatorMixin(FormControlElement) imp render() { const displayFeedbackStyle = this.hasFeedback === "both" || this.hasFeedback === "style"; const displayFeedbackText = this.hasFeedback === "both" || this.hasFeedback === "text"; + const hasDefaultSlot = this.hasSlotController.test("[default]"); return html`
@@ -208,7 +212,9 @@ export class SgdsCheckbox extends SgdsFormValidatorMixin(FormControlElement) imp @blur=${this._handleBlur} @focus=${this._handleFocus} /> - + ${hasDefaultSlot + ? html`` + : nothing}
${displayFeedbackText && this.invalid ? html` diff --git a/src/components/DataTable/data-table.css b/src/components/DataTable/data-table.css new file mode 100644 index 000000000..cfe09b85e --- /dev/null +++ b/src/components/DataTable/data-table.css @@ -0,0 +1,178 @@ +/* shared default — overridden per component below */ +:host { + display: contents; +} + +/* ── sgds-data-table ──────────────────────────────────────────── */ +:host([layout="fixed"]) .table { + table-layout: fixed; +} + +.table { + display: table; + width: 100%; + table-layout: auto; + border-collapse: collapse; +} + +.data-table { + display: flex; + flex-direction: column; + min-height: var(--sgds-dimension-56); + border: var(--sgds-border-width-1) solid; + border-color: var(--sgds-border-color-muted); + border-radius: var(--sgds-border-radius-md); +} + +.footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--sgds-padding-sm) var(--sgds-padding-lg); +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + min-height: var(--sgds-dimension-128); + padding: var(--sgds-padding-sm) var(--sgds-padding-md); + border-top: var(--sgds-border-width-1) solid var(--sgds-border-color-muted); +} + +.loading .loading-menu { + display: flex; + gap: var(--sgds-gap-xs); + align-items: center; + color: var(--sgds-text-color-subtle); + pointer-events: none; +} + +.no-data { + display: flex; + align-items: center; + justify-content: center; + min-height: var(--sgds-dimension-128); + padding: var(--sgds-padding-sm) var(--sgds-padding-md); + color: var(--sgds-text-color-subtle); + border-bottom: var(--sgds-border-width-1) solid var(--sgds-border-color-muted); +} + +/* hover / expanded states — set CSS custom property on the row host, + cells inherit it across the shadow boundary */ +tr:hover:not([data-header-row]), +.active:not(.sort-icon) { + background-color: var(--sgds-bg-translucent-subtle); + transition: background-color 0.15s ease; +} + +/* ── sgds-data-table-cell / sgds-data-table-head ─────────────── */ +td, +th { + vertical-align: middle; + border-bottom: var(--sgds-border-width-1) solid var(--sgds-border-color-muted); +} + +.data-table-cell, +.data-table-head { + display: flex; + align-items: center; + min-height: var(--sgds-dimension-56); + padding: var(--sgds-padding-sm) var(--sgds-padding-md); +} + +.data-table-head { + font-weight: var(--sgds-font-weight-semibold); + gap: var(--sgds-gap-2-xs); +} + +.sort { + display: inline-flex; + align-items: center; +} + +.sort-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--sgds-dimension-24); + height: var(--sgds-dimension-24); + padding: 0; + border: var(--sgds-border-width-1) solid var(--sgds-border-color-transparent); + border-radius: var(--sgds-form-border-radius-md); + background-color: var(--sgds-bg-transparent); + color: var(--sgds-color-subtle); + cursor: pointer; + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; +} + +.sort-button:hover { + background-color: var(--sgds-bg-translucent-subtle); + color: var(--sgds-color-default); +} + +.sort-button:focus, +.sort-button:focus-visible { + background-color: var(--sgds-bg-translucent-subtle); + border-color: transparent; + outline: var(--sgds-outline-focus); + outline-offset: var(--sgds-outline-offset-focus); +} + +.sort-button:disabled { + background-color: var(--sgds-bg-transparent); + color: var(--sgds-color-muted); + cursor: not-allowed; +} + +.sort-button.active { + background-color: var(--sgds-primary-surface-translucent); + color: var(--sgds-primary-color-default); +} + +.sort-icon { + display: inline-flex; +} + +.data-table-head.align-right { + justify-content: flex-end; + text-align: right; +} + +.data-table-cell.align-right { + justify-content: flex-end; + text-align: right; +} + +/* control cell variants — applied as classes on the host */ +.control-cell { + width: var(--sgds-dimension-56); +} + +:host(.checkbox-cell) .data-table-cell, +:host(.checkbox-cell) .data-table-head { + padding: var(--sgds-padding-sm) var(--sgds-padding-md); +} + +:host(.expand-cell) .data-table-cell { + padding: var(--sgds-padding-md); + cursor: pointer; +} + +/* ── expandable content row ───────────────────────────────────── */ +.expandable-td { + padding: 0; + border: 0; +} + +.expandable-content { + background-color: var(--sgds-surface-raised); + padding: var(--sgds-padding-2-xl); + padding-left: var(--sgds-padding-4-xl); + border-bottom: var(--sgds-border-width-1) solid var(--sgds-border-color-muted); +} + +.hidden { + display: none; +} diff --git a/src/components/DataTable/index.ts b/src/components/DataTable/index.ts new file mode 100644 index 000000000..824f479c2 --- /dev/null +++ b/src/components/DataTable/index.ts @@ -0,0 +1,19 @@ +import { SgdsDataTable } from "./sgds-data-table"; +import SgdsDataTableRow from "./sgds-data-table-row"; +import SgdsDataTableCell from "./sgds-data-table-cell"; +import SgdsDataTableHead from "./sgds-data-table-head"; +import { register } from "../../utils/ce-registry"; + +register("sgds-data-table", SgdsDataTable); +register("sgds-data-table-row", SgdsDataTableRow); +register("sgds-data-table-cell", SgdsDataTableCell); +register("sgds-data-table-head", SgdsDataTableHead); + +declare global { + interface HTMLElementTagNameMap { + "sgds-data-table": SgdsDataTable; + "sgds-data-table-row": SgdsDataTableRow; + "sgds-data-table-cell": SgdsDataTableCell; + "sgds-data-table-head": SgdsDataTableHead; + } +} diff --git a/src/components/DataTable/sgds-data-table-cell.ts b/src/components/DataTable/sgds-data-table-cell.ts new file mode 100644 index 000000000..b475e9e13 --- /dev/null +++ b/src/components/DataTable/sgds-data-table-cell.ts @@ -0,0 +1,31 @@ +import { css, html } from "lit"; +import SgdsElement from "../../base/sgds-element"; +import { property } from "lit/decorators.js"; + +/** + * @summary Data container for a table cell. Rendered as a `` by the parent `sgds-data-table-row`. + * + * @slot default - Content to display inside the cell. + */ +export class SgdsDataTableCell extends SgdsElement { + static styles = [ + ...SgdsElement.styles, + css` + :host { + display: contents; + } + ` + ]; + + /** Number of columns this cell spans. */ + @property({ type: Number, reflect: true }) colspan: number | undefined; + + /** Number of rows this cell spans. */ + @property({ type: Number, reflect: true }) rowspan: number | undefined; + + render() { + return html``; + } +} + +export default SgdsDataTableCell; diff --git a/src/components/DataTable/sgds-data-table-head.ts b/src/components/DataTable/sgds-data-table-head.ts new file mode 100644 index 000000000..39df6849e --- /dev/null +++ b/src/components/DataTable/sgds-data-table-head.ts @@ -0,0 +1,59 @@ +import { css, html } from "lit"; +import SgdsElement from "../../base/sgds-element"; +import { property } from "lit/decorators.js"; + +/** + * @summary Data container for a table header cell. Rendered as a `` by the parent `sgds-data-table-row`. + * + * @slot default - Content to display inside the header cell. + * + */ +export class SgdsDataTableHead extends SgdsElement { + static styles = [ + ...SgdsElement.styles, + css` + :host { + display: contents; + } + ` + ]; + + /** Sets the column width. */ + @property({ type: String, reflect: true }) width: string | undefined; + + /** Number of columns this cell spans. */ + @property({ type: Number, reflect: true }) colspan: number | undefined; + + /** Number of rows this cell spans. */ + @property({ type: Number, reflect: true }) rowspan: number | undefined; + + /** Text alignment for the header content. */ + @property({ type: String, reflect: true }) textAlign: "left" | "right" = "left"; + + /** Current sort direction for this column. */ + @property({ type: String, reflect: true }) ariasort: "ascending" | "descending" | "none" | "other" | undefined; + + /** When true, clicking this header cycles through ascending → descending → none sort. */ + @property({ type: Boolean, reflect: true }) sorting = false; + + /** Column key emitted in the row's internal sort payload, used to identify which column to sort. */ + @property({ type: String, reflect: true }) sortKey = ""; + + /** @internal — called by the row when the rendered `` is clicked. */ + handleSortClick() { + if (!this.sorting) return; + const next = this.ariasort === "ascending" ? "descending" : this.ariasort === "descending" ? "none" : "ascending"; + this.ariasort = next; + this.emit("i-sgds-sort", { + detail: { key: this.sortKey, direction: next }, + bubbles: true, + composed: true + }); + } + + render() { + return html``; + } +} + +export default SgdsDataTableHead; diff --git a/src/components/DataTable/sgds-data-table-row.ts b/src/components/DataTable/sgds-data-table-row.ts new file mode 100644 index 000000000..7de93b262 --- /dev/null +++ b/src/components/DataTable/sgds-data-table-row.ts @@ -0,0 +1,334 @@ +import { html, nothing } from "lit"; +import { property, query, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import SgdsElement from "../../base/sgds-element"; +import { SgdsCheckbox } from "../Checkbox/sgds-checkbox"; +import { animateTo, shimKeyframesHeightAuto, stopAnimations } from "../../utils/animate"; +import { getAnimation, setDefaultAnimation } from "../../utils/animation-registry"; +import { waitForEvent } from "../../utils/event"; +import { watch } from "../../utils/watch"; +import tableRowStyle from "./data-table.css"; +import SgdsDataTableCell from "./sgds-data-table-cell"; +import SgdsDataTableHead from "./sgds-data-table-head"; +import { SgdsIcon } from "../Icon/sgds-icon"; + +/** + * @summary A data table row. Set `expand` to enable an expandable content area beneath + * the row, then place detail content in the `content` slot. + * + * @slot default - Insert `sgds-data-table-cell` or `sgds-data-table-head` elements. + * @slot content - Content shown in the animated expand area beneath the row. + * + * @event sgds-show - Emitted when the expandable row begins to open. + * @event sgds-after-show - Emitted after the expandable row open animation completes. + * @event sgds-hide - Emitted when the expandable row begins to close. + * @event sgds-after-hide - Emitted after the expandable row close animation completes. + */ +export class SgdsDataTableRow extends SgdsElement { + static styles = [...SgdsElement.styles, tableRowStyle]; + + /** @internal */ + static dependencies = { + "sgds-checkbox": SgdsCheckbox, + "sgds-data-table-cell": SgdsDataTableCell, + "sgds-data-table-head": SgdsDataTableHead, + "sgds-icon": SgdsIcon + }; + + /** Arbitrary data associated with this row. Returned in event detail on row selection. */ + @property({ type: Object, reflect: true }) rowData: Record = {}; + + /** When true, the row has an expandable content area toggled by a chevron. */ + @property({ type: Boolean, reflect: true }) expand = false; + + /** When true, the expandable content area is open. */ + @property({ type: Boolean, reflect: true }) open = false; + + /** When true, the row is checked. */ + @property({ type: Boolean, reflect: true }) checked = false; + + /** @internal — set by `sgds-data-table` to show a checkbox cell on this row. */ + @property({ type: Boolean }) showCheckbox = false; + + /** @internal — set by `sgds-data-table` when at least one sibling row is expandable. */ + @property({ type: Boolean }) showExpandPlaceholder = false; + + /** @internal — per-column alignment injected by `sgds-data-table` from header cells. */ + @property({ attribute: false }) columnAlignments: Array<"left" | "right"> = []; + /** @internal — set by `sgds-data-table` to indicate whether body rows exist. */ + @property({ type: Boolean }) hasDataRows = false; + + /** @internal */ + @query(".expandable-body") private _expandableBody!: HTMLElement; + + /** @internal */ + @query("sgds-checkbox") private _checkboxEl!: SgdsCheckbox; + + @state() private _isHeaderRow = false; + private _cellSlotNames = new WeakMap(); + private _lightDomObserver?: MutationObserver; + + /** The checkbox rendered inside this row, if `showCheckbox` is true. */ + get checkbox(): SgdsCheckbox | null { + return this._checkboxEl ?? null; + } + + connectedCallback() { + super.connectedCallback(); + this._lightDomObserver = new MutationObserver(() => this._handleLightDomChange()); + this._lightDomObserver.observe(this, { childList: true }); + this._handleLightDomChange(); + } + + disconnectedCallback() { + this._lightDomObserver?.disconnect(); + super.disconnectedCallback(); + } + + firstUpdated() { + if (this._expandableBody) { + this._expandableBody.hidden = !this.open; + } + } + + @watch("open", { waitUntilFirstUpdate: true }) + async handleExpandedChange() { + if (this.open) { + const sgdsShow = this.emit("sgds-show", { cancelable: true }); + if (sgdsShow.defaultPrevented) { + this.open = false; + return; + } + await stopAnimations(this._expandableBody); + this._expandableBody.hidden = false; + const { keyframes, options } = getAnimation(this, "row.expand.show"); + await animateTo( + this._expandableBody, + shimKeyframesHeightAuto(keyframes, this._expandableBody.scrollHeight), + options + ); + this.emit("sgds-after-show"); + } else { + const sgdsHide = this.emit("sgds-hide", { cancelable: true }); + if (sgdsHide.defaultPrevented) { + this.open = true; + return; + } + await stopAnimations(this._expandableBody); + const { keyframes, options } = getAnimation(this, "row.expand.hide"); + await animateTo( + this._expandableBody, + shimKeyframesHeightAuto(keyframes, this._expandableBody.scrollHeight), + options + ); + this._expandableBody.hidden = true; + this.emit("sgds-after-hide"); + } + } + + @watch("checked") + async handleCheckedChange() { + if (this.checked) { + this.emit("i-sgds-change", { detail: { checked: true } }); + } + } + + /** Opens the expandable content area. */ + public async show() { + if (this.open) return; + this.open = true; + return waitForEvent(this, "sgds-after-show"); + } + + /** Closes the expandable content area. */ + public async hide() { + if (!this.open) return; + this.open = false; + return waitForEvent(this, "sgds-after-hide"); + } + + /** @internal — returns visible text content for a body cell by visual column index. */ + public getCellText(columnIndex: number) { + const cells = this._getCells().filter(cell => cell instanceof SgdsDataTableCell) as SgdsDataTableCell[]; + const cell = cells[columnIndex]; + if (!cell) return ""; + return (cell.textContent ?? "").trim(); + } + + private _getCells() { + return Array.from(this.children).filter( + child => child instanceof SgdsDataTableCell || child instanceof SgdsDataTableHead + ) as Array; + } + + private _syncCellSlots(cells: Array) { + cells.forEach(cell => { + const index = cells.indexOf(cell); + const slotName = `cell-${index}`; + this._cellSlotNames.set(cell, slotName); + if (cell.getAttribute("slot") !== slotName) { + cell.setAttribute("slot", slotName); + } + }); + } + + private _handleLightDomChange() { + const cells = this._getCells(); + this._syncCellSlots(cells); + this._isHeaderRow = cells.some(cell => cell instanceof SgdsDataTableHead); + this.requestUpdate(); + } + + private _onCheckboxChange(e: CustomEvent) { + this.emit("i-sgds-change", { detail: { checked: (e.target as SgdsCheckbox).checked } }); + } + + private _toggleExpand() { + this.open ? this.hide() : this.show(); + } + + private _onExpandKeyDown(e: KeyboardEvent) { + if (e.key !== "Enter") return; + e.preventDefault(); + this._toggleExpand(); + } + + private _onSortKeyDown(e: KeyboardEvent, header: SgdsDataTableHead) { + if (e.key !== "Enter") return; + e.preventDefault(); + header.handleSortClick(); + } + + private _renderCell(el: SgdsDataTableCell | SgdsDataTableHead, columnIndex: number) { + const slotName = this._cellSlotNames.get(el); + + if (el instanceof SgdsDataTableHead) { + return html` el.handleSortClick() : nothing} + @keydown=${el.sorting ? (e: KeyboardEvent) => this._onSortKeyDown(e, el) : nothing} + > +
+ ${slotName ? html`` : nothing} + ${el.sorting + ? html` + + ` + : nothing} +
+ `; + } + + const cellAlignment = this.columnAlignments[columnIndex] ?? "left"; + return html` +
+ ${slotName ? html`` : nothing} +
+ `; + } + + private _renderExpandCell() { + if (this._isHeaderRow) { + return html``; + } + + return html` +
+ +
+ `; + } + + private _renderExpandPlaceholder() { + return this._isHeaderRow ? html`` : html``; + } + + private _renderCheckboxCell() { + return this._isHeaderRow + ? html` +
+ +
+ ` + : html` +
+ +
+ `; + } + + render() { + const cells = this._getCells(); + const totalCols = cells.length + (this.showCheckbox ? 1 : 0) + (this.expand || this.showExpandPlaceholder ? 1 : 0); + const rowPart = this._isHeaderRow ? "row row-header" : "row row-body"; + + return html` + + ${this.expand + ? this._renderExpandCell() + : this.showExpandPlaceholder + ? this._renderExpandPlaceholder() + : nothing} + ${this.showCheckbox ? this._renderCheckboxCell() : nothing} + ${(() => { + let visualColumnIndex = 0; + return cells.map(el => { + const renderedCell = this._renderCell(el, visualColumnIndex); + const span = Number(el.colspan) > 0 ? Number(el.colspan) : 1; + visualColumnIndex += span; + return renderedCell; + }); + })()} + + + ${this.expand + ? html` + + +
+
+ +
+
+ + + ` + : nothing} + `; + } +} + +setDefaultAnimation("row.expand.show", { + keyframes: [ + { height: "0", opacity: "0" }, + { height: "auto", opacity: "1" } + ], + options: { duration: 250, easing: "ease-in-out" } +}); + +setDefaultAnimation("row.expand.hide", { + keyframes: [ + { height: "auto", opacity: "1" }, + { height: "0", opacity: "0" } + ], + options: { duration: 250, easing: "ease-in-out" } +}); + +export default SgdsDataTableRow; diff --git a/src/components/DataTable/sgds-data-table.ts b/src/components/DataTable/sgds-data-table.ts new file mode 100644 index 000000000..6ff5e741f --- /dev/null +++ b/src/components/DataTable/sgds-data-table.ts @@ -0,0 +1,428 @@ +import { css, html, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; +import SgdsElement from "../../base/sgds-element"; +import tableStyle from "./data-table.css"; +import SgdsPagination from "../Pagination/sgds-pagination"; +import type { ISgdsPaginationPageChangeEventDetail } from "../Pagination/sgds-pagination"; +import SgdsDataTableRow from "./sgds-data-table-row"; +import SgdsDataTableCell from "./sgds-data-table-cell"; +import SgdsDataTableHead from "./sgds-data-table-head"; +import SgdsSpinner from "../Spinner/sgds-spinner"; + +/** + * @summary A data table container with pagination, row selection, loading, and sorting support. + * + * @slot default - Insert one or more `sgds-data-table-row` elements. + * @slot no-data - Custom content rendered when no body rows are available and `isLoading` is false. + * + * @event sgds-row-select - Emitted when row checkboxes change. Detail: `{ selected: rowData[] }`. + * @event sgds-sort - Emitted when `serverSort` is true and a sorting column header is clicked. + * Detail: `{ key: string; direction: "ascending" | "descending" | "none" }`. + * @event sgds-page-change - Emitted when pagination changes page. + */ +export class SgdsDataTable extends SgdsElement { + static styles = [ + ...SgdsElement.styles, + tableStyle, + css` + :host { + display: block; + } + ` + ]; + + /** @internal */ + static dependencies = { + "sgds-pagination": SgdsPagination, + "sgds-data-table-cell": SgdsDataTableCell, + "sgds-data-table-head": SgdsDataTableHead, + "sgds-spinner": SgdsSpinner + }; + + /** If true, renders a checkbox column for row selection. */ + @property({ type: Boolean }) multiSelect = false; + + /** Total number of data items (for server-side pagination). */ + @property({ type: Number }) dataLength = 0; + + /** Current page (1-based). */ + @property({ type: Number }) currentPage = 1; + + /** Number of rows per page. */ + @property({ type: Number }) itemsPerPage = 5; + + /** Custom text shown on the footer's left side. */ + @property({ type: String }) footerText = ""; + + /** If true, hides the footer (summary text and pagination controls). */ + @property({ type: Boolean }) hideFooter = false; + + /** When true, shows a loading state and hides body rows. */ + @property({ type: Boolean }) isLoading = false; + + /** When true, emits sort events for external handling instead of local sorting. */ + @property({ type: Boolean }) serverSort = false; + + /** + * Controls the CSS `table-layout` algorithm. + * Use "auto" to let the browser size columns based on content, or "fixed" to distribute column widths evenly regardless of content. + * @type {"auto" | "fixed"} + * @default "auto" + */ + @property({ type: String, reflect: true }) layout: "auto" | "fixed" = "auto"; + + /** + * Pagination mode. `client` (default) slices rows and renders pagination controls. + * `server` renders all slotted rows as-is — the caller is responsible for passing + * the correct page of data and handling `sgds-page-change` externally. + */ + @property({ type: String }) mode: "client" | "server" = "client"; + + @state() private tableRows: SgdsDataTableRow[] = []; + @state() private headerRows: SgdsDataTableRow[] = []; + + private _headerCells: SgdsDataTableHead[] = []; + private _initialRowPositions = new WeakMap(); + private _hasCapturedInitialRowPositions = false; + + private _rowHandlers = new WeakMap(); + + connectedCallback() { + super.connectedCallback(); + this.setAttribute("role", "table"); + } + + private _isRowChecked(row: SgdsDataTableRow) { + return row.checked || row.checkbox?.checked === true; + } + + private _updateHeaderCheckbox() { + const checkedCount = this.tableRows.filter(r => this._isRowChecked(r)).length; + + const allChecked = checkedCount === this.tableRows.length && checkedCount > 0; + + this.headerRows.forEach(r => { + r.checked = allChecked; + if (r.checkbox) { + r.checkbox.checked = allChecked; + r.checkbox.indeterminate = checkedCount > 0 && !allChecked; + } + }); + } + + private async _syncHeaderCheckboxAfterRender() { + if (!this.multiSelect || this.headerRows.length === 0) return; + + const rows = [...this.headerRows, ...this.tableRows]; + await Promise.all(rows.map(row => row.updateComplete)); + this._updateHeaderCheckbox(); + } + + private _emitRowSelect() { + const selected = this.tableRows.filter(r => this._isRowChecked(r)).map(r => r.rowData); + this.emit("sgds-row-select", { detail: { selected } }); + } + + private _attachRowListener(row: SgdsDataTableRow) { + const old = this._rowHandlers.get(row); + if (old) row.removeEventListener("i-sgds-change", old); + + const handler: EventListener = (e: Event) => { + const { checked } = (e as CustomEvent<{ checked: boolean }>).detail; + + if (this.headerRows.includes(row)) { + this.tableRows.forEach(r => { + r.checked = checked; + if (r.checkbox) r.checkbox.checked = checked; + }); + } else { + row.checked = checked; + this._updateHeaderCheckbox(); + } + this._emitRowSelect(); + }; + + this._rowHandlers.set(row, handler); + row.addEventListener("i-sgds-change", handler); + } + + private _configureRows() { + const hasExpand = this.tableRows.some(r => r.expand); + const hasDataRows = this.tableRows.length > 0; + + this.headerRows.forEach(row => { + row.showCheckbox = this.multiSelect; + row.showExpandPlaceholder = hasExpand; + row.hasDataRows = hasDataRows; + if (this.multiSelect) this._attachRowListener(row); + }); + + this.tableRows.forEach(row => { + row.showCheckbox = this.multiSelect; + row.showExpandPlaceholder = !row.expand && hasExpand; + if (this.multiSelect) this._attachRowListener(row); + }); + + if (this.multiSelect) { + this._syncHeaderCheckboxAfterRender(); + } + + this._applyColumnAlignment(); + } + + private _applyColumnAlignment() { + const headerAlignments = this._headerCells.reduce>((acc, header) => { + const span = Number(header.colspan) > 0 ? Number(header.colspan) : 1; + const alignment = header.textAlign ?? "left"; + for (let i = 0; i < span; i++) { + acc.push(alignment); + } + return acc; + }, []); + + this.headerRows.forEach(row => { + row.columnAlignments = [...headerAlignments]; + }); + + this.tableRows.forEach(row => { + row.columnAlignments = [...headerAlignments]; + }); + } + + private _extractCellText(row: SgdsDataTableRow, columnIndex: number) { + const rowCellText = row.getCellText(columnIndex); + if (rowCellText) return rowCellText; + + const cells = Array.from(row.children).filter(child => child.tagName.toLowerCase() === "sgds-data-table-cell"); + const cell = cells[columnIndex]; + return (cell?.textContent || "").trim(); + } + + private _toComparableValue(row: SgdsDataTableRow, key: string, columnIndex: number) { + if (key && row.rowData && key in row.rowData) { + return row.rowData[key]; + } + + return this._extractCellText(row, columnIndex); + } + + private _compareValues(left: unknown, right: unknown, direction: "ascending" | "descending") { + if (left === right) return 0; + + const leftNumber = Number(left); + const rightNumber = Number(right); + const bothNumeric = Number.isFinite(leftNumber) && Number.isFinite(rightNumber); + + if (bothNumeric) { + return direction === "ascending" ? leftNumber - rightNumber : rightNumber - leftNumber; + } + + const cmp = String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: "base" }); + return direction === "ascending" ? cmp : -cmp; + } + + private _resetOtherHeaderSortStates(activeHeader: SgdsDataTableHead | null) { + this._headerCells.forEach(header => { + if (header !== activeHeader && header.sorting) { + header.ariasort = "none"; + } + }); + } + + private _getRowsInInitialPositionOrder(rows: SgdsDataTableRow[]) { + return [...rows].sort((left, right) => { + const leftPosition = this._initialRowPositions.get(left); + const rightPosition = this._initialRowPositions.get(right); + + if (leftPosition === undefined && rightPosition === undefined) return 0; + if (leftPosition === undefined) return 1; + if (rightPosition === undefined) return -1; + return leftPosition - rightPosition; + }); + } + + private _captureInitialRowPositions(rows: SgdsDataTableRow[]) { + if (this._hasCapturedInitialRowPositions) return; + + rows.forEach((row, index) => { + this._initialRowPositions.set(row, index); + }); + + this._hasCapturedInitialRowPositions = true; + } + + private _handleSort(e: Event) { + const activeHeader = e.composedPath().find(node => node instanceof SgdsDataTableHead) as + | SgdsDataTableHead + | undefined; + + if (!activeHeader || !activeHeader.sorting) return; + + const { key, direction } = (e as CustomEvent<{ key: string; direction: "ascending" | "descending" | "none" }>) + .detail; + const columnIndex = activeHeader ? this._headerCells.indexOf(activeHeader) : -1; + + this._resetOtherHeaderSortStates(activeHeader); + + if (this.serverSort) { + e.stopPropagation(); + this.emit("sgds-sort", { detail: { key, direction } }); + return; + } + + if (columnIndex < 0) return; + + if (direction === "none") { + this.tableRows = this._getRowsInInitialPositionOrder(this.tableRows); + this._syncDomRowOrder(); + this._updateVisibleRows(); + return; + } + + if (this.mode === "client") { + const start = (this.currentPage - 1) * this.itemsPerPage; + const end = start + this.itemsPerPage; + const nextRows = [...this.tableRows]; + + const sortedVisibleRows = nextRows.slice(start, end).sort((left, right) => { + const leftValue = this._toComparableValue(left, key, columnIndex); + const rightValue = this._toComparableValue(right, key, columnIndex); + return this._compareValues(leftValue, rightValue, direction); + }); + nextRows.splice(start, sortedVisibleRows.length, ...sortedVisibleRows); + + this.tableRows = nextRows; + this._syncDomRowOrder(); + this._updateVisibleRows(); + return; + } + + this.tableRows = [...this.tableRows].sort((left, right) => { + const leftValue = this._toComparableValue(left, key, columnIndex); + const rightValue = this._toComparableValue(right, key, columnIndex); + return this._compareValues(leftValue, rightValue, direction); + }); + + this._syncDomRowOrder(); + this._updateVisibleRows(); + } + + private _syncDomRowOrder() { + const parent = this.tableRows[0]?.parentElement; + if (!parent) return; + this.tableRows.forEach(row => parent.appendChild(row)); + } + + private _handleSlotChange(e: Event) { + const slotted = (e.target as HTMLSlotElement) + .assignedElements({ flatten: true }) + .filter(el => el instanceof SgdsDataTableRow) as SgdsDataTableRow[]; + + const rowTableChildren = (row: SgdsDataTableRow) => + Array.from(row.children).filter(child => { + const tagName = child.tagName.toLowerCase(); + const isTableCell = tagName === "sgds-data-table-cell" || tagName === "sgds-data-table-head"; + if (!isTableCell) return false; + return child.getAttribute("slot") !== "content"; + }); + + this.headerRows = slotted.filter(row => + rowTableChildren(row).some(child => child.tagName.toLowerCase() === "sgds-data-table-head") + ); + + this.tableRows = slotted.filter(row => { + const children = rowTableChildren(row); + return children.length > 0 && children.every(child => child.tagName.toLowerCase() === "sgds-data-table-cell"); + }); + + this._headerCells = this.headerRows.reduce((acc, row) => { + const headers = Array.from(row.children).filter( + child => child instanceof SgdsDataTableHead + ) as SgdsDataTableHead[]; + acc.push(...headers); + return acc; + }, []); + + this._captureInitialRowPositions(this.tableRows); + + this._headerCells.forEach(header => { + if (header.sorting && !header.ariasort) { + header.ariasort = "none"; + } + }); + + this._configureRows(); + this._updateVisibleRows(); + } + + private _updateVisibleRows() { + if (this.mode === "server") { + this.tableRows.forEach(row => (row.style.display = this.isLoading ? "none" : "")); + return; + } + + const total = this.dataLength || this.tableRows.length; + const lastPage = Math.max(1, Math.ceil(total / this.itemsPerPage)); + if (this.currentPage > lastPage) { + this.currentPage = lastPage; + return; + } + const start = (this.currentPage - 1) * this.itemsPerPage; + const visible = new Set(this.tableRows.slice(start, start + this.itemsPerPage)); + this.tableRows.forEach(row => (row.style.display = visible.has(row) ? "" : "none")); + } + + updated(changed: Map) { + if (changed.has("currentPage") || changed.has("itemsPerPage") || changed.has("mode") || changed.has("isLoading")) { + this._updateVisibleRows(); + } + if (changed.has("multiSelect")) { + this._configureRows(); + } + + if (this.multiSelect && (changed.has("tableRows") || changed.has("headerRows"))) { + this._syncHeaderCheckboxAfterRender(); + } + } + + render() { + const total = this.dataLength || this.tableRows.length; + const start = (this.currentPage - 1) * this.itemsPerPage; + const end = Math.min(start + this.itemsPerPage, total); + const displayStart = total === 0 ? 0 : start + 1; + const showNoData = !this.isLoading && this.tableRows.length === 0; + + return html` +
+
+ + ${this.isLoading + ? html`
+
Loading...
+
` + : showNoData + ? html`No data` + : nothing} +
+ + ${this.hideFooter + ? nothing + : html``} +
+ `; + } +} + +export default SgdsDataTable; diff --git a/src/components/index.ts b/src/components/index.ts index c9452265a..f1fc34250 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -12,6 +12,10 @@ export * from "./Checkbox/sgds-checkbox-group"; export * from "./CloseButton/sgds-close-button"; export * from "./ComboBox/sgds-combo-box"; export * from "./ComboBox/sgds-combo-box-option"; +export * from "./DataTable/sgds-data-table"; +export * from "./DataTable/sgds-data-table-cell"; +export * from "./DataTable/sgds-data-table-head"; +export * from "./DataTable/sgds-data-table-row"; export * from "./Datepicker/sgds-datepicker"; export * from "./DescriptionList/sgds-description-list"; export * from "./DescriptionList/sgds-description-list-group"; diff --git a/src/index.ts b/src/index.ts index 5f5932708..1e9abda30 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,4 +45,5 @@ import "./components/ImageCard"; import "./components/ThumbnailCard"; import "./components/IconCard"; import "./components/Sidebar"; +import "./components/DataTable"; // COMPONENT IMPORTS diff --git a/stories/component-templates/DataTable/additional.mdx b/stories/component-templates/DataTable/additional.mdx new file mode 100644 index 000000000..f9d63fdac --- /dev/null +++ b/stories/component-templates/DataTable/additional.mdx @@ -0,0 +1,120 @@ +## Basic content examples + +The basic table story uses static cell content patterns without event handlers: +`sgds-badge` for status, a combined controls column with `sgds-combo-box` and `sgds-input` +(plus a small `sgds-button` with `size="sm"`), and a small `sgds-overflow-menu`. + + + + + +## Server Loading + +Use `mode="server"` with `isLoading` to show the loading state while waiting for remote data. +For backend sorting, set `serverSort` on `` and handle the emitted +`sgds-sort` event in your application. + + + + + +## No rows + +Use a header-only table when data is unavailable. The component shows the default no-data state. + + + + + +## Multi-select + +Set `multiSelect` to render a checkbox column for row selection. + + + + + +## Multi-select pre-checked on load + +Set `checked` on body rows to preselect them on initial render. +The header checkbox state is computed on load and shows indeterminate or checked accordingly. + + + + + +## Expandable rows + +Set `expand` on a row and provide content in the `content` slot. +Set `open` if a row should start expanded. +This example uses static `sgds-table` samples inside expandable content, showing three rows in the first expandable section and two rows in the second. + + + + + +## Expandable with multi-select + +Use `expand` and `multiSelect` together when rows need both selection and detail content. + + + + + +## Custom footer text + +Set `footerText` to replace the default results summary text in the footer. + + + + + +## Hide footer + +Set `hideFooter` to hide both the results summary and pagination controls. + + + + + +## Header Text Alignment + +Use `textAlign="right"` on `` to right-align that header and all body cells in the same column. + + + + + +## Header and cell props + +Use `sorting`, `sortKey`, `width`, and `textAlign` on header cells. +This example keeps all cells unmerged to show a standard sortable fixed-layout table. +Use the row colspan example below when rows need merged columns. + +Only headers with `sorting` participate in sort interactions. +When the sort direction cycles back to `none`, client-mode rows return to their initial slotted order. + + + + + +## Default sort + +Set `ariasort` on a sortable header to present an initial sort state on first render. + + + + + +## Sorting with no rows + +When there are no body rows, sort controls are disabled. + +## Row colspan example (5 by 5) + +This table has five columns and five data rows. +Rows two and four use `colspan="2"` to merge the item and category columns. + + + + diff --git a/stories/component-templates/DataTable/additional.stories.js b/stories/component-templates/DataTable/additional.stories.js new file mode 100644 index 000000000..f750afd3f --- /dev/null +++ b/stories/component-templates/DataTable/additional.stories.js @@ -0,0 +1,472 @@ +import { html } from "lit"; + +const ServerLoadingTemplate = () => html` + + + ID + Name + Amount + + + 1 + Citizen 1 + 42 + + +`; + +const NoRowsTemplate = () => html` + + + # + First name + Last name + Username + + +`; + +const MultiSelectTemplate = () => html` + + + # + First name + Last name + Username + + + 1 + John + Doe + @johndoe + + + 2 + Jane + Doe + @janedoe + + + 3 + Bob + Smith + @bobsmith + + +`; + +const MultiSelectPrecheckedOnLoadTemplate = () => html` + + + # + First name + Last name + Username + + + 1 + John + Doe + @johndoe + + + 2 + Jane + Doe + @janedoe + + + 3 + Bob + Smith + @bobsmith + + +`; + +const ExpandableRowsTemplate = () => html` + + + # + First name + Last name + Username + + + 1 + John + Doe + @johndoe +
+
+ + + Case ID + Owner + Category + Amount + Status + + + + OPS-2026-001 + Amy Tan + Operations + 12,450.00 + Review + + + + FIN-2026-014 + Ben Ho + Finance + 3,180.00 + Approved + + + + SEC-2026-007 + Cara Lim + Security + 28,900.00 + Escalated + + +
+
+
+ + 2 + Jane + Doe + @janedoe +
+
+ + + Case ID + Owner + Category + Amount + Status + + + + OPS-2026-019 + Faye Low + Design + 4,560.00 + In Review + + + + OPS-2026-021 + Glen Neo + Operations + 2,330.00 + Completed + + +
+
+
+ + 3 + Bob + Smith + @bobsmith + +
+`; + +const ExpandableMultiSelectTemplate = () => html` + + + # + First name + Last name + Username + + + 1 + John + Doe + @johndoe +
+
Department: Engineering
+
+
+ + 2 + Jane + Doe + @janedoe + + + 3 + Bob + Smith + @bobsmith + +
+`; + +const CustomFooterTextTemplate = () => html` + + + ID + Name + + + 1 + Amy + + + 2 + Ben + + + 3 + Cara + + + 4 + Dan + + +`; + +const HideFooterTemplate = () => html` + + + ID + Status + + + 1001 + Pending + + + 1002 + Approved + + + 1003 + Rejected + + +`; + +const HeaderAndCellPropsTemplate = () => html` + + + ID + Name + Role + Score + + + 1 + Lina + Engineer + 82 + + + 2 + Adam + Engineer + 70 + + + 3 + Nora + Manager + 85 + + +`; + +const DefaultSortTemplate = () => html` + + + ID + Name + Role + + + 2 + Alice + Engineer + + + 4 + Ben + Analyst + + + 1 + Chloe + Manager + + + 3 + Darren + Designer + + +`; + +const RowColspanFiveByFiveTemplate = () => html` + + + ID + Item + Category + Status + Amount + + + 1 + Application fee + Service + Paid + 120.00 + + + 2 + Support package (category merged) + Pending + 240.00 + + + 3 + Licence renewal + Regulatory + Paid + 320.00 + + + 4 + Bulk order adjustment (category merged) + Approved + 180.00 + + + 5 + Maintenance + Service + In progress + 95.00 + + +`; + +const HeaderAlignmentTemplate = () => html` + + + ID + Amount + + + 1 + 125.00 + + + 2 + 98.30 + + + 3 + 302.10 + + +`; + +export const ServerLoading = { + render: ServerLoadingTemplate.bind({}), + name: "Server loading", + args: {}, + parameters: {} +}; + +export const NoRows = { + render: NoRowsTemplate.bind({}), + name: "No rows", + args: {}, + parameters: {} +}; + +export const MultiSelect = { + render: MultiSelectTemplate.bind({}), + name: "Multi-select", + args: {}, + parameters: {} +}; + +export const MultiSelectPrecheckedOnLoad = { + render: MultiSelectPrecheckedOnLoadTemplate.bind({}), + name: "Multi-select pre-checked on load", + args: {}, + parameters: {} +}; + +export const ExpandableRows = { + render: ExpandableRowsTemplate.bind({}), + name: "Expandable rows", + args: {}, + parameters: {} +}; + +export const ExpandableMultiSelect = { + render: ExpandableMultiSelectTemplate.bind({}), + name: "Expandable with multi-select", + args: {}, + parameters: {} +}; + +export const CustomFooterText = { + render: CustomFooterTextTemplate.bind({}), + name: "Custom footer text", + args: {}, + parameters: {} +}; + +export const HideFooter = { + render: HideFooterTemplate.bind({}), + name: "Hide footer", + args: {}, + parameters: {} +}; + +export const HeaderColspanRowspan = { + render: HeaderAndCellPropsTemplate.bind({}), + name: "Header and cell props", + args: {}, + parameters: {} +}; + +export const DefaultSort = { + render: DefaultSortTemplate.bind({}), + name: "Default sort", + args: {}, + parameters: {} +}; + +export const RowColspanFiveByFive = { + render: RowColspanFiveByFiveTemplate.bind({}), + name: "Row colspan example (5 by 5)", + args: {}, + parameters: {} +}; + +export const HeaderTextAlignment = { + render: HeaderAlignmentTemplate.bind({}), + name: "Header text alignment", + args: {}, + parameters: {} +}; diff --git a/stories/component-templates/DataTable/basic.js b/stories/component-templates/DataTable/basic.js new file mode 100644 index 000000000..111fed385 --- /dev/null +++ b/stories/component-templates/DataTable/basic.js @@ -0,0 +1,80 @@ +import { html } from "lit"; + +const sampleRows = [ + { id: 1, name: "Amy", status: "Active", age: 28 }, + { id: 2, name: "Ben", status: "Pending", age: 35 }, + { id: 3, name: "Cara", status: "Inactive", age: 31 }, + { id: 4, name: "Dan", status: "Active", age: 26 }, + { id: 5, name: "Eli", status: "Pending", age: 40 }, + { id: 6, name: "Faye", status: "Inactive", age: 29 }, + { id: 7, name: "Gabe", status: "Active", age: 33 }, + { id: 8, name: "Hana", status: "Pending", age: 24 }, + { id: 9, name: "Ivan", status: "Inactive", age: 37 }, + { id: 10, name: "Jade", status: "Active", age: 27 }, + { id: 11, name: "Kai", status: "Pending", age: 30 }, + { id: 12, name: "Lena", status: "Inactive", age: 38 }, + { id: 13, name: "Milo", status: "Active", age: 25 }, + { id: 14, name: "Nora", status: "Pending", age: 34 }, + { id: 15, name: "Owen", status: "Inactive", age: 32 } +]; + +export const Template = args => html` + + + # + Name + Status + Age + Action + + ${sampleRows.map( + row => html` + + ${row.id} + ${row.name} + + ${row.status === "Active" + ? html`${row.status}` + : row.status === "Pending" + ? html`${row.status}` + : html`${row.status}`} + + ${row.age} + + + View + Edit + Delete + + + + ` + )} + +`; + +export const args = { + dataLength: 15, + itemsPerPage: 5, + currentPage: 1, + multiSelect: false, + mode: "client", + serverSort: false, + isLoading: false, + hideFooter: false, + footerText: "" +}; + +export const parameters = {}; + +export const play = undefined; diff --git a/test/a11y/axe-core/data-table.a11y.test.ts b/test/a11y/axe-core/data-table.a11y.test.ts new file mode 100644 index 000000000..babaaa231 --- /dev/null +++ b/test/a11y/axe-core/data-table.a11y.test.ts @@ -0,0 +1,453 @@ +import { html } from "lit"; +import { elementUpdated, expect, fixture } from "@open-wc/testing"; +import { SgdsDataTable } from "../../../src/components"; +import SgdsDataTableHead from "../../../src/components/DataTable/sgds-data-table-head"; +import "../../sgds-web-component"; + +describe("", () => { + it("projects slotted table cells through named slots in row shadow DOM", async () => { + const el = await fixture(html` + + + Name + + + + Action + + + + `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const bodyRow = slot.assignedElements({ flatten: true })[1] as HTMLElement; + const cellHost = bodyRow.querySelector("sgds-data-table-cell") as HTMLElement; + const projectedCellSlot = bodyRow.shadowRoot?.querySelector('slot[name="cell-0"]') as HTMLSlotElement; + + expect(projectedCellSlot).to.exist; + expect(projectedCellSlot.assignedElements({ flatten: true })[0]).to.equal(cellHost); + }); + + it("renders data rows and header row from slotted content", async () => { + const el = await fixture(html` + + + Name + Role + + + Alice + Admin + + + Bob + User + + + `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const rows = slot.assignedElements({ flatten: true }); + expect(rows).to.have.lengthOf(3); + }); + + it("sorts rows when each header is clicked", async () => { + const el = await fixture(html` + + + ID + Name + + + 2 + Charlie + + + 3 + Alice + + + 1 + Bob + + + `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const [headerRow] = slot.assignedElements({ flatten: true }); + const headerCells = headerRow.shadowRoot?.querySelectorAll("th[data-sorting]") || []; + + // Sort by Name ascending + (headerCells[1] as HTMLElement).click(); + await elementUpdated(el); + + const sortedByNameAsc = slot + .assignedElements({ flatten: true }) + .slice(1) + .map(row => row.querySelectorAll("sgds-data-table-cell")[1]?.textContent?.trim()); + expect(sortedByNameAsc).to.deep.equal(["Alice", "Bob", "Charlie"]); + + // Sort by ID ascending from another header + (headerCells[0] as HTMLElement).click(); + await elementUpdated(el); + + const sortedByIdAsc = slot + .assignedElements({ flatten: true }) + .slice(1) + .map(row => row.querySelectorAll("sgds-data-table-cell")[0]?.textContent?.trim()); + expect(sortedByIdAsc).to.deep.equal(["1", "2", "3"]); + }); + + it("sorts rows when Enter is pressed on a sorting header", async () => { + const el = await fixture(html` + + + ID + Name + + + 2 + Charlie + + + 3 + Alice + + + 1 + Bob + + + `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const [headerRow] = slot.assignedElements({ flatten: true }); + const headerCells = headerRow.shadowRoot?.querySelectorAll("th[data-sorting]") || []; + + (headerCells[1] as HTMLElement).dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await elementUpdated(el); + + const sortedByNameAsc = slot + .assignedElements({ flatten: true }) + .slice(1) + .map(row => row.querySelectorAll("sgds-data-table-cell")[1]?.textContent?.trim()); + expect(sortedByNameAsc).to.deep.equal(["Alice", "Bob", "Charlie"]); + }); + + it("sorts only currently visible rows in client mode", async () => { + const el = await fixture(html` + + + ID + Name + + + 2 + Charlie + + + 1 + Alice + + + 4 + Zed + + + 3 + Bob + + + `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const [headerRow] = slot.assignedElements({ flatten: true }); + const headerCells = headerRow.shadowRoot?.querySelectorAll("th[data-sorting]") || []; + + (headerCells[1] as HTMLElement).click(); + await elementUpdated(el); + + const rowIdsAfterSort = slot + .assignedElements({ flatten: true }) + .slice(1) + .map(row => row.querySelectorAll("sgds-data-table-cell")[0]?.textContent?.trim()); + + expect(rowIdsAfterSort).to.deep.equal(["1", "2", "4", "3"]); + }); + + it("ignores sgds-sort events that do not originate from table headers", async () => { + const el = await fixture(html` + + + ID + Name + + + 2 + Charlie + + + 3 + Alice + + + 1 + Bob + + + `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const initialOrder = slot + .assignedElements({ flatten: true }) + .slice(1) + .map(row => row.querySelectorAll("sgds-data-table-cell")[0]?.textContent?.trim()); + + const firstBodyRow = slot.assignedElements({ flatten: true })[1]; + firstBodyRow.dispatchEvent( + new CustomEvent("sgds-sort", { + detail: { key: "id", direction: "ascending" }, + bubbles: true, + composed: true + }) + ); + await elementUpdated(el); + + const orderAfterNonHeaderSort = slot + .assignedElements({ flatten: true }) + .slice(1) + .map(row => row.querySelectorAll("sgds-data-table-cell")[0]?.textContent?.trim()); + + expect(orderAfterNonHeaderSort).to.deep.equal(initialOrder); + }); + + it("keeps an expandable row open when open is true", async () => { + const el = await fixture(html` + + + ID + + + 1 +
Details
+
+
+ `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const row = slot.assignedElements({ flatten: true })[1] as HTMLElement & { open: boolean }; + const expandControl = row.shadowRoot?.querySelector("td.control-cell") as HTMLElement; + const renderedRow = row.shadowRoot?.querySelector("tr") as HTMLElement; + + expect(row.open).to.be.true; + expect(renderedRow.classList.contains("active")).to.be.true; + + expandControl.click(); + await elementUpdated(el); + + expect(row.open).to.be.false; + expect(renderedRow.classList.contains("active")).to.be.false; + }); + + it("toggles expandable row when pressing Enter on expand control", async () => { + const el = await fixture(html` + + + ID + + + 1 +
Details
+
+
+ `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const row = slot.assignedElements({ flatten: true })[1] as HTMLElement & { open: boolean }; + const expandControl = row.shadowRoot?.querySelector("td.control-cell") as HTMLElement; + + expect(row.open).to.be.false; + + expandControl.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await elementUpdated(row as Element); + + expect(row.open).to.be.true; + }); + + it("renders header control cells as th", async () => { + const el = await fixture(html` + + + ID + Name + + + 1 + Alice +
Details
+
+
+ `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const headerRow = slot.assignedElements({ flatten: true })[0] as HTMLElement; + const controlCells = Array.from(headerRow.shadowRoot?.querySelectorAll(".control-cell") ?? []); + + expect(controlCells.length).to.be.greaterThan(0); + expect(controlCells.every(cell => cell.tagName.toLowerCase() === "th")).to.be.true; + }); + + it("applies right alignment to all cells in a right-aligned column", async () => { + const el = await fixture(html` + + + Amount + + + 100 + + + `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const [headerRow] = slot.assignedElements({ flatten: true }); + const [bodyRow] = slot.assignedElements({ flatten: true }).slice(1); + const headerCellContent = headerRow.shadowRoot?.querySelector(".data-table-head"); + const bodyCellContent = bodyRow.shadowRoot?.querySelector(".data-table-cell"); + + expect(headerCellContent).to.exist; + expect(headerCellContent?.classList.contains("align-right")).to.be.true; + expect(bodyCellContent).to.exist; + expect(bodyCellContent?.classList.contains("align-right")).to.be.true; + }); + + it("shows loading state in server mode when isLoading is true", async () => { + const el = await fixture(html` + + + ID + + + 1 + + + `); + await elementUpdated(el); + + const loadingState = el.shadowRoot?.querySelector(".loading"); + const loadingMenu = el.shadowRoot?.querySelector(".loading-menu"); + const loadingSpinner = el.shadowRoot?.querySelector("sgds-spinner"); + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const bodyRows = slot.assignedElements({ flatten: true }).slice(1) as HTMLElement[]; + + expect(loadingState).to.exist; + expect(loadingMenu).to.exist; + expect(loadingSpinner).to.exist; + expect(bodyRows[0].style.display).to.equal("none"); + + el.isLoading = false; + await elementUpdated(el); + + expect(el.shadowRoot?.querySelector(".loading")).to.not.exist; + expect(bodyRows[0].style.display).to.equal(""); + }); + + it("shows loading state in client mode when isLoading is true", async () => { + const el = await fixture(html` + + + ID + + + 1 + + + `); + await elementUpdated(el); + + const loadingState = el.shadowRoot?.querySelector(".loading"); + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const bodyRows = slot.assignedElements({ flatten: true }).slice(1) as HTMLElement[]; + + expect(loadingState).to.exist; + expect(bodyRows[0].style.display).to.equal(""); + }); + + it("emits sgds-sort in server mode without locally sorting rows", async () => { + const el = await fixture(html` + + + Name + + + Charlie + + + Alice + + + Bob + + + `); + await elementUpdated(el); + + let receivedDetail: { key: string; direction: string } | null = null; + el.addEventListener("sgds-sort", (event: Event) => { + receivedDetail = (event as CustomEvent<{ key: string; direction: string }>).detail; + }); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const [headerRow] = slot.assignedElements({ flatten: true }); + const header = headerRow.querySelector("sgds-data-table-head") as SgdsDataTableHead; + + header.handleSortClick(); + await elementUpdated(el); + + expect(receivedDetail).to.deep.equal({ key: "name", direction: "ascending" }); + + const namesAfterSortEvent = slot + .assignedElements({ flatten: true }) + .slice(1) + .map(row => row.querySelectorAll("sgds-data-table-cell")[0]?.textContent?.trim()); + + expect(namesAfterSortEvent).to.deep.equal(["Charlie", "Alice", "Bob"]); + }); + + it("shows no-data state when there are no body rows", async () => { + const el = await fixture(html` + + `); + await elementUpdated(el); + + const noData = el.shadowRoot?.querySelector(".no-data"); + expect(noData).to.exist; + expect(noData?.textContent?.trim()).to.equal("No data"); + }); + + it("does not show no-data state while loading", async () => { + const el = await fixture(html` + + `); + await elementUpdated(el); + + expect(el.shadowRoot?.querySelector(".loading")).to.exist; + expect(el.shadowRoot?.querySelector(".no-data")).to.not.exist; + }); +}); diff --git a/test/a11y/oobee/data-table.html b/test/a11y/oobee/data-table.html new file mode 100644 index 000000000..08e83c952 --- /dev/null +++ b/test/a11y/oobee/data-table.html @@ -0,0 +1,626 @@ + + + + + + + +
+

Basic

+ + + # + First name + Last name + Username + + + 1 + John + Doe + @johndoe + + + 2 + Jane + Doe + @janedoe + + + 3 + Bob + Smith + @bobsmith + + + 4 + Alice + Tan + @alicetan + + + 5 + Charlie + Lee + @charlielee + + + 6 + Diana + Lim + @dianalim + + + 7 + Ethan + Ng + @ethanng + + + 8 + Fiona + Koh + @fionakoh + + + 9 + George + Wong + @georgewong + + + 10 + Hannah + Yeo + @hannahyeo + + + 11 + Ivan + Chua + @ivanchua + + + 12 + Julia + Goh + @juliagoh + + + 13 + Kevin + Teo + @kevinteo + + + 14 + Laura + Ong + @lauraong + + + 15 + Marcus + Chan + @marcuschan + + + 16 + Nina + Phua + @ninaphua + + + 17 + Oscar + Sim + @oscarsim + + + 18 + Priya + Rajan + @priyarajan + + + 19 + Quinn + Ho + @quinnho + + + 20 + Rachel + Wee + @rachelwee + + + 21 + Samuel + Tan + @samueltan + + + 22 + Tina + Loh + @tinaloh + + + 23 + Uma + Devi + @umadevi + + +
+ + +
+

No rows

+ + + # + First name + Last name + Username + + +
+ + +
+

Multi-select

+ + + # + First name + Last name + Username + + + 1 + John + Doe + @johndoe + + + 2 + Jane + Doe + @janedoe + + + 3 + Bob + Smith + @bobsmith + + +
+ + +
+

Multi-select (Pre-checked on load)

+ + + # + First name + Last name + Username + + + 1 + John + Doe + @johndoe + + + 2 + Jane + Doe + @janedoe + + + 3 + Bob + Smith + @bobsmith + + +
+ + +
+

Expandable rows

+ + + # + First name + Last name + Username + + + + 1 + John + Doe + @johndoe +
Department: Engineering · Join date: 01 Jan 2022 · Status: Active
+
+ + + 2 + Jane + Doe + @janedoe +
Department: Design · Join date: 15 Mar 2023 · Status: Active
+
+ + + 3 + Bob + Smith + @bobsmith + +
+
+ + +
+

Expandable + Multi-select

+ + + # + First name + Last name + Username + + + + 1 + John + Doe + @johndoe +
Department: Engineering
+
+ + + 2 + Jane + Doe + @janedoe + + + + 3 + Bob + Smith + @bobsmith + +
+
+ + +
+

Custom Footer Text (footerText)

+ + + ID + Name + + + 1 + Amy + + + 2 + Ben + + + 3 + Cara + + + 4 + Dan + + +
+ + +
+

Hide Footer (hideFooter)

+ + + ID + Status + + + 1001 + Pending + + + 1002 + Approved + + + 1003 + Rejected + + +
+ + +
+

Server Mode (mode="server")

+ + + ID + Name + Region + + +
+ + +
+

Header and Cell Props (sorting, sortKey, width)

+ + + ID + Name + Role + Score + + + + 1 + Lina + Engineer + 82 + + + 2 + Adam + Engineer + 70 + + + 3 + Nora + Manager + 85 + + +
+ + +
+

Default Sort

+ + + ID + Name + Role + + + + 2 + Alice + Engineer + + + 4 + Ben + Analyst + + + 1 + Chloe + Manager + + + 3 + Darren + Designer + + +
+ + +
+

Row Colspan Example (5 data rows, 5 columns)

+ + + ID + Item + Category + Status + Amount + + + + 1 + Application fee + Service + Paid + 120.00 + + + + 2 + Support package (category merged) + Pending + 240.00 + + + + 3 + Licence renewal + Regulatory + Paid + 320.00 + + + + 4 + Bulk order adjustment (category merged) + Approved + 180.00 + + + + 5 + Maintenance + Service + In progress + 95.00 + + +
+ + diff --git a/test/checkbox.test.ts b/test/checkbox.test.ts index 5ebcebee9..caf0782cb 100644 --- a/test/checkbox.test.ts +++ b/test/checkbox.test.ts @@ -82,9 +82,11 @@ describe("", () => { expect(el.invalid).to.be.false; }); it("id attribute should equal to label for attribute", async () => { - const el = await fixture(html``); + const el = await fixture(html`Label`); const input = el.shadowRoot?.querySelector("input"); const label = el.shadowRoot?.querySelector("label"); + + expect(label).to.exist; expect(input?.getAttribute("id")).to.equal(label?.getAttribute("for")); }); @@ -120,10 +122,14 @@ describe("", () => { }); it("should emit sgds-change event when label is clicked", async () => { - const el = await fixture(html``); + const el = await fixture(html`Label`); const toggleHandler = Sinon.spy(); el.addEventListener("sgds-change", toggleHandler); - el.shadowRoot?.querySelector("label")?.click(); + + const label = el.shadowRoot?.querySelector("label"); + expect(label).to.exist; + label?.click(); + expect(toggleHandler).to.have.been.calledOnce; }); diff --git a/test/data-table.test.ts b/test/data-table.test.ts new file mode 100644 index 000000000..a5a932fce --- /dev/null +++ b/test/data-table.test.ts @@ -0,0 +1,546 @@ +import { html } from "lit"; +import { elementUpdated, expect, fixture } from "@open-wc/testing"; +import { SgdsDataTable } from "../src/components"; +import SgdsDataTableHead from "../src/components/DataTable/sgds-data-table-head"; +import "./sgds-web-component"; + +describe("", () => { + it("projects slotted table cells through named slots in row shadow DOM", async () => { + const el = await fixture(html` + + + Name + + + + Action + + + + `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const bodyRow = slot.assignedElements({ flatten: true })[1] as HTMLElement; + const cellHost = bodyRow.querySelector("sgds-data-table-cell") as HTMLElement; + const projectedCellSlot = bodyRow.shadowRoot?.querySelector('slot[name="cell-0"]') as HTMLSlotElement; + + expect(projectedCellSlot).to.exist; + expect(projectedCellSlot.assignedElements({ flatten: true })[0]).to.equal(cellHost); + }); + + it("renders data rows and header row from slotted content", async () => { + const el = await fixture(html` + + + Name + Role + + + Alice + Admin + + + Bob + User + + + `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const rows = slot.assignedElements({ flatten: true }); + expect(rows).to.have.lengthOf(3); + }); + + it("sorts rows when each header is clicked", async () => { + const el = await fixture(html` + + + ID + Name + + + 2 + Charlie + + + 3 + Alice + + + 1 + Bob + + + `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const [headerRow] = slot.assignedElements({ flatten: true }); + const headerCells = headerRow.shadowRoot?.querySelectorAll("th[data-sorting]") || []; + + // Sort by Name ascending + (headerCells[1] as HTMLElement).click(); + await elementUpdated(el); + + const sortedByNameAsc = slot + .assignedElements({ flatten: true }) + .slice(1) + .map(row => row.querySelectorAll("sgds-data-table-cell")[1]?.textContent?.trim()); + expect(sortedByNameAsc).to.deep.equal(["Alice", "Bob", "Charlie"]); + + // Sort by ID ascending from another header + (headerCells[0] as HTMLElement).click(); + await elementUpdated(el); + + const sortedByIdAsc = slot + .assignedElements({ flatten: true }) + .slice(1) + .map(row => row.querySelectorAll("sgds-data-table-cell")[0]?.textContent?.trim()); + expect(sortedByIdAsc).to.deep.equal(["1", "2", "3"]); + }); + + it("sorts rows when Enter is pressed on a sorting header", async () => { + const el = await fixture(html` + + + ID + Name + + + 2 + Charlie + + + 3 + Alice + + + 1 + Bob + + + `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const [headerRow] = slot.assignedElements({ flatten: true }); + const headerCells = headerRow.shadowRoot?.querySelectorAll("th[data-sorting]") || []; + + (headerCells[1] as HTMLElement).dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await elementUpdated(el); + + const sortedByNameAsc = slot + .assignedElements({ flatten: true }) + .slice(1) + .map(row => row.querySelectorAll("sgds-data-table-cell")[1]?.textContent?.trim()); + expect(sortedByNameAsc).to.deep.equal(["Alice", "Bob", "Charlie"]); + }); + + it("sorts only currently visible rows in client mode", async () => { + const el = await fixture(html` + + + ID + Name + + + 2 + Charlie + + + 1 + Alice + + + 4 + Zed + + + 3 + Bob + + + `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const [headerRow] = slot.assignedElements({ flatten: true }); + const headerCells = headerRow.shadowRoot?.querySelectorAll("th[data-sorting]") || []; + + (headerCells[1] as HTMLElement).click(); + await elementUpdated(el); + + const rowIdsAfterSort = slot + .assignedElements({ flatten: true }) + .slice(1) + .map(row => row.querySelectorAll("sgds-data-table-cell")[0]?.textContent?.trim()); + + expect(rowIdsAfterSort).to.deep.equal(["1", "2", "4", "3"]); + }); + + it("reverts to initial slotted row order when sort direction is none", async () => { + const el = await fixture(html` + + + Name + + + Charlie + + + Alice + + + Zed + + + Bob + + + `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const [headerRow] = slot.assignedElements({ flatten: true }); + const headerCell = headerRow.querySelector("sgds-data-table-head") as SgdsDataTableHead; + + headerCell.dispatchEvent( + new CustomEvent("i-sgds-sort", { + detail: { key: "name", direction: "ascending" }, + bubbles: true, + composed: true + }) + ); + await elementUpdated(el); + + headerCell.dispatchEvent( + new CustomEvent("i-sgds-sort", { + detail: { key: "name", direction: "none" }, + bubbles: true, + composed: true + }) + ); + await elementUpdated(el); + + const rowNamesAfterReset = slot + .assignedElements({ flatten: true }) + .slice(1) + .map(row => row.querySelectorAll("sgds-data-table-cell")[0]?.textContent?.trim()); + + expect(rowNamesAfterReset).to.deep.equal(["Charlie", "Alice", "Zed", "Bob"]); + }); + + it("ignores sgds-sort events that do not originate from table headers", async () => { + const el = await fixture(html` + + + ID + Name + + + 2 + Charlie + + + 3 + Alice + + + 1 + Bob + + + `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const initialOrder = slot + .assignedElements({ flatten: true }) + .slice(1) + .map(row => row.querySelectorAll("sgds-data-table-cell")[0]?.textContent?.trim()); + + const firstBodyRow = slot.assignedElements({ flatten: true })[1]; + firstBodyRow.dispatchEvent( + new CustomEvent("sgds-sort", { + detail: { key: "id", direction: "ascending" }, + bubbles: true, + composed: true + }) + ); + await elementUpdated(el); + + const orderAfterNonHeaderSort = slot + .assignedElements({ flatten: true }) + .slice(1) + .map(row => row.querySelectorAll("sgds-data-table-cell")[0]?.textContent?.trim()); + + expect(orderAfterNonHeaderSort).to.deep.equal(initialOrder); + }); + + it("keeps an expandable row open when open is true", async () => { + const el = await fixture(html` + + + ID + + + 1 +
Details
+
+
+ `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const row = slot.assignedElements({ flatten: true })[1] as HTMLElement & { open: boolean }; + const expandControl = row.shadowRoot?.querySelector("td.control-cell") as HTMLElement; + const renderedRow = row.shadowRoot?.querySelector("tr") as HTMLElement; + + expect(row.open).to.be.true; + expect(renderedRow.classList.contains("active")).to.be.true; + + expandControl.click(); + await elementUpdated(el); + + expect(row.open).to.be.false; + expect(renderedRow.classList.contains("active")).to.be.false; + }); + + it("toggles expandable row when pressing Enter on expand control", async () => { + const el = await fixture(html` + + + ID + + + 1 +
Details
+
+
+ `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const row = slot.assignedElements({ flatten: true })[1] as HTMLElement & { open: boolean }; + const expandControl = row.shadowRoot?.querySelector("td.control-cell") as HTMLElement; + + expect(row.open).to.be.false; + + expandControl.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await elementUpdated(row as Element); + + expect(row.open).to.be.true; + }); + + it("renders header control cells as th", async () => { + const el = await fixture(html` + + + ID + Name + + + 1 + Alice +
Details
+
+
+ `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const headerRow = slot.assignedElements({ flatten: true })[0] as HTMLElement; + const controlCells = Array.from(headerRow.shadowRoot?.querySelectorAll(".control-cell") ?? []); + + expect(controlCells.length).to.be.greaterThan(0); + expect(controlCells.every(cell => cell.tagName.toLowerCase() === "th")).to.be.true; + }); + + it("applies right alignment to all cells in a right-aligned column", async () => { + const el = await fixture(html` + + + Amount + + + 100 + + + `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const [headerRow] = slot.assignedElements({ flatten: true }); + const [bodyRow] = slot.assignedElements({ flatten: true }).slice(1); + const headerCellContent = headerRow.shadowRoot?.querySelector(".data-table-head"); + const bodyCellContent = bodyRow.shadowRoot?.querySelector(".data-table-cell"); + + expect(headerCellContent).to.exist; + expect(headerCellContent?.classList.contains("align-right")).to.be.true; + expect(bodyCellContent).to.exist; + expect(bodyCellContent?.classList.contains("align-right")).to.be.true; + }); + + it("shows loading state in server mode when isLoading is true", async () => { + const el = await fixture(html` + + + ID + + + 1 + + + `); + await elementUpdated(el); + + const loadingState = el.shadowRoot?.querySelector(".loading"); + const loadingMenu = el.shadowRoot?.querySelector(".loading-menu"); + const loadingSpinner = el.shadowRoot?.querySelector("sgds-spinner"); + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const bodyRows = slot.assignedElements({ flatten: true }).slice(1) as HTMLElement[]; + + expect(loadingState).to.exist; + expect(loadingMenu).to.exist; + expect(loadingSpinner).to.exist; + expect(bodyRows[0].style.display).to.equal("none"); + + el.isLoading = false; + await elementUpdated(el); + + expect(el.shadowRoot?.querySelector(".loading")).to.not.exist; + expect(bodyRows[0].style.display).to.equal(""); + }); + + it("shows loading state in client mode when isLoading is true", async () => { + const el = await fixture(html` + + + ID + + + 1 + + + `); + await elementUpdated(el); + + const loadingState = el.shadowRoot?.querySelector(".loading"); + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const bodyRows = slot.assignedElements({ flatten: true }).slice(1) as HTMLElement[]; + + expect(loadingState).to.exist; + expect(bodyRows[0].style.display).to.equal(""); + }); + + it("disables sort control when there are no body rows", async () => { + const el = await fixture(html` + + + Name + + + `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const [headerRow] = slot.assignedElements({ flatten: true }); + const sortButton = headerRow.shadowRoot?.querySelector(".sort-button") as HTMLButtonElement; + + expect(sortButton).to.exist; + expect(sortButton.disabled).to.be.true; + }); + + it("keeps sort control enabled in none state when body rows exist", async () => { + const el = await fixture(html` + + + Name + + + Amy + + + `); + await elementUpdated(el); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const [headerRow] = slot.assignedElements({ flatten: true }); + const header = headerRow.querySelector("sgds-data-table-head") as SgdsDataTableHead; + const sortButton = headerRow.shadowRoot?.querySelector(".sort-button") as HTMLButtonElement; + + expect(header.ariasort).to.equal("none"); + expect(sortButton).to.exist; + expect(sortButton.disabled).to.be.false; + }); + + it("emits sgds-sort in server mode without locally sorting rows", async () => { + const el = await fixture(html` + + + Name + + + Charlie + + + Alice + + + Bob + + + `); + await elementUpdated(el); + + let receivedDetail: { key: string; direction: string } | null = null; + el.addEventListener("sgds-sort", (event: Event) => { + receivedDetail = (event as CustomEvent<{ key: string; direction: string }>).detail; + }); + + const slot = el.shadowRoot?.querySelector("slot") as HTMLSlotElement; + const [headerRow] = slot.assignedElements({ flatten: true }); + const header = headerRow.querySelector("sgds-data-table-head") as SgdsDataTableHead; + + header.handleSortClick(); + await elementUpdated(el); + + expect(receivedDetail).to.deep.equal({ key: "name", direction: "ascending" }); + + const namesAfterSortEvent = slot + .assignedElements({ flatten: true }) + .slice(1) + .map(row => row.querySelectorAll("sgds-data-table-cell")[0]?.textContent?.trim()); + + expect(namesAfterSortEvent).to.deep.equal(["Charlie", "Alice", "Bob"]); + }); + + it("shows no-data state when there are no body rows", async () => { + const el = await fixture(html` + + `); + await elementUpdated(el); + + const noData = el.shadowRoot?.querySelector(".no-data"); + expect(noData).to.exist; + expect(noData?.textContent?.trim()).to.equal("No data"); + }); + + it("does not show no-data state while loading", async () => { + const el = await fixture(html` + + `); + await elementUpdated(el); + + expect(el.shadowRoot?.querySelector(".loading")).to.exist; + expect(el.shadowRoot?.querySelector(".no-data")).to.not.exist; + }); +});