diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 764b238..cd01699 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,4 @@ jobs: - run: npm run lint - run: npm test - run: npm run build + - run: npm run size diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f19162e..5c8cd9d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,6 +55,7 @@ jobs: npm run lint npm test npm run build + npm run size - name: Publish to npm if: steps.check.outputs.exists == 'false' diff --git a/CHANGELOG.md b/CHANGELOG.md index e86bb5f..7a70df0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [0.3.0] - 2026-09-24 + +### Added +- Server (controlled) mode: pass `rows`, `total`, `totalPages`, `query` and `onQueryChange` instead of `table`; the table renders what it is given and reports what the user asked for +- Debounced search (`searchDelay`, 300 ms by default in server mode) +- Page-past-the-end correction in server mode (emits a query for the last page) +- Several filters (`filters: [ { field, label, options, multiple?, type? } ]`): any-of within a filter, all across filters, per-filter and "Clear all" controls, select fallback above 6 options, array-valued row fields +- Loading, empty and error states: `isLoading` (aria-busy, previous rows kept and dimmed, aria-hidden skeleton rows), `error` + `errorAction` (role="alert"), separate `empty` / `emptyAll` labels +- `toolbar` slot for per-screen controls (date range, export…) +- Opt-in row selection (`selection`) and `bulkActions` with eligibility, destructive styling, tri-state header checkbox, row-named checkboxes and focus handling after an action +- `perPageOptions` rows-per-page select +- Column `sortValue( row )`, `searchValue( row )` and `defaultSortDirection` +- Polite, debounced result announcements +- Size ceiling in CI (`npm run size`: fails above 20 KB JS / 5 KB CSS gzipped; 0.3.0 is ~4.6 KB / ~1.5 KB) +- Component tests with jsdom and an axe accessibility check + +### Changed +- **Breaking:** one query shape for both modes. `useTableState` takes `filters` (object) instead of `filterField`; `toggleFilter( field, value )`, `setFilter`, `clearFilters( field? )` replace the single-filter setters; the `filters` prop takes filter definitions instead of a flat option list +- Locale-aware sorting (`Intl.Collator`, numeric): "Ábel" sorts before "Zoltán", "9" before "10" +- Normalized search text is cached per row instead of rebuilt per keystroke +- Accessibility: explicit table roles that survive the mobile card layout, visible cell labels instead of `::before` content, chips with a check icon and ≥ 24 px targets, 3:1 control outlines, forced-colors styles, focusable scroll region, RTL-aware alignment and pager icons + ## [0.2.0] - 2026-09-24 ### Added diff --git a/README.md b/README.md index d820d2f..941b54e 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,22 @@ # @lwplugins/data-table -Lightweight data table for WordPress admin React screens — search, filter -chips, sortable columns and paging, built only on core -`@wordpress/components`. - -**~3 KB gzipped** in your plugin bundle. `@wordpress/dataviews` has to be -bundled by plugins (core does not ship it as a script) and weighs ~380 KB -gzipped; this covers the common "list / log / report" case without that cost. - -- Client-side search (every word must match, accent-insensitive) -- One "any of" filter rendered as toggle chips -- Sortable columns (`aria-sort`), paging, entry count, empty state -- Mobile: rows turn into labelled cards below 782px +Lightweight data table for WordPress admin React screens, built only on core +`@wordpress/components`. Client-side or server-paged, with filters, row +selection and bulk actions. + +**~4.6 KB gzipped** (JS, every feature included; CSS ~1.5 KB). CI fails the +build above 20 KB JS / 5 KB CSS, so a bundled dependency cannot sneak in. `@wordpress/dataviews` has to be bundled by plugins (core does not ship +it as a script) and weighs ~340–380 KB gzipped; this covers ordinary admin +lists (bookings, customers, orders, logs) without that cost. + +- Two modes, one query shape: client (`useTableState`) or server (controlled) +- Search (accent-insensitive, every word must match; debounced in server mode) +- Several filters: toggle chips or a select, any-of within, all across +- Locale-aware sorting, per-column `sortValue` / `searchValue` +- Loading, empty and error states +- Opt-in row selection and bulk actions +- Toolbar slot for your own controls (date range, export…) +- Mobile card layout, RTL, forced colors, screen-reader announcements - No bundled dependencies, no text domain — you pass translated labels ## Install @@ -23,34 +28,32 @@ npm install @lwplugins/data-table Peer dependencies (provided by WordPress core when built with `@wordpress/scripts`): `@wordpress/components`, `@wordpress/element`, `react`. -## Usage - -```jsx -import { __, _n, sprintf } from '@wordpress/i18n'; +```js import { DataTable, useTableState } from '@lwplugins/data-table'; import '@lwplugins/data-table/style.css'; +``` -const TYPES = [ - { value: 'converted', label: __( 'Converted', 'my-plugin' ) }, - { value: 'failed', label: __( 'Failed', 'my-plugin' ) }, -]; +## Client mode +All rows are in memory; the hook searches, filters, sorts and pages them. + +```jsx const COLUMNS = [ { id: 'time', label: __( 'Time', 'my-plugin' ), sortable: true }, - { id: 'file', label: __( 'File', 'my-plugin' ) }, + { id: 'name', label: __( 'Name', 'my-plugin' ), sortable: true, defaultSortDirection: 'asc' }, { id: 'saved', label: __( 'Saved', 'my-plugin' ), sortable: true, align: 'end', - render: ( row ) => `${ Math.round( row.saved / 1024 ) } KB`, + render: ( row ) => formatBytes( row.saved ), }, ]; -export default function Log( { rows } ) { +function Log( { rows } ) { const table = useTableState( rows, { - searchFields: [ 'file', 'message' ], - filterField: 'type', + searchFields: [ 'name', 'message' ], + columns: COLUMNS, sort: { field: 'time', direction: 'desc' }, perPage: 20, } ); @@ -59,87 +62,253 @@ export default function Log( { rows } ) { - /* translators: %d: number of rows. */ - sprintf( _n( '%d entry', '%d entries', count, 'my-plugin' ), count ), - page: ( page, total ) => - /* translators: 1: current page, 2: total pages. */ - sprintf( __( 'Page %1$d of %2$d', 'my-plugin' ), page, total ), - } } + filters={ [ + { field: 'type', label: __( 'Event', 'my-plugin' ), options: TYPES }, + { field: 'source', label: __( 'Source', 'my-plugin' ), options: SOURCES }, + ] } + labels={ LABELS } /> ); } ``` -### Why labels are a prop +## Server mode -WordPress loads script translations per plugin, by text domain. Strings -inside a shared package would never be translated in your plugin, so the -package ships English fallbacks only and takes your `__()` strings. +Pass the query and the current page of results instead of `table`. The table +renders what it is given and reports what the user asked for through +`onQueryChange`. Search, filter, sort and per-page changes reset `page` to 1. -## API +```jsx +import { DEFAULT_QUERY } from '@lwplugins/data-table'; -### `useTableState( rows, options )` +function Bookings() { + const [ query, setQuery ] = useState( { + ...DEFAULT_QUERY, + sort: { field: 'date', direction: 'desc' }, + } ); + const [ result, setResult ] = useState( { items: [], total: 0, totalPages: 1 } ); + const [ loading, setLoading ] = useState( true ); + const [ error, setError ] = useState( null ); -| Option | Type | Description | -|--------|------|-------------| -| `searchFields` | `string[]` | Row keys the search box looks in | -| `filterField` | `string` | Row key the filter chips apply to | -| `sort` | `{ field, direction }` | Initial sort (`'asc'` / `'desc'`) | -| `perPage` | `number` | Rows per page (default `20`) | + useEffect( () => { + // Abort the previous request: a slow response for "ab" must never + // overwrite the result for "abc". + const controller = new AbortController(); + setLoading( true ); + apiFetch( { path: addQueryArgs( '/my/v1/bookings', toArgs( query ) ), signal: controller.signal } ) + .then( ( data ) => { + setResult( data ); + setError( null ); + } ) + .catch( ( e ) => e.name !== 'AbortError' && setError( e.message ) ) + .finally( () => ! controller.signal.aborted && setLoading( false ) ); + return () => controller.abort(); + }, [ query ] ); -Returns `rows` (current page), `total`, `page`, `totalPages`, `search`, -`filter`, `sort` and the setters `setSearch`, `toggleFilter`, `clearFilter`, -`toggleSort`, `setPage`. Changing search or filter returns to page 1. + return ( + setQuery( { ...query } ) }>{ __( 'Retry', 'my-plugin' ) } } + /> + ); +} +``` + +The query object: + +```js +{ + search: '', // free text + filters: { status: [ 'pending' ], owner: [] }, // field → allowed values + sort: { field: 'date', direction: 'desc' }, + page: 1, + perPage: 20, +} +``` + +What the table handles for you: + +- **Debounce:** search is reported after `searchDelay` ms (300 by default in + server mode, 0 in client mode). Clearing the box reports immediately. +- **Page past the end:** after deleting the last row on the last page, + `page > totalPages`; the table emits a query for the last page instead of + showing an empty one. +- **Loading:** previous rows stay (dimmed) and the layout does not jump; with + nothing to show yet, aria-hidden skeleton rows are drawn. + +What stays your job: aborting stale requests (pattern above) — the table +never assumes the last `rows` it received belong to the latest query. + +## Filters + +```js +filters={ [ + { field: 'status', label: 'Status', options: STATUSES }, // chips, multi + { field: 'owner', label: 'Owner', options: OWNERS, multiple: false }, // chips, single + { field: 'country', label: 'Country', options: COUNTRIES, type: 'select' }, +] } +``` + +- Within a filter, any chosen value matches; across filters, all must match. +- Chips show up to 6 options; longer lists (or `type: 'select'`) become a + single-choice select with an "All" option. +- Each chip group gets a "Clear" link; "Clear all" appears with 2+ active filters. +- Client mode matches array-valued row fields: `tags: [ 'a', 'b' ]` matches filter `a`. + +## States + +| Prop | Effect | +|------|--------| +| `isLoading` | `aria-busy` on the region, previous rows dimmed, or skeleton rows when empty | +| `error` | Message in a `role="alert"` box instead of the rows and the empty state | +| `errorAction` | Node rendered next to the error (e.g. a Retry button) | +| `labels.empty` | Shown when search/filters match nothing ("No matching entries.") | +| `labels.emptyAll` | Shown when there is no data at all ("Nothing here yet.") | + +## Selection and bulk actions + +```jsx +const [ selected, setSelected ] = useState( [] ); + + `${ row.name }, ${ row.date }` } + selection={ { selected, onChange: setSelected } } + bulkActions={ [ + { + id: 'confirm', + label: __( 'Confirm', 'my-plugin' ), + isEligible: ( row ) => row.status === 'pending', + onClick: async ( rows, ids ) => { await confirm( rows ); setSelected( [] ); }, + }, + { id: 'delete', label: __( 'Delete', 'my-plugin' ), isDestructive: true, onClick: askThenDelete }, + ] } +/> +``` + +- **Selection is kept by id across pages and filters**, and never silently: + the bar reads "3 selected, 1 on this page". Clear it yourself when a filter + change should reset it (`onQueryChange` is the place). +- **"Select all" means this page only.** Selecting every result across pages + is out of scope. +- The header checkbox is tri-state; row checkboxes are named after the row + (`getRowLabel`, e.g. "Select: Jane Doe, 12 Oct"). +- An action receives the **eligible selected rows on the current page** and + all selected ids. Partially eligible actions show why ("applies to 2 of 3"); + an action with no eligible row is disabled but stays focusable. +- **Confirmation for destructive actions is your job** (a `ConfirmDialog` + in `onClick`); `isDestructive` only styles the button. +- After an action, focus moves to the selection count (or the table region), + since the button that had focus may no longer exist. + +## Toolbar slot and per-page + +```jsx + + + + + } + perPageOptions={ [ 10, 20, 50 ] } +/> +``` + +Date pickers and exports stay out of the package; the slot gives them a place. + +## API ### `` | Prop | Type | Description | |------|------|-------------| -| `columns` | `Array` | `{ id, label, render?( row ), sortable?, align?: 'start' \| 'center' \| 'end' }` | -| `table` | `Object` | `useTableState()` result | -| `filters` | `Array` | Chip options `{ value, label }` for `filterField` | -| `caption` | `string` | Table caption for screen readers | -| `labels` | `Object` | Translated strings, see `DEFAULT_LABELS` | +| `columns` | `Array` | `{ id, label, render?( row ), sortable?, defaultSortDirection?, align?: 'start' \| 'center' \| 'end', sortValue?( row ), searchValue?( row ) }` | +| `table` | `Object` | Client mode: `useTableState()` result | +| `rows`, `total`, `totalPages`, `query`, `onQueryChange` | | Server mode (instead of `table`) | +| `filters` | `Array` | Filter definitions (see Filters) | +| `searchable` | `boolean` | Show the search box (default `true`) | +| `searchDelay` | `number` | Search debounce in ms (default 300 server / 0 client) | +| `isLoading`, `error`, `errorAction` | | See States | +| `toolbar` | `node` | Extra controls in the toolbar row | +| `selection` | `{ selected, onChange }` | Opt-in row selection (ids) | +| `bulkActions` | `Array` | `{ id, label, onClick( rows, ids ), isEligible?( row ), isDestructive? }` | | `getRowId` | `( row ) => key` | Row key (default `row.id`) | +| `getRowLabel` | `( row ) => string` | Row name for checkbox labels | | `pagination` | `'bottom' \| 'top' \| 'both'` | Where the pager shows (default `'bottom'`) | +| `perPageOptions` | `number[]` | Adds a rows-per-page select | +| `caption` | `string` | Table caption and region name (screen readers) | +| `labels` | `Object` | Translated strings, see `DEFAULT_LABELS` | -### `filterSortPaginate( rows, state, options )` +### `useTableState( rows, options )` -The pure function behind the hook — use it with your own markup or in tests. +Options: `searchFields`, `columns` (for `sortValue` / `searchValue`), initial +`sort`, `perPage`, `filters`. Returns the table API: `rows`, `total`, `page`, +`totalPages`, `query`, and `setSearch`, `setFilter( field, values )`, +`toggleFilter( field, value, multiple )`, `clearFilters( field? )`, +`toggleSort( field, defaultDirection )`, `setPage`, `setPerPage`, `setQuery( patch )`. -## Styling +### Utilities -Import `@lwplugins/data-table/style.css`. Colors follow the WordPress Design -System tokens (`--wpds-*`) and the admin theme color. Override the accent: +`filterSortPaginate( rows, query, { searchFields, columns } )`, +`matchesFilters( row, filters )`, `compareValues( a, b )`, `normalize( text )`, +`DEFAULT_QUERY`, `patchQuery( query, patch )`, `createTableApi( query, setQuery, result )`, +`isNarrowed( query )`. -```css -.lw-table { - --lw-table-accent: #00a876; -} -``` +### Why labels are a prop + +WordPress loads script translations per plugin, by text domain. Strings +inside a shared package would never be translated in your plugin, so the +package ships English fallbacks only and takes your `__()` strings. Function +labels (`entries`, `results`, `page`, `selectRow`, `selected`, `eligible`) +receive numbers so you can use `_n()` and `sprintf()`. + +## Accessibility + +- Explicit table roles, so the mobile card layout (`display: block`) keeps + row/cell/column-header semantics; visible cell labels instead of CSS + generated content. +- `aria-sort` only on the sorted column; header buttons are named by the label. +- Debounced polite announcement of the result count; nothing announced per + request or while loading. Errors use `role="alert"`, empty states are plain text. +- Chips: `aria-pressed`, a check icon (not colour alone), ≥ 24 px targets. +- Forced colors: pressed chips, selected rows and focus rings use system colours. +- The scrolling table region is focusable and labelled for keyboard scrolling. +- Focus is not dropped: pager buttons stay focusable when disabled, focus + moves to the selection count after a bulk action. +- CI runs axe on a fully featured table. Manual screen-reader passes (NVDA + + Chrome, VoiceOver + Safari) are still to do before 1.0. + +## Migrating from 0.2 + +| 0.2 | 0.3 | +|-----|-----| +| `useTableState( rows, { filterField: 'type' } )` | `useTableState( rows, { … } )` — filters live in the query | +| `filters={ TYPES }` | `filters={ [ { field: 'type', label: 'Event', options: TYPES } ] }` | +| `table.filter` | `table.filters.type` | +| `table.toggleFilter( value )` | `table.toggleFilter( 'type', value )` | +| `table.clearFilter()` | `table.clearFilters( 'type' )` or `table.clearFilters()` | -## When to use DataViews instead +## Out of scope -Grid/list layouts, column hiding and reordering, bulk actions, server-side -paging of very large datasets, or DataForm integration. This package is -intentionally small; server-side mode is on the roadmap. +Column hiding and reordering, grid/list layouts, saved views, DataForm, +inline editing, virtualisation, "select all across pages", built-in date +pickers. Use DataViews when you need those. ## Releasing Bump `version` in `package.json`, add the `CHANGELOG.md` entry, push to -`main`. The Release workflow publishes to npm through trusted publishing -(OIDC, no token or OTP) and creates the `vX.Y.Z` tag and GitHub release. -Never tag or `npm publish` by hand. +`main`. The Release workflow tests, checks the size ceiling, publishes to npm +through trusted publishing (OIDC, no token or OTP) and creates the `vX.Y.Z` +tag and GitHub release. Never tag or `npm publish` by hand. ## License diff --git a/package-lock.json b/package-lock.json index b2e132f..620583b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,24 @@ { "name": "@lwplugins/data-table", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lwplugins/data-table", - "version": "0.1.0", + "version": "0.2.0", "license": "GPL-2.0-or-later", "devDependencies": { "@babel/cli": "^7.29.7", "@babel/core": "^7.29.7", + "@testing-library/react": "^16.3.3", "@wordpress/babel-preset-default": "^8.56.0", "@wordpress/components": "^41.0.0", "@wordpress/element": "^8.8.0", "@wordpress/scripts": "^36.0.0", + "axe-core": "^4.13.0", + "esbuild": "^0.28.2", + "jsdom": "^30.1.1", "vite": "^8.3.0", "vitest": "^5.0.1" }, @@ -119,6 +123,59 @@ "dev": true, "license": "MIT" }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz", + "integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.0", + "@csstools/css-color-parser": "^4.2.3", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz", + "integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/cli": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.29.7.tgz", @@ -641,6 +698,19 @@ } } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@cacheable/memory": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", @@ -702,6 +772,26 @@ "@keyv/serialize": "^1.1.1" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@csstools/css-calc": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", @@ -726,6 +816,34 @@ "@csstools/css-tokenizer": "^4.0.0" } }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, "node_modules/@csstools/css-parser-algorithms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", @@ -1155,6 +1273,448 @@ "node": ">=10" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-plugin-eslint-comments": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.8.1.tgz", @@ -1454,6 +2014,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", @@ -5469,6 +6047,66 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", @@ -5480,6 +6118,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -9797,6 +10443,16 @@ "dev": true, "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -11052,6 +11708,35 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -11411,6 +12096,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -11867,6 +12560,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -14007,6 +14742,19 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, "node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", @@ -14854,6 +15602,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -15166,6 +15921,56 @@ "node": "^22.22.2 || >=24.15.0" } }, + "node_modules/jsdom": { + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -15900,6 +16705,17 @@ "node": ">=10" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", @@ -17774,6 +18590,32 @@ "dev": true, "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -18816,6 +19658,44 @@ "node": ">=6.0.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -19935,6 +20815,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -21692,6 +22585,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "7.4.15", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.15.tgz", + "integrity": "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.15" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, "node_modules/tldts-core": { "version": "7.4.15", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.15.tgz", @@ -21759,6 +22665,32 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-dump": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", @@ -22041,6 +22973,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", @@ -22538,6 +23480,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/watchpack": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", @@ -22582,6 +23537,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/webpack": { "version": "5.111.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.111.1.tgz", @@ -23003,6 +23968,31 @@ "node": ">=0.8.0" } }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/when-exit": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", @@ -23276,6 +24266,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 103425e..fdb4029 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@lwplugins/data-table", - "version": "0.2.0", - "description": "Lightweight data table for WordPress admin React screens: search, filter chips, sorting and paging on core @wordpress/components. ~3 KB gzipped — a small alternative to DataViews.", + "version": "0.3.0", + "description": "Lightweight data table for WordPress admin React screens: client or server-paged, filters, selection and bulk actions on core @wordpress/components. ~5 KB gzipped \u2014 a small alternative to DataViews.", "license": "GPL-2.0-or-later", "author": "LW Plugins (https://lwplugins.com)", "homepage": "https://github.com/lwplugins/data-table#readme", @@ -38,10 +38,11 @@ "LICENSE" ], "scripts": { - "build": "babel src --out-dir build-module --ignore 'src/**/*.test.js'", + "build": "babel src --out-dir build-module", "prepublishOnly": "npm test && npm run build", "test": "wp-scripts test-unit-js", - "lint": "wp-scripts lint-js src test" + "lint": "wp-scripts lint-js src test", + "size": "node scripts/size.mjs" }, "peerDependencies": { "@wordpress/components": ">=28", @@ -54,10 +55,14 @@ "devDependencies": { "@babel/cli": "^7.29.7", "@babel/core": "^7.29.7", + "@testing-library/react": "^16.3.3", "@wordpress/babel-preset-default": "^8.56.0", "@wordpress/components": "^41.0.0", "@wordpress/element": "^8.8.0", "@wordpress/scripts": "^36.0.0", + "axe-core": "^4.13.0", + "esbuild": "^0.28.2", + "jsdom": "^30.1.1", "vite": "^8.3.0", "vitest": "^5.0.1" } diff --git a/scripts/size.mjs b/scripts/size.mjs new file mode 100644 index 0000000..79b78e1 --- /dev/null +++ b/scripts/size.mjs @@ -0,0 +1,41 @@ +/** + * Size budget: what the package adds to a consumer's bundle, minified and + * gzipped, with WordPress/React externals (core provides them). + * Fails when over budget. Run after `npm run build`. + */ +import { build } from 'esbuild'; +import { readFileSync } from 'node:fs'; +import { gzipSync } from 'node:zlib'; + +// A ceiling against accidental bloat (e.g. a bundled dependency), not a +// target: 0.3.0 is ~4.6 KB JS / ~1.5 KB CSS. +const BUDGET = { js: 20 * 1024, css: 5 * 1024 }; + +const js = await build( { + entryPoints: [ 'build-module/index.js' ], + bundle: true, + minify: true, + format: 'esm', + write: false, + external: [ '@wordpress/*', 'react', 'react/*', 'react-dom' ], +} ); +const css = await build( { + entryPoints: [ 'src/data-table.css' ], + bundle: true, + minify: true, + write: false, +} ); + +const size = ( out ) => gzipSync( out.outputFiles[ 0 ].contents, { level: 9 } ).length; +const result = { js: size( js ), css: size( css ) }; +let failed = false; + +for ( const [ kind, bytes ] of Object.entries( result ) ) { + const ok = bytes <= BUDGET[ kind ]; + failed ||= ! ok; + console.log( + `${ kind.padEnd( 3 ) } ${ ( bytes / 1024 ).toFixed( 2 ) } KB gz (budget ${ BUDGET[ kind ] / 1024 } KB) ${ ok ? 'ok' : 'OVER' }` + ); +} + +process.exit( failed ? 1 : 0 ); diff --git a/src/data-table.css b/src/data-table.css index fc47b93..983350f 100644 --- a/src/data-table.css +++ b/src/data-table.css @@ -12,11 +12,16 @@ .lw-table { --lw-table-accent: var(--wp-admin-theme-color, #3858e9); --lw-table-text: var(--wpds-color-foreground-content-neutral, #1e1e1e); + /* 4.9:1 on white (WCAG 1.4.3). */ --lw-table-muted: var(--wpds-color-foreground-content-neutral-weak, #707070); - --lw-table-border: var(--wpds-color-stroke-surface-neutral, #ddd); + /* Control outlines 3:1 (WCAG 1.4.11); dividers may stay faint. */ + --lw-table-control: var(--wpds-color-stroke-interactive-neutral, #8c8f94); --lw-table-border-weak: var(--wpds-color-stroke-surface-neutral-weak, #f0f0f0); --lw-table-surface: var(--wpds-color-background-surface-neutral-strong, #fff); --lw-table-hover: var(--wpds-color-background-surface-neutral-weak, #f6f7f7); + --lw-table-selected: var(--wpds-color-background-surface-brand, #eef2fd); + --lw-table-error: var(--wpds-color-foreground-content-error, #cc1818); + --lw-table-error-bg: var(--wpds-color-background-surface-error-weak, #fcf0f1); display: flex; flex-direction: column; @@ -24,6 +29,8 @@ color: var(--lw-table-text); } +/* Toolbar ------------------------------------------------------------------ */ + .lw-table__toolbar { display: flex; flex-wrap: wrap; @@ -42,9 +49,18 @@ gap: 6px; } +.lw-table__chips-label { + color: var(--lw-table-muted); + font-size: 12px; +} + .lw-table__chip { - padding: 4px 10px; - border: 1px solid var(--lw-table-border); + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 28px; /* ≥ 24px target (WCAG 2.5.8) */ + padding: 0 10px; + border: 1px solid var(--lw-table-control); border-radius: 999px; background: var(--lw-table-surface); color: var(--lw-table-text); @@ -57,6 +73,7 @@ border-color: var(--lw-table-accent); } +/* Pressed = colour + fill + check icon, never colour alone. */ .lw-table__chip[aria-pressed="true"] { border-color: var(--lw-table-accent); background: var(--lw-table-accent); @@ -64,13 +81,77 @@ } .lw-table__chip:focus-visible, -.lw-table th button:focus-visible { +.lw-table th button:focus-visible, +.lw-table__scroll:focus-visible, +.lw-table__check:focus-visible, +.lw-table__bulk-count:focus-visible { outline: 2px solid var(--lw-table-accent); outline-offset: 2px; } +.lw-table__select .components-select-control__input { + min-height: 28px; +} + +.lw-table__slot { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.lw-table__paging { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + color: var(--lw-table-muted); + font-size: 12px; +} + +.lw-table__toolbar .lw-table__paging { + margin-inline-start: auto; +} + +.lw-table__pager { + display: flex; + align-items: center; + gap: 4px; +} + +/* Bulk actions --------------------------------------------------------------- */ + +.lw-table__bulk { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 6px; + background: var(--lw-table-selected); +} + +.lw-table__bulk-count { + margin-inline-end: 4px; + font-weight: 600; +} + +.lw-table__bulk-note { + margin-inline-start: 4px; + font-weight: 400; + opacity: 0.85; +} + +/* Table ---------------------------------------------------------------------- */ + .lw-table__scroll { overflow-x: auto; + border-radius: 2px; +} + +.lw-table__scroll.is-loading tbody { + opacity: 0.55; + transition: opacity 0.15s; } .lw-table table { @@ -79,7 +160,8 @@ font-size: 13px; } -.lw-table__caption { +.lw-table__caption, +.lw-table__live { position: absolute; width: 1px; height: 1px; @@ -92,13 +174,13 @@ .lw-table td { padding: 10px 12px; border-bottom: 1px solid var(--lw-table-border-weak); - text-align: left; + text-align: start; vertical-align: middle; } .lw-table th.is-end, .lw-table td.is-end { - text-align: right; + text-align: end; white-space: nowrap; } @@ -138,6 +220,51 @@ background: var(--lw-table-hover); } +.lw-table tbody tr.is-selected { + background: var(--lw-table-selected); +} + +.lw-table .lw-table__select-cell { + width: 1%; + padding-inline-end: 0; +} + +.lw-table__check { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--lw-table-accent); +} + +/* Shown only in the mobile card layout. */ +.lw-table__cell-label { + display: none; +} + +.lw-table__flip { + display: block; +} + +[dir="rtl"] .lw-table__flip { + transform: scaleX(-1); +} + +/* States --------------------------------------------------------------------- */ + +.lw-table__skeleton span { + display: block; + height: 10px; + border-radius: 3px; + background: var(--lw-table-hover); + animation: lw-table-pulse 1.5s ease-in-out infinite; +} + +@keyframes lw-table-pulse { + 50% { + opacity: 0.45; + } +} + .lw-table__empty { margin: 0; padding: 32px 0; @@ -145,63 +272,110 @@ text-align: center; } -.lw-table__foot { +.lw-table__error { display: flex; + flex-wrap: wrap; align-items: center; - justify-content: space-between; - color: var(--lw-table-muted); - font-size: 12px; + justify-content: center; + gap: 12px; + margin-top: 12px; + padding: 16px; + border-radius: 6px; + background: var(--lw-table-error-bg); + color: var(--lw-table-error); } -.lw-table__top-pager { - display: flex; - align-items: center; - gap: 12px; - margin-left: auto; - color: var(--lw-table-muted); - font-size: 12px; +.lw-table__error p { + margin: 0; } -.lw-table__pager { +.lw-table__foot { display: flex; - align-items: center; - gap: 4px; + justify-content: flex-end; +} + +.lw-table__foot .lw-table__paging { + flex: 1; + justify-content: space-between; +} + +@media (prefers-reduced-motion: reduce) { + .lw-table__skeleton span { + animation: none; + } + + .lw-table__scroll.is-loading tbody { + transition: none; + } +} + +/* Windows High Contrast / forced colours: box-shadows and backgrounds vanish, + so pressed, selected and focus states use system colours. */ +@media (forced-colors: active) { + .lw-table__chip[aria-pressed="true"] { + border: 2px solid Highlight; + background: Highlight; + color: HighlightText; + } + + .lw-table tbody tr.is-selected { + outline: 2px solid Highlight; + outline-offset: -2px; + } + + .lw-table__chip:focus-visible, + .lw-table th button:focus-visible, + .lw-table__scroll:focus-visible, + .lw-table__check:focus-visible { + outline-color: Highlight; + } } -/* Mobile (WP admin breakpoint): each row becomes a card. */ +/* Mobile (WP admin breakpoint): each row becomes a card. The explicit table + roles in the markup keep the semantics despite display: block, and each + cell shows its column name in an aria-hidden label element. */ @media (max-width: 782px) { - .lw-table thead { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); + .lw-table thead tr { + display: flex; + flex-wrap: wrap; + gap: 8px; + border-bottom: 1px solid var(--lw-table-border-weak); + } + + .lw-table thead th { + padding: 6px 0; + border: 0; + } + + .lw-table thead th:not([aria-sort]):not(.lw-table__select-cell):not(:has(button)) { + display: none; } .lw-table table, .lw-table tbody, - .lw-table tr, - .lw-table td { + .lw-table tbody tr, + .lw-table tbody td { display: block; } - .lw-table tr { + .lw-table tbody tr { + position: relative; padding: 10px 0; border-bottom: 1px solid var(--lw-table-border-weak); } - .lw-table td, - .lw-table td.is-end { + .lw-table tbody td, + .lw-table tbody td.is-end { display: flex; justify-content: space-between; gap: 12px; padding: 4px 0; border: 0; - text-align: left; + text-align: start; } - .lw-table td::before { - content: attr(data-label); + .lw-table__cell-label { + display: block; flex-shrink: 0; color: var(--lw-table-muted); font-size: 11px; @@ -209,13 +383,17 @@ text-transform: uppercase; } + .lw-table tbody td.lw-table__select-cell { + width: auto; + } + .lw-table__toolbar .components-search-control { flex-basis: 100%; } - .lw-table__top-pager { + .lw-table__toolbar .lw-table__paging { justify-content: space-between; width: 100%; - margin-left: 0; + margin-inline-start: 0; } } diff --git a/src/data-table.js b/src/data-table.js index 0c24b39..3481e9d 100644 --- a/src/data-table.js +++ b/src/data-table.js @@ -1,176 +1,242 @@ /** * WordPress dependencies */ -import { Button, SearchControl } from '@wordpress/components'; +import { Button } from '@wordpress/components'; +import { useEffect, useRef } from '@wordpress/element'; /** * Internal dependencies */ -import { ArrowDown, ArrowUp } from './icons.js'; -import { Pager } from './pager.js'; import { DEFAULT_LABELS } from './labels.js'; +import { DEFAULT_QUERY, createTableApi, isNarrowed } from './query.js'; +import { BulkBar } from './parts/bulk-bar.js'; +import { FilterGroup } from './parts/filter-group.js'; +import { Pager } from './parts/pager.js'; +import { PerPage } from './parts/per-page.js'; +import { SearchBox } from './parts/search-box.js'; +import { TableBody } from './parts/table-body.js'; +import { TableHead } from './parts/table-head.js'; +import { useAnnounce } from './parts/use-announce.js'; /** - * Sortable column header cell. + * Lightweight data table on core components. Two modes, one state shape: * - * @param {Object} props - * @param {Object} props.column Column definition. - * @param {Object} props.table useTableState() result. + * - Client: pass `table` from useTableState( rows ). + * - Server (controlled): pass `rows`, `total`, `totalPages`, `query`, + * `onQueryChange` — fetch whatever `query` asks for. + * + * Every other feature (filters, toolbar, selection, bulk actions, per-page + * select, states) is opt-in. + * + * @param {Object} props See README for the full prop list. */ -function HeaderCell( { column, table } ) { - const sorted = table.sort?.field === column.id; - const asc = sorted && table.sort.direction === 'asc'; - let ariaSort; - if ( sorted ) { - ariaSort = asc ? 'ascending' : 'descending'; - } +export function DataTable( props ) { + const { + columns, + filters = [], + toolbar, + caption, + getRowId = ( row ) => row.id, + getRowLabel, + pagination = 'bottom', + perPageOptions, + searchable = true, + isLoading = false, + error, + errorAction, + selection, + bulkActions = [], + } = props; + const labels = { ...DEFAULT_LABELS, ...props.labels }; + const server = ! props.table; + const table = + props.table || + createTableApi( props.query || DEFAULT_QUERY, props.onQueryChange, { + rows: props.rows || [], + total: props.total || 0, + totalPages: Math.max( 1, props.totalPages || 1 ), + page: ( props.query || DEFAULT_QUERY ).page, + } ); + const searchDelay = props.searchDelay ?? ( server ? 300 : 0 ); + const focusTarget = useRef(); - return ( - - { column.sortable ? ( - - ) : ( - column.label + // Page past the end (e.g. the last row of the last page was deleted): + // ask for the last page instead of showing an empty one. + const { page, totalPages } = table; + useEffect( () => { + if ( server && ! isLoading && page > totalPages ) { + table.setPage( totalPages ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ server, isLoading, page, totalPages ] ); + + const announced = useAnnounce( + labels.results( table.total ), + `${ table.total }|${ table.page }|${ JSON.stringify( table.query ) }`, + isLoading + ); + + const showSkeleton = isLoading && ! table.rows.length; + const selectionApi = selection && { + isSelected: ( id ) => selection.selected.includes( id ), + toggle: ( id ) => + selection.onChange( + selection.selected.includes( id ) + ? selection.selected.filter( ( item ) => item !== id ) + : [ ...selection.selected, id ] + ), + label: ( row ) => + labels.selectRow( + getRowLabel ? getRowLabel( row ) : String( getRowId( row ) ) + ), + }; + const pageIds = table.rows.map( getRowId ); + const pageSelected = selection + ? table.rows.filter( ( row ) => + selection.selected.includes( getRowId( row ) ) + ) + : []; + const activeFilters = filters.filter( + ( f ) => table.filters[ f.field ]?.length + ); + + const pager = ( +
+ { labels.entries( table.total ) } + { perPageOptions && ( + ) } - + +
); -} -/** - * Lightweight data table on core components: search, filter chips, sortable - * headers, paging. Pair it with useTableState(); style with style.css. - * - * @param {Object} props - * @param {Array} props.columns { id, label, render?, sortable?, align? }. - * @param {Object} props.table useTableState() result. - * @param {Array} props.filters Chip options { value, label } (optional). - * @param {string} props.caption Table caption (screen readers). - * @param {Object} props.labels Translated UI strings (see DEFAULT_LABELS). - * @param {Function} props.getRowId Row key (default: row.id). - * @param {string} props.pagination Where the pager shows: 'bottom' (default), 'top' or 'both'. - */ -export function DataTable( { - columns, - table, - filters = [], - caption, - labels: customLabels, - getRowId = ( row ) => row.id, - pagination = 'bottom', -} ) { - const labels = { ...DEFAULT_LABELS, ...customLabels }; - const showTop = pagination === 'top' || pagination === 'both'; - const showBottom = pagination !== 'top'; + let empty = null; + if ( error ) { + empty = ( +
+

{ error }

+ { errorAction } +
+ ); + } else if ( ! isLoading && table.total === 0 ) { + empty = ( +

+ { isNarrowed( table.query ) ? labels.empty : labels.emptyAll } +

+ ); + } return ( -
+
- - { filters.length > 0 && ( -
- { filters.map( ( option ) => ( - - ) ) } - { table.filter.length > 0 && ( - - ) } -
+ { searchable && ( + ) } - { showTop && ( -
- { labels.entries( table.total ) } - -
+ { filters.map( ( filter ) => ( + + ) ) } + { activeFilters.length > 1 && ( + ) } + { toolbar &&
{ toolbar }
} + { ( pagination === 'top' || pagination === 'both' ) && pager }
-
- + { selection && selection.selected.length > 0 && ( + selection.onChange( [] ) } + labels={ labels } + focusTarget={ focusTarget } + /> + ) } + + { /* Scrollable region: focusable so keyboard users can scroll it. */ } +
+
{ caption && ( ) } - - - { columns.map( ( column ) => ( - - ) ) } - - - - { table.rows.map( ( row ) => ( - - { columns.map( ( column ) => ( - - ) ) } - - ) ) } - + + selection.onChange( + all + ? [ + ...new Set( [ + ...selection.selected, + ...pageIds, + ] ), + ] + : selection.selected.filter( + ( id ) => + ! pageIds.includes( id ) + ) + ), + } + } + /> + { ! error && ( + + ) }
{ caption }
- { column.render - ? column.render( row ) - : row[ column.id ] } -
- { table.total === 0 && ( -

{ labels.empty }

- ) } + { empty }
- { showBottom && ( -
- { labels.entries( table.total ) } - -
+ { pagination !== 'top' && ( +
{ pager }
) } + +
+ { announced } +
); } diff --git a/src/filter-sort-paginate.js b/src/filter-sort-paginate.js index 48474f0..bbf19ea 100644 --- a/src/filter-sort-paginate.js +++ b/src/filter-sort-paginate.js @@ -10,58 +10,128 @@ export const normalize = ( value ) => .normalize( 'NFD' ) .replace( /[̀-ͯ]/g, '' ); +let collator; + +/** + * Locale-aware comparison: accented initials sort with their base letter, + * and numeric strings sort as numbers ("9" before "10"). + * + * @param {*} a First value. + * @param {*} b Second value. + * @return {number} Negative, zero or positive. + */ +export function compareValues( a, b ) { + const emptyA = a === null || a === undefined || a === ''; + const emptyB = b === null || b === undefined || b === ''; + if ( emptyA || emptyB ) { + return Number( emptyA ) - Number( emptyB ); + } + if ( typeof a === 'number' && typeof b === 'number' ) { + return a - b; + } + if ( ! collator ) { + const lang = + ( typeof document !== 'undefined' && + document.documentElement.lang ) || + undefined; + collator = new Intl.Collator( lang, { + numeric: true, + sensitivity: 'base', + } ); + } + return collator.compare( String( a ), String( b ) ); +} + +// Normalized search text per row, computed once per row object and field set. +const haystacks = new WeakMap(); + +function haystackOf( row, searchFields, columns, key ) { + let cache = haystacks.get( row ); + if ( cache?.key !== key ) { + const parts = searchFields.map( ( field ) => row[ field ] ); + columns.forEach( ( column ) => + parts.push( column.searchValue( row ) ) + ); + cache = { key, text: normalize( parts.flat().join( ' ' ) ) }; + haystacks.set( row, cache ); + } + return cache.text; +} + +/** + * Whether a row passes every active filter. Within a filter any chosen value + * matches; array-valued row fields match when they contain one of them. + * + * @param {Object} row Row. + * @param {Object} filters { [field]: value[] }. + * @return {boolean} Match. + */ +export function matchesFilters( row, filters = {} ) { + return Object.entries( filters ).every( ( [ field, values ] ) => { + if ( ! values || ! values.length ) { + return true; + } + const value = row[ field ]; + return Array.isArray( value ) + ? value.some( ( item ) => values.includes( item ) ) + : values.includes( value ); + } ); +} + /** - * Apply search, filter, sort and paging to a row list. Pure function — the - * useTableState() hook wraps it, and it is usable on its own (e.g. in tests - * or with a different UI). + * Apply search, filters, sort and paging to a row list. Pure function — the + * useTableState() hook wraps it; usable on its own with any markup. * * @param {Array} rows All rows. - * @param {Object} state - * @param {string} state.search Free text; every word must match. - * @param {Array} state.filter Allowed values of `filterField` (empty = all). - * @param {Object} state.sort { field, direction: 'asc'|'desc' }. - * @param {number} state.page 1-based page. + * @param {Object} query + * @param {string} query.search Free text; every word must match. + * @param {Object} query.filters { [field]: allowed values } (empty = all). + * @param {Object} query.sort { field, direction: 'asc'|'desc' }. + * @param {number} query.page 1-based page. + * @param {number} query.perPage Rows per page. * @param {Object} options * @param {Array} options.searchFields Row keys the search looks in. - * @param {string} options.filterField Row key the filter applies to. - * @param {number} options.perPage Rows per page. + * @param {Array} options.columns Columns; `searchValue( row )` joins the + * search text, `sortValue( row )` drives sorting. * @return {{rows: Array, total: number, page: number, totalPages: number}} Result. */ export function filterSortPaginate( rows, - { search = '', filter = [], sort, page = 1 } = {}, - { searchFields = [], filterField, perPage = 20 } = {} + { search = '', filters = {}, sort, page = 1, perPage = 20 } = {}, + { searchFields = [], columns = [] } = {} ) { const words = normalize( search ).split( /\s+/ ).filter( Boolean ); + const searchColumns = columns.filter( ( column ) => column.searchValue ); + const key = + searchFields.join( '|' ) + + '#' + + searchColumns.map( ( c ) => c.id ).join( '|' ); const matched = ( rows || [] ).filter( ( row ) => { - if ( filter.length && ! filter.includes( row[ filterField ] ) ) { + if ( ! matchesFilters( row, filters ) ) { return false; } if ( ! words.length ) { return true; } - const haystack = searchFields - .map( ( key ) => normalize( row[ key ] ) ) - .join( ' ' ); - return words.every( ( word ) => haystack.includes( word ) ); + const text = haystackOf( row, searchFields, searchColumns, key ); + return words.every( ( word ) => text.includes( word ) ); } ); if ( sort?.field ) { + const column = columns.find( ( c ) => c.id === sort.field ); + const valueOf = column?.sortValue ?? ( ( row ) => row[ sort.field ] ); const dir = sort.direction === 'asc' ? 1 : -1; matched.sort( ( a, b ) => { - const x = a[ sort.field ]; - const y = b[ sort.field ]; - if ( x === y ) { - return 0; - } - if ( x === null || x === undefined ) { - return 1; - } - if ( y === null || y === undefined ) { - return -1; + const x = valueOf( a ); + const y = valueOf( b ); + const emptyX = x === null || x === undefined || x === ''; + const emptyY = y === null || y === undefined || y === ''; + // Empty values stay last in both directions. + if ( emptyX || emptyY ) { + return Number( emptyX ) - Number( emptyY ); } - return ( x > y ? 1 : -1 ) * dir; + return compareValues( x, y ) * dir; } ); } diff --git a/src/icons.js b/src/icons.js index e62399b..1eace5f 100644 --- a/src/icons.js +++ b/src/icons.js @@ -2,11 +2,13 @@ * Inline stroke icons (paths from `@wordpress/icons` 17, GPL-2.0-or-later), * kept inline so the package has no bundled dependency. * - * @param {string} d Path data. - * @param {number} size Pixel size. + * @param {string} d Path data. + * @param {number} size Pixel size. + * @param {string} className Optional class (e.g. for RTL flipping). */ -const icon = ( d, size ) => ( +const icon = ( d, size, className ) => ( icon( 'M12 4V19M7 14L12 19L17 14', size ); export const ChevronLeft = ( { size = 24 } ) => - icon( 'M14 6.5L9 12L14 17.5', size ); + icon( 'M14 6.5L9 12L14 17.5', size, 'lw-table__flip' ); export const ChevronRight = ( { size = 24 } ) => - icon( 'M10 6.5L15 12L10 17.5', size ); + icon( 'M10 6.5L15 12L10 17.5', size, 'lw-table__flip' ); + +export const Check = ( { size = 14 } ) => icon( 'M5 12.5L9.5 17L19 7.5', size ); diff --git a/src/index.js b/src/index.js index 273c0bf..6bb4ec1 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,15 @@ export { DataTable } from './data-table.js'; export { useTableState } from './use-table-state.js'; -export { filterSortPaginate, normalize } from './filter-sort-paginate.js'; +export { + filterSortPaginate, + matchesFilters, + compareValues, + normalize, +} from './filter-sort-paginate.js'; +export { + DEFAULT_QUERY, + patchQuery, + createTableApi, + isNarrowed, +} from './query.js'; export { DEFAULT_LABELS } from './labels.js'; diff --git a/src/labels.js b/src/labels.js index 00e07a6..1b198e1 100644 --- a/src/labels.js +++ b/src/labels.js @@ -7,14 +7,41 @@ export const DEFAULT_LABELS = { search: 'Search', filter: 'Filter', clear: 'Clear', + clearAll: 'Clear all filters', + all: 'All', + /** No rows match the current search or filters. */ empty: 'No matching entries.', + /** There is no data at all yet. */ + emptyAll: 'Nothing here yet.', + loading: 'Loading…', previous: 'Previous page', next: 'Next page', + perPage: 'Rows per page', + selectAll: 'Select all rows on this page', + clearSelection: 'Clear selection', + bulkActions: 'Bulk actions', /** @param {number} count Number of rows. */ entries: ( count ) => `${ count } ${ count === 1 ? 'entry' : 'entries' }`, + /** @param {number} count Number of results (announced to screen readers). */ + results: ( count ) => `${ count } ${ count === 1 ? 'result' : 'results' }`, /** * @param {number} page Current page. * @param {number} total Total pages. */ page: ( page, total ) => `Page ${ page } of ${ total }`, + /** @param {string} rowLabel Row name, e.g. "Jane Doe, 12 Oct". */ + selectRow: ( rowLabel ) => `Select: ${ rowLabel }`, + /** + * @param {number} count Selected rows in total. + * @param {number} onPage Of those, rows on the current page. + */ + selected: ( count, onPage ) => + count === onPage + ? `${ count } selected` + : `${ count } selected, ${ onPage } on this page`, + /** + * @param {number} eligible Selected rows the action applies to. + * @param {number} count Selected rows on this page. + */ + eligible: ( eligible, count ) => `applies to ${ eligible } of ${ count }`, }; diff --git a/src/parts/bulk-bar.js b/src/parts/bulk-bar.js new file mode 100644 index 0000000..42ff7e9 --- /dev/null +++ b/src/parts/bulk-bar.js @@ -0,0 +1,81 @@ +/** + * WordPress dependencies + */ +import { Button } from '@wordpress/components'; +import { useRef } from '@wordpress/element'; + +/** + * Selection count + bulk action buttons. Shown while something is selected. + * After an action runs, focus moves to the count: the button that had focus + * may be gone (the rows it acted on were deleted, the bar hides…). + * + * @param {Object} props + * @param {Array} props.actions { id, label, onClick, isEligible?, isDestructive? }. + * @param {Array} props.selected Selected ids (all pages). + * @param {Array} props.pageRows Selected rows on the current page. + * @param {Function} props.onClear Clear the selection. + * @param {Object} props.labels Resolved labels. + * @param {Object} props.focusTarget Ref of the element to focus afterwards. + */ +export function BulkBar( { + actions, + selected, + pageRows, + onClear, + labels, + focusTarget, +} ) { + const status = useRef(); + + const run = async ( action, rows ) => { + await action.onClick( rows, selected ); + ( status.current || focusTarget?.current )?.focus(); + }; + + return ( +
+ + { labels.selected( selected.length, pageRows.length ) } + + { actions.map( ( action ) => { + const rows = action.isEligible + ? pageRows.filter( action.isEligible ) + : pageRows; + const partial = rows.length < pageRows.length; + const note = partial + ? labels.eligible( rows.length, pageRows.length ) + : ''; + return ( + + ); + } ) } + +
+ ); +} diff --git a/src/parts/filter-group.js b/src/parts/filter-group.js new file mode 100644 index 0000000..b51c19b --- /dev/null +++ b/src/parts/filter-group.js @@ -0,0 +1,88 @@ +/** + * WordPress dependencies + */ +import { Button, SelectControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { Check } from '../icons.js'; + +// Above this many options chips stop scaling; a select takes over. +const CHIP_LIMIT = 6; + +/** + * One filter: toggle chips (any-of) or, for long lists / type "select", + * a single-choice select with an "All" option. + * + * @param {Object} props + * @param {Object} props.filter { field, label, options, multiple?, type? }. + * @param {Object} props.table Table API. + * @param {Object} props.labels Resolved labels. + */ +export function FilterGroup( { filter, table, labels } ) { + const { field, label, options, multiple = true } = filter; + const values = table.filters[ field ] || []; + const type = + filter.type || ( options.length > CHIP_LIMIT ? 'select' : 'chips' ); + + if ( type === 'select' ) { + return ( + ( { + value: String( o.value ), + label: o.label, + } ) ), + ] } + onChange={ ( next ) => { + const option = options.find( + ( o ) => String( o.value ) === next + ); + table.setFilter( field, option ? [ option.value ] : [] ); + } } + /> + ); + } + + return ( +
+ + { options.map( ( option ) => { + const pressed = values.includes( option.value ); + return ( + + ); + } ) } + { values.length > 0 && ( + + ) } +
+ ); +} diff --git a/src/parts/header-checkbox.js b/src/parts/header-checkbox.js new file mode 100644 index 0000000..5be5381 --- /dev/null +++ b/src/parts/header-checkbox.js @@ -0,0 +1,35 @@ +/** + * WordPress dependencies + */ +import { useEffect, useRef } from '@wordpress/element'; + +/** + * Tri-state "select all rows on this page" checkbox (native, so the + * indeterminate state is announced by assistive tech). + * + * @param {Object} props + * @param {number} props.checked Selected rows on this page. + * @param {number} props.total Selectable rows on this page. + * @param {Function} props.onChange Receives true (select all) / false. + * @param {string} props.label Accessible name. + */ +export function HeaderCheckbox( { checked, total, onChange, label } ) { + const ref = useRef(); + const all = total > 0 && checked === total; + + useEffect( () => { + ref.current.indeterminate = checked > 0 && ! all; + }, [ checked, all ] ); + + return ( + onChange( ! all ) } + /> + ); +} diff --git a/src/pager.js b/src/parts/pager.js similarity index 94% rename from src/pager.js rename to src/parts/pager.js index 3da595c..96b2bf9 100644 --- a/src/pager.js +++ b/src/parts/pager.js @@ -6,7 +6,7 @@ import { Button } from '@wordpress/components'; /** * Internal dependencies */ -import { ChevronLeft, ChevronRight } from './icons.js'; +import { ChevronLeft, ChevronRight } from '../icons.js'; /** * Previous / "Page x of y" / next. Rendered above and/or below the table. diff --git a/src/parts/per-page.js b/src/parts/per-page.js new file mode 100644 index 0000000..4c120ed --- /dev/null +++ b/src/parts/per-page.js @@ -0,0 +1,31 @@ +/** + * WordPress dependencies + */ +import { SelectControl } from '@wordpress/components'; + +/** + * Rows-per-page select (rendered only when `perPageOptions` is given). + * + * @param {Object} props + * @param {Object} props.table Table API. + * @param {Array} props.options Allowed sizes, e.g. [ 10, 20, 50 ]. + * @param {string} props.label Label. + */ +export function PerPage( { table, options, label } ) { + return ( + ( { + value: String( size ), + label: String( size ), + } ) ) } + onChange={ ( next ) => table.setPerPage( Number( next ) ) } + /> + ); +} diff --git a/src/parts/search-box.js b/src/parts/search-box.js new file mode 100644 index 0000000..a106a91 --- /dev/null +++ b/src/parts/search-box.js @@ -0,0 +1,44 @@ +/** + * WordPress dependencies + */ +import { SearchControl } from '@wordpress/components'; +import { useEffect, useRef, useState } from '@wordpress/element'; + +/** + * Search input that reports after a pause (`delay` ms), so a server-paged + * table does not fire a request per keystroke. Follows outside resets. + * + * @param {Object} props + * @param {string} props.value Current search. + * @param {Function} props.onChange Receives the settled value. + * @param {number} props.delay Debounce in ms (0 = immediate). + * @param {string} props.label Label / placeholder. + */ +export function SearchBox( { value, onChange, delay, label } ) { + const [ text, setText ] = useState( value ); + const timer = useRef(); + + useEffect( () => setText( value ), [ value ] ); + useEffect( () => () => clearTimeout( timer.current ), [] ); + + const change = ( next ) => { + setText( next ); + clearTimeout( timer.current ); + if ( ! delay || next === '' ) { + onChange( next ); + return; + } + timer.current = setTimeout( () => onChange( next ), delay ); + }; + + return ( + + ); +} diff --git a/src/parts/table-body.js b/src/parts/table-body.js new file mode 100644 index 0000000..50404f8 --- /dev/null +++ b/src/parts/table-body.js @@ -0,0 +1,86 @@ +/* eslint-disable jsx-a11y/no-redundant-roles, jsx-a11y/no-interactive-element-to-noninteractive-role -- explicit table roles on purpose: the mobile card layout sets display:block on table/tr/td, which drops the native semantics in some browsers (Safari/VoiceOver); the roles restore them. */ +/** + * Rows, skeleton rows (loading with nothing to show yet) and the cell labels + * the mobile card layout displays. The labels are aria-hidden: the explicit + * table roles keep the column-header relationship in every layout. + * + * @param {Object} props + * @param {Array} props.columns Columns. + * @param {Array} props.rows Rows to render. + * @param {Function} props.getRowId Row key. + * @param {boolean} props.showSkeleton Render placeholder rows. + * @param {number} props.skeletonRows How many. + * @param {Object} props.selection { isSelected( id ), toggle( id ), label( row ) } or null. + */ +export function TableBody( { + columns, + rows, + getRowId, + showSkeleton, + skeletonRows, + selection, +} ) { + if ( showSkeleton ) { + return ( + + { Array.from( { length: skeletonRows }, ( _, index ) => ( + + { selection && } + { columns.map( ( column ) => ( + + + + ) ) } + + ) ) } + + ); + } + + return ( + + { rows.map( ( row ) => { + const id = getRowId( row ); + const selected = selection?.isSelected( id ); + return ( + + { selection && ( + + selection.toggle( id ) } + /> + + ) } + { columns.map( ( column ) => ( + + + + { column.render + ? column.render( row ) + : row[ column.id ] } + + + ) ) } + + ); + } ) } + + ); +} diff --git a/src/parts/table-head.js b/src/parts/table-head.js new file mode 100644 index 0000000..aacda8d --- /dev/null +++ b/src/parts/table-head.js @@ -0,0 +1,68 @@ +/* eslint-disable jsx-a11y/no-redundant-roles -- explicit table roles on purpose: the mobile card layout sets display:block on table/tr/td, which drops the native semantics in some browsers (Safari/VoiceOver); the roles restore them. */ +/** + * Internal dependencies + */ +import { ArrowDown, ArrowUp } from '../icons.js'; +import { HeaderCheckbox } from './header-checkbox.js'; + +/** + * Column headers. `aria-sort` sits only on the sorted column; the button's + * name is just the column label (no "sortable" noise). + * + * @param {Object} props + * @param {Array} props.columns Columns. + * @param {Object} props.table Table API. + * @param {Object} props.selectAll Header checkbox props, or null. + */ +export function TableHead( { columns, table, selectAll } ) { + return ( + + + { selectAll && ( + + + + ) } + { columns.map( ( column ) => { + const sorted = table.sort?.field === column.id; + const asc = sorted && table.sort.direction === 'asc'; + let ariaSort; + if ( sorted ) { + ariaSort = asc ? 'ascending' : 'descending'; + } + return ( + + { column.sortable ? ( + + ) : ( + column.label + ) } + + ); + } ) } + + + ); +} diff --git a/src/parts/use-announce.js b/src/parts/use-announce.js new file mode 100644 index 0000000..6a32698 --- /dev/null +++ b/src/parts/use-announce.js @@ -0,0 +1,34 @@ +/** + * WordPress dependencies + */ +import { useEffect, useRef, useState } from '@wordpress/element'; + +/** + * Debounced polite announcement ("12 results") after the shown rows change — + * never on first render, never while loading, never per keystroke. + * + * @param {string} message Text to announce. + * @param {string} key Changes when the result set changes. + * @param {boolean} isLoading Skip while a request is in flight. + * @return {string} Current live-region text. + */ +export function useAnnounce( message, key, isLoading ) { + const [ text, setText ] = useState( '' ); + const first = useRef( true ); + + useEffect( () => { + if ( first.current ) { + first.current = false; + return; + } + if ( isLoading ) { + return; + } + const timer = setTimeout( () => setText( message ), 600 ); + return () => clearTimeout( timer ); + // Re-announce only when the result set changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ key, isLoading ] ); + + return text; +} diff --git a/src/query.js b/src/query.js new file mode 100644 index 0000000..1b4b864 --- /dev/null +++ b/src/query.js @@ -0,0 +1,91 @@ +/** + * The one state shape both modes share. Client mode keeps it in + * useTableState(); server mode receives it as `query` / `onQueryChange`. + */ +export const DEFAULT_QUERY = { + search: '', + filters: {}, + sort: undefined, + page: 1, + perPage: 20, +}; + +/** + * Merge a change into a query. Anything that changes what is shown — + * search, filters, sort, per page — returns to page 1. + * + * @param {Object} query Current query. + * @param {Object} patch Changed keys. + * @return {Object} Next query. + */ +export function patchQuery( query, patch ) { + const resets = [ 'search', 'filters', 'sort', 'perPage' ].some( + ( key ) => key in patch + ); + return { + ...query, + ...patch, + page: resets ? 1 : ( patch.page ?? query.page ), + }; +} + +/** + * The object drives: current rows/totals plus setters, all + * expressed as query changes. + * + * @param {Object} query Current query. + * @param {Function} setQuery Receives the next query. + * @param {Object} result { rows, total, totalPages, page }. + * @return {Object} Table API. + */ +export function createTableApi( query, setQuery, result ) { + const update = ( patch ) => setQuery( patchQuery( query, patch ) ); + const filters = query.filters || {}; + + return { + ...result, + query, + search: query.search, + filters, + sort: query.sort, + perPage: query.perPage, + setQuery: update, + setSearch: ( search ) => update( { search } ), + setPage: ( page ) => update( { page } ), + setPerPage: ( perPage ) => update( { perPage } ), + setFilter: ( field, values ) => + update( { filters: { ...filters, [ field ]: values } } ), + toggleFilter: ( field, value, multiple = true ) => { + const current = filters[ field ] || []; + let next = [ value ]; + if ( current.includes( value ) ) { + next = current.filter( ( item ) => item !== value ); + } else if ( multiple ) { + next = [ ...current, value ]; + } + update( { filters: { ...filters, [ field ]: next } } ); + }, + clearFilters: ( field ) => + update( { + filters: field ? { ...filters, [ field ]: [] } : {}, + } ), + // Same column flips; a new column starts in its default direction. + toggleSort: ( field, defaultDirection = 'desc' ) => { + let direction = defaultDirection; + if ( query.sort?.field === field ) { + direction = query.sort.direction === 'asc' ? 'desc' : 'asc'; + } + update( { sort: { field, direction } } ); + }, + }; +} + +/** + * Whether any search or filter narrows the rows. + * + * @param {Object} query Query. + * @return {boolean} Narrowed. + */ +export const isNarrowed = ( query ) => + Boolean( query.search?.trim() ) || + Object.values( query.filters || {} ).some( ( values ) => values?.length ); diff --git a/src/use-table-state.js b/src/use-table-state.js index 81c136a..a14d9d5 100644 --- a/src/use-table-state.js +++ b/src/use-table-state.js @@ -7,70 +7,37 @@ import { useMemo, useState } from '@wordpress/element'; * Internal dependencies */ import { filterSortPaginate } from './filter-sort-paginate.js'; +import { DEFAULT_QUERY, createTableApi } from './query.js'; /** - * Client-side table state for : search, one "any of" filter, - * sorting and paging. Any change to what is shown returns to page 1. + * Client-side table state for : search, filters, sorting, paging. * * @param {Array} rows All rows (null/undefined while loading). * @param {Object} options * @param {Array} options.searchFields Row keys the search looks in. - * @param {string} options.filterField Row key the filter chips apply to. - * @param {Object} options.sort Initial { field, direction }. - * @param {number} options.perPage Rows per page (default 20). - * @return {Object} Visible rows, totals and setters. + * @param {Array} options.columns Columns (for `sortValue` / `searchValue`). + * @param {Object} options.sort Initial sort { field, direction }. + * @param {number} options.perPage Initial rows per page (default 20). + * @param {Object} options.filters Initial filters { [field]: values }. + * @return {Object} Table API: visible rows, totals, query and setters. */ export function useTableState( rows, options = {} ) { - const { sort: initialSort, searchFields, filterField, perPage } = options; - const [ search, setSearch ] = useState( '' ); - const [ filter, setFilter ] = useState( [] ); - const [ sort, setSort ] = useState( initialSort ); - const [ page, setPage ] = useState( 1 ); + const { searchFields = [], columns = [], sort, perPage, filters } = options; + const [ query, setQuery ] = useState( () => ( { + ...DEFAULT_QUERY, + sort, + perPage: perPage ?? DEFAULT_QUERY.perPage, + filters: filters ?? {}, + } ) ); // Compared by content, so inline arrays do not recompute every render. - const searchKey = String( searchFields ); + const searchKey = searchFields.join( '|' ); const result = useMemo( - () => - filterSortPaginate( - rows, - { search, filter, sort, page }, - { searchFields, filterField, perPage } - ), + () => filterSortPaginate( rows, query, { searchFields, columns } ), // searchFields is tracked through searchKey (by content). // eslint-disable-next-line react-hooks/exhaustive-deps - [ rows, search, filter, sort, page, filterField, perPage, searchKey ] + [ rows, query, searchKey, columns ] ); - return { - ...result, - setPage, - search, - setSearch: ( value ) => { - setSearch( value ); - setPage( 1 ); - }, - filter, - toggleFilter: ( value ) => { - setFilter( ( prev ) => - prev.includes( value ) - ? prev.filter( ( item ) => item !== value ) - : [ ...prev, value ] - ); - setPage( 1 ); - }, - clearFilter: () => { - setFilter( [] ); - setPage( 1 ); - }, - sort, - // Same column flips direction; a new column starts descending. - toggleSort: ( field ) => - setSort( ( prev ) => ( { - field, - direction: - prev?.field === field && prev.direction === 'desc' - ? 'asc' - : 'desc', - } ) ), - }; + return createTableApi( query, setQuery, result ); } diff --git a/test/data-table.test.js b/test/data-table.test.js new file mode 100644 index 0000000..913669c --- /dev/null +++ b/test/data-table.test.js @@ -0,0 +1,320 @@ +// @vitest-environment jsdom +/** + * External dependencies + */ +import { + act, + cleanup, + fireEvent, + render, + screen, +} from '@testing-library/react'; +import axe from 'axe-core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { DataTable, useTableState, DEFAULT_QUERY } from '../src/index.js'; + +afterEach( cleanup ); + +const COLUMNS = [ + { id: 'name', label: 'Name', sortable: true, defaultSortDirection: 'asc' }, + { id: 'status', label: 'Status' }, +]; +const ROWS = [ + { id: 1, name: 'Anna', status: 'pending' }, + { id: 2, name: 'Béla', status: 'confirmed' }, + { id: 3, name: 'Cecil', status: 'pending' }, +]; +const STATUS = { + field: 'status', + label: 'Status', + options: [ + { value: 'pending', label: 'Pending' }, + { value: 'confirmed', label: 'Confirmed' }, + ], +}; + +function ClientTable( props ) { + const table = useTableState( ROWS, { + searchFields: [ 'name' ], + columns: COLUMNS, + } ); + return ( + + ); +} + +const names = () => + screen + .getAllByRole( 'row' ) + .slice( 1 ) + .map( + ( row ) => row.querySelector( '.lw-table__cell-value' ).textContent + ); + +describe( 'DataTable (client)', () => { + it( 'renders every row with explicit table roles', () => { + render( ); + + expect( screen.getByRole( 'table' ) ).toBeTruthy(); + expect( names() ).toEqual( [ 'Anna', 'Béla', 'Cecil' ] ); + } ); + + it( 'filters with chips and shows the pressed state with a check icon', () => { + render( ); + + const chip = screen.getByRole( 'button', { name: 'Pending' } ); + fireEvent.click( chip ); + + expect( chip.getAttribute( 'aria-pressed' ) ).toBe( 'true' ); + expect( chip.querySelector( 'svg' ) ).toBeTruthy(); + expect( names() ).toEqual( [ 'Anna', 'Cecil' ] ); + } ); + + it( 'separates "no results" from "nothing here yet"', () => { + render( + {} } + rows={ [] } + total={ 0 } + /> + ); + expect( screen.getByText( 'Nothing here yet.' ) ).toBeTruthy(); + cleanup(); + + render( + {} } + rows={ [] } + total={ 0 } + /> + ); + expect( screen.getByText( 'No matching entries.' ) ).toBeTruthy(); + } ); + + it( 'starts a column in its default sort direction', () => { + render( ); + + fireEvent.click( screen.getByRole( 'button', { name: 'Name' } ) ); + + expect( + screen + .getByRole( 'columnheader', { name: 'Name' } ) + .getAttribute( 'aria-sort' ) + ).toBe( 'ascending' ); + } ); +} ); + +describe( 'DataTable (server)', () => { + it( 'asks for the last page when the page is past the end', () => { + const onQueryChange = vi.fn(); + render( + + ); + + expect( onQueryChange ).toHaveBeenCalledWith( + expect.objectContaining( { page: 2 } ) + ); + } ); + + it( 'keeps the previous rows while loading and marks the region busy', () => { + const { container } = render( + {} } + isLoading + /> + ); + + expect( + container.querySelector( '.lw-table' ).getAttribute( 'aria-busy' ) + ).toBe( 'true' ); + expect( names() ).toEqual( [ 'Anna', 'Béla', 'Cecil' ] ); + } ); + + it( 'shows aria-hidden skeleton rows when loading with nothing to show', () => { + const { container } = render( + {} } + isLoading + /> + ); + + const body = container.querySelector( 'tbody' ); + expect( body.getAttribute( 'aria-hidden' ) ).toBe( 'true' ); + expect( + body.querySelectorAll( '.lw-table__skeleton' ).length + ).toBeGreaterThan( 0 ); + } ); + + it( 'renders an error as an alert with the retry slot', () => { + render( + {} } + error="Could not load bookings." + errorAction={ } + /> + ); + + expect( screen.getByRole( 'alert' ).textContent ).toContain( + 'Could not load bookings.' + ); + expect( screen.getByRole( 'button', { name: 'Retry' } ) ).toBeTruthy(); + } ); + + it( 'debounces search before reporting it', () => { + vi.useFakeTimers(); + const onQueryChange = vi.fn(); + render( + + ); + + fireEvent.change( screen.getByRole( 'searchbox' ), { + target: { value: 'ann' }, + } ); + expect( onQueryChange ).not.toHaveBeenCalled(); + act( () => vi.advanceTimersByTime( 300 ) ); + + expect( onQueryChange ).toHaveBeenCalledWith( + expect.objectContaining( { search: 'ann', page: 1 } ) + ); + vi.useRealTimers(); + } ); +} ); + +describe( 'selection and bulk actions', () => { + function Selectable( { onConfirm } ) { + const [ selected, setSelected ] = useState( [] ); + const table = useTableState( ROWS, { searchFields: [ 'name' ] } ); + return ( + row.name } + selection={ { selected, onChange: setSelected } } + bulkActions={ [ + { + id: 'confirm', + label: 'Confirm', + onClick: onConfirm, + isEligible: ( row ) => row.status === 'pending', + }, + ] } + /> + ); + } + + it( 'names row checkboxes after the row and runs actions on eligible rows', async () => { + const onConfirm = vi.fn(); + render( ); + + fireEvent.click( + screen.getByRole( 'checkbox', { name: 'Select: Anna' } ) + ); + fireEvent.click( + screen.getByRole( 'checkbox', { name: 'Select: Béla' } ) + ); + expect( screen.getByText( '2 selected' ) ).toBeTruthy(); + + await act( async () => + fireEvent.click( screen.getByRole( 'button', { name: /Confirm/ } ) ) + ); + + expect( + onConfirm.mock.calls[ 0 ][ 0 ].map( ( row ) => row.id ) + ).toEqual( [ 1 ] ); + } ); + + it( 'makes the header checkbox indeterminate for a partial selection', () => { + render( {} } /> ); + + fireEvent.click( + screen.getByRole( 'checkbox', { name: 'Select: Anna' } ) + ); + const header = screen.getByRole( 'checkbox', { + name: 'Select all rows on this page', + } ); + + expect( header.indeterminate ).toBe( true ); + fireEvent.click( header ); + expect( screen.getByText( '3 selected' ) ).toBeTruthy(); + } ); +} ); + +describe( 'accessibility', () => { + it( 'has no axe violations with filters, selection and pager', async () => { + function Full() { + const [ selected, setSelected ] = useState( [ 1 ] ); + const table = useTableState( ROWS, { searchFields: [ 'name' ] } ); + return ( + row.name } + selection={ { selected, onChange: setSelected } } + bulkActions={ [ + { + id: 'delete', + label: 'Delete', + onClick: () => {}, + isDestructive: true, + }, + ] } + /> + ); + } + const { container } = render( ); + + const results = await axe.run( container, { + rules: { region: { enabled: false } }, + } ); + + expect( + results.violations.map( ( v ) => `${ v.id }: ${ v.help }` ) + ).toEqual( [] ); + } ); +} ); diff --git a/test/filter-sort-paginate.test.js b/test/filter-sort-paginate.test.js index b4f9b1e..723ec22 100644 --- a/test/filter-sort-paginate.test.js +++ b/test/filter-sort-paginate.test.js @@ -6,18 +6,41 @@ import { describe, expect, it } from 'vitest'; /** * Internal dependencies */ -import { filterSortPaginate, normalize } from '../src/filter-sort-paginate.js'; +import { + compareValues, + filterSortPaginate, + matchesFilters, + normalize, +} from '../src/filter-sort-paginate.js'; const ROWS = [ - { id: 1, name: 'Kép-feltöltés.jpg', type: 'converted', size: 300 }, - { id: 2, name: 'logo.png', type: 'skipped', size: 10 }, - { id: 3, name: 'hero banner.jpg', type: 'converted', size: 900 }, - { id: 4, name: 'scan.tiff', type: 'failed', size: null }, - { id: 5, name: 'team photo.jpg', type: 'converted', size: 450 }, + { + id: 1, + name: 'Kép-feltöltés.jpg', + type: 'converted', + size: 300, + tags: [ 'a' ], + }, + { id: 2, name: 'logo.png', type: 'skipped', size: 10, tags: [ 'b' ] }, + { + id: 3, + name: 'hero banner.jpg', + type: 'converted', + size: 900, + tags: [ 'a', 'b' ], + }, + { id: 4, name: 'scan.tiff', type: 'failed', size: null, tags: [] }, + { + id: 5, + name: 'team photo.jpg', + type: 'converted', + size: 450, + tags: [ 'c' ], + }, ]; -const OPTIONS = { searchFields: [ 'name' ], filterField: 'type', perPage: 2 }; - +const OPTIONS = { searchFields: [ 'name' ] }; +const Q = ( query ) => ( { perPage: 2, ...query } ); const ids = ( result ) => result.rows.map( ( row ) => row.id ); describe( 'normalize', () => { @@ -31,46 +54,111 @@ describe( 'normalize', () => { } ); } ); +describe( 'compareValues', () => { + it( 'sorts accented initials with their base letter', () => { + const names = [ 'Zoltán', 'Ábel', 'Béla' ].sort( compareValues ); + expect( names ).toEqual( [ 'Ábel', 'Béla', 'Zoltán' ] ); + } ); + + it( 'sorts numeric strings as numbers', () => { + expect( [ '10', '9', '100' ].sort( compareValues ) ).toEqual( [ + '9', + '10', + '100', + ] ); + } ); +} ); + +describe( 'matchesFilters', () => { + it( 'matches any chosen value within a filter', () => { + expect( + matchesFilters( ROWS[ 1 ], { type: [ 'skipped', 'failed' ] } ) + ).toBe( true ); + } ); + + it( 'requires every filter to match', () => { + expect( + matchesFilters( ROWS[ 0 ], { + type: [ 'converted' ], + tags: [ 'b' ], + } ) + ).toBe( false ); + expect( + matchesFilters( ROWS[ 2 ], { + type: [ 'converted' ], + tags: [ 'b' ], + } ) + ).toBe( true ); + } ); + + it( 'matches array-valued fields that contain a chosen value', () => { + expect( matchesFilters( ROWS[ 2 ], { tags: [ 'b' ] } ) ).toBe( true ); + expect( matchesFilters( ROWS[ 3 ], { tags: [ 'b' ] } ) ).toBe( false ); + } ); + + it( 'ignores empty filters', () => { + expect( matchesFilters( ROWS[ 3 ], { type: [] } ) ).toBe( true ); + } ); +} ); + describe( 'filterSortPaginate', () => { it( 'returns the first page of all rows by default', () => { - const result = filterSortPaginate( ROWS, {}, OPTIONS ); + const result = filterSortPaginate( ROWS, Q(), OPTIONS ); expect( ids( result ) ).toEqual( [ 1, 2 ] ); expect( result ).toMatchObject( { total: 5, page: 1, totalPages: 3 } ); } ); it( 'matches search accent-insensitively', () => { - const result = filterSortPaginate( ROWS, { search: 'kep' }, OPTIONS ); - - expect( ids( result ) ).toEqual( [ 1 ] ); + expect( + ids( filterSortPaginate( ROWS, Q( { search: 'kep' } ), OPTIONS ) ) + ).toEqual( [ 1 ] ); } ); it( 'requires every search word to match', () => { expect( - ids( filterSortPaginate( ROWS, { search: 'hero jpg' }, OPTIONS ) ) + ids( + filterSortPaginate( ROWS, Q( { search: 'hero jpg' } ), OPTIONS ) + ) ).toEqual( [ 3 ] ); expect( - filterSortPaginate( ROWS, { search: 'hero png' }, OPTIONS ).total + filterSortPaginate( ROWS, Q( { search: 'hero png' } ), OPTIONS ) + .total ).toBe( 0 ); } ); - it( 'keeps only rows whose filter field is in the filter list', () => { + it( 'searches column searchValue() text', () => { + const columns = [ + { id: 'size', searchValue: ( row ) => `${ row.size } KB` }, + ]; + const result = filterSortPaginate( ROWS, Q( { search: '900 kb' } ), { + ...OPTIONS, + columns, + } ); + + expect( ids( result ) ).toEqual( [ 3 ] ); + } ); + + it( 'applies several filters together', () => { const result = filterSortPaginate( ROWS, - { filter: [ 'skipped', 'failed' ] }, + Q( { + perPage: 10, + filters: { type: [ 'converted' ], tags: [ 'a' ] }, + } ), OPTIONS ); - expect( ids( result ) ).toEqual( [ 2, 4 ] ); + expect( ids( result ) ).toEqual( [ 1, 3 ] ); } ); it.each( [ [ 'desc', [ 3, 5 ] ], [ 'asc', [ 2, 1 ] ], - ] )( 'sorts %s by a field, empty values last', ( direction, expected ) => { + ] )( 'sorts %s by a field', ( direction, expected ) => { const result = filterSortPaginate( ROWS, - { sort: { field: 'size', direction } }, + Q( { sort: { field: 'size', direction } } ), OPTIONS ); @@ -78,33 +166,41 @@ describe( 'filterSortPaginate', () => { } ); it( 'puts empty values at the end in both directions', () => { - const all = { ...OPTIONS, perPage: 10 }; - const asc = filterSortPaginate( - ROWS, - { sort: { field: 'size', direction: 'asc' } }, - all - ); - const desc = filterSortPaginate( + const all = ( direction ) => + ids( + filterSortPaginate( + ROWS, + { perPage: 10, sort: { field: 'size', direction } }, + OPTIONS + ) + ); + + expect( all( 'asc' ).at( -1 ) ).toBe( 4 ); + expect( all( 'desc' ).at( -1 ) ).toBe( 4 ); + } ); + + it( 'sorts by a column sortValue() instead of the raw field', () => { + const columns = [ + { id: 'name', sortValue: ( row ) => row.name.length }, + ]; + const result = filterSortPaginate( ROWS, - { sort: { field: 'size', direction: 'desc' } }, - all + { perPage: 1, sort: { field: 'name', direction: 'asc' } }, + { ...OPTIONS, columns } ); - expect( ids( asc ).at( -1 ) ).toBe( 4 ); - expect( ids( desc ).at( -1 ) ).toBe( 4 ); + expect( ids( result ) ).toEqual( [ 2 ] ); // "logo.png" is the shortest. } ); it( 'clamps an out-of-range page to the last page', () => { - const result = filterSortPaginate( ROWS, { page: 99 }, OPTIONS ); + const result = filterSortPaginate( ROWS, Q( { page: 99 } ), OPTIONS ); expect( result.page ).toBe( 3 ); expect( ids( result ) ).toEqual( [ 5 ] ); } ); it( 'reports one empty page for no rows', () => { - const result = filterSortPaginate( null, {}, OPTIONS ); - - expect( result ).toEqual( { + expect( filterSortPaginate( null, Q(), OPTIONS ) ).toEqual( { rows: [], total: 0, page: 1, @@ -116,7 +212,7 @@ describe( 'filterSortPaginate', () => { const copy = [ ...ROWS ]; filterSortPaginate( ROWS, - { sort: { field: 'size', direction: 'asc' } }, + Q( { sort: { field: 'size', direction: 'asc' } } ), OPTIONS ); diff --git a/test/query.test.js b/test/query.test.js new file mode 100644 index 0000000..08eba6a --- /dev/null +++ b/test/query.test.js @@ -0,0 +1,101 @@ +/** + * External dependencies + */ +import { describe, expect, it, vi } from 'vitest'; + +/** + * Internal dependencies + */ +import { + DEFAULT_QUERY, + createTableApi, + isNarrowed, + patchQuery, +} from '../src/query.js'; + +const on = ( query ) => { + const setQuery = vi.fn(); + const api = createTableApi( { ...DEFAULT_QUERY, ...query }, setQuery, { + rows: [], + total: 0, + totalPages: 1, + page: 1, + } ); + return { api, next: () => setQuery.mock.calls.at( -1 )[ 0 ] }; +}; + +describe( 'patchQuery', () => { + it.each( [ + [ 'search', 'x' ], + [ 'filters', { a: [ 1 ] } ], + [ 'sort', { field: 'a', direction: 'asc' } ], + [ 'perPage', 50 ], + ] )( 'returns to page 1 when %s changes', ( key, value ) => { + expect( + patchQuery( { ...DEFAULT_QUERY, page: 4 }, { [ key ]: value } ).page + ).toBe( 1 ); + } ); + + it( 'keeps an explicit page change', () => { + expect( + patchQuery( { ...DEFAULT_QUERY, page: 4 }, { page: 2 } ).page + ).toBe( 2 ); + } ); +} ); + +describe( 'createTableApi', () => { + it( 'adds and removes values of a multiple filter', () => { + const { api, next } = on( { filters: { status: [ 'a' ] } } ); + + api.toggleFilter( 'status', 'b' ); + expect( next().filters.status ).toEqual( [ 'a', 'b' ] ); + api.toggleFilter( 'status', 'a' ); + expect( next().filters.status ).toEqual( [] ); + } ); + + it( 'replaces the value of a single-choice filter', () => { + const { api, next } = on( { filters: { owner: [ 'x' ] } } ); + + api.toggleFilter( 'owner', 'y', false ); + expect( next().filters.owner ).toEqual( [ 'y' ] ); + } ); + + it( 'clears one filter or all of them', () => { + const { api, next } = on( { filters: { a: [ 1 ], b: [ 2 ] } } ); + + api.clearFilters( 'a' ); + expect( next().filters ).toEqual( { a: [], b: [ 2 ] } ); + api.clearFilters(); + expect( next().filters ).toEqual( {} ); + } ); + + it( 'starts a new sort column in its default direction, then flips', () => { + const first = on(); + first.api.toggleSort( 'name', 'asc' ); + expect( first.next().sort ).toEqual( { + field: 'name', + direction: 'asc', + } ); + + const again = on( { sort: { field: 'name', direction: 'asc' } } ); + again.api.toggleSort( 'name', 'asc' ); + expect( again.next().sort ).toEqual( { + field: 'name', + direction: 'desc', + } ); + } ); +} ); + +describe( 'isNarrowed', () => { + it( 'is true with a search or an active filter only', () => { + expect( isNarrowed( DEFAULT_QUERY ) ).toBe( false ); + expect( isNarrowed( { ...DEFAULT_QUERY, search: ' ' } ) ).toBe( false ); + expect( isNarrowed( { ...DEFAULT_QUERY, search: 'x' } ) ).toBe( true ); + expect( isNarrowed( { ...DEFAULT_QUERY, filters: { a: [] } } ) ).toBe( + false + ); + expect( + isNarrowed( { ...DEFAULT_QUERY, filters: { a: [ 1 ] } } ) + ).toBe( true ); + } ); +} ); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..300756d --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +// Source and tests write JSX in .js files (WordPress convention); let the +// transformer treat them as JSX with the automatic React runtime. +export default defineConfig( { + oxc: { + include: /\.js$/, + exclude: [], + lang: 'jsx', + jsx: { runtime: 'automatic' }, + }, +} );