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`
+
+
`
+ : 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;
+ });
+});