diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..45899b8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,38 @@ +# CLAUDE.md + +Read `CONTEXT.md` for the vocabulary. Every term in it has one meaning. + +## UI is Radix Themes + +Build every DOM-rendered control out of `@radix-ui/themes` components — +`Button`, `Spinner`, `Popover`, `Checkbox`, `Text`, and the rest. Reach for the +Radix component first and check its API before writing markup; a hand-rolled +` + + ); + } + + return ( + <> + + 3 price breaks + + + + ); +} diff --git a/demo/src/grid/testGrid.ts b/demo/src/grid/testGrid.tsx similarity index 93% rename from demo/src/grid/testGrid.ts rename to demo/src/grid/testGrid.tsx index 2ce8ebd..8f4bbc5 100644 --- a/demo/src/grid/testGrid.ts +++ b/demo/src/grid/testGrid.tsx @@ -10,6 +10,7 @@ import { updateTestRow, } from "./api/testRowQueries"; import { regionCell, testGridCells } from "./testGridCells"; +import { RegionHeader } from "./RegionHeader"; export const GRID_NAME = "testGrid"; @@ -70,10 +71,6 @@ export const testGridDescriptor: GridDescriptor = { "updatedAt", "active", ], - // The default is "editable columns only", which would leave `id`, `sector`, - // `region`, and the derived `value` unsortable. Sorting a read-only column - // is a thing this page exists to exercise. - sortable: () => true, }, grouping: { field: "region", @@ -86,6 +83,11 @@ export const testGridDescriptor: GridDescriptor = { // The header reads its colour from the very cell that draws the column, so // a group's header text and that group's badges cannot drift apart. color: (region) => regionCell.colorOf(region), + + // The whole reason group headers left the canvas. Each region gets a + // DIFFERENT set of components, which is what the hook is for. + header: (region) => , + headerHeight: 44, }, api: { fetchRows: fetchTestRows, diff --git a/demo/vite.config.ts b/demo/vite.config.ts index 235f38c..150a143 100644 --- a/demo/vite.config.ts +++ b/demo/vite.config.ts @@ -1,6 +1,7 @@ -import { defineConfig } from "vite"; +import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; const here = (p: string) => fileURLToPath(new URL(p, import.meta.url)); @@ -13,10 +14,10 @@ const here = (p: string) => fileURLToPath(new URL(p, import.meta.url)); */ export default defineConfig({ root: here("."), - plugins: [react()], + plugins: [react(), tailwindcss()], resolve: { alias: [ - { find: "@matthewhsu1/datagrid/datagrid.css", replacement: here("../src/datagrid.css") }, + { find: "@matthewhsu1/datagrid/datagrid.css", replacement: here("../src/datagrid.src.css") }, { find: "@matthewhsu1/datagrid/radix-styles", replacement: here("../src/theme/radixStyles.ts"), diff --git a/justfile b/justfile new file mode 100644 index 0000000..642e56a --- /dev/null +++ b/justfile @@ -0,0 +1,65 @@ +# Show every command in this file. +default: + @just --list + +# Run every check that CI runs. +check: + npm run lint + npm run typecheck + npm test + npm run build + +# Show the version here, the newest tag, and what npm has. +status: + #!/usr/bin/env bash + set -euo pipefail + git fetch origin --prune --tags --quiet + pkg="$(node -p "require('./package.json').version")" + tag="$(git describe --tags --abbrev=0 2>/dev/null || echo none)" + published="$(npm view "$(node -p "require('./package.json').name")" version 2>/dev/null || echo none)" + echo "package.json : $pkg" + echo "newest tag : $tag" + echo "on npm : $published" + +# Publish a new version. LEVEL is patch, minor, or major. +release level="patch": + #!/usr/bin/env bash + set -euo pipefail + + case "{{level}}" in + patch|minor|major) ;; + *) echo "error: level must be patch, minor, or major"; exit 1 ;; + esac + + # A tag is only correct if it points at code that is already on GitHub. + # Everything below refuses to tag until that is true. + branch="$(git rev-parse --abbrev-ref HEAD)" + if [ "$branch" != "main" ]; then + echo "error: you are on '$branch'. Switch to main first." + exit 1 + fi + + if [ -n "$(git status --porcelain)" ]; then + echo "error: you have uncommitted changes. Commit or stash them first." + git status --short + exit 1 + fi + + git fetch origin --prune --tags + if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then + echo "error: main and origin/main are different. Push or pull first." + exit 1 + fi + + # Never tag code that does not build. A bad tag cannot be unpublished. + just check + + # npm version bumps package.json, commits, and tags -- in that order, + # on the newest commit. Do not do these steps by hand. + new="$(npm version {{level}} -m 'chore: release %s')" + + git push origin main + git push origin "$new" + + echo "" + echo "pushed $new. GitHub Actions is publishing it now." diff --git a/package-lock.json b/package-lock.json index 23dd145..4016e0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,8 @@ "@glideapps/glide-data-grid": "6.0.4-alpha24", "@radix-ui/themes": "^3.3.0", "@reduxjs/toolkit": "^2.12.0", + "@tailwindcss/cli": "^4.3.3", + "@tailwindcss/vite": "^4.3.3", "@tanstack/react-query": "^5.101.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -34,6 +36,7 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-redux": "^9.3.0", + "tailwindcss": "^4.3.3", "tsup": "^8.5.0", "typescript": "~6.0.2", "vite": "^8.1.1", @@ -998,6 +1001,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1489,202 +1503,375 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@radix-ui/colors": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", - "integrity": "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/number": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", - "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", - "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-accessible-icon": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.15.tgz", - "integrity": "sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==", + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", "dev": true, + "hasInstallScript": true, "license": "MIT", "dependencies": { - "@radix-ui/react-visually-hidden": "1.2.11" + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "engines": { + "node": ">= 10.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@radix-ui/react-accordion": { - "version": "1.2.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.20.tgz", - "integrity": "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==", + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-collapsible": "1.1.20", - "@radix-ui/react-collection": "1.1.15", - "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.4", - "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-controllable-state": "1.2.6" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.23", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.23.tgz", - "integrity": "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==", + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-dialog": "1.1.23", - "@radix-ui/react-primitive": "2.1.10" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", - "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.10" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.15.tgz", - "integrity": "sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ==", + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.10" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@radix-ui/react-avatar": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", - "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-callback-ref": "1.1.4", - "@radix-ui/react-use-is-hydrated": "0.1.3", - "@radix-ui/react-use-layout-effect": "1.1.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", - "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@radix-ui/colors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", + "integrity": "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.15.tgz", + "integrity": "sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-presence": "1.1.10", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-controllable-state": "1.2.6", - "@radix-ui/react-use-size": "1.1.4" + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -1701,21 +1888,22 @@ } } }, - "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz", - "integrity": "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==", + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.20.tgz", + "integrity": "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==", "dev": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-controllable-state": "1.2.6", - "@radix-ui/react-use-layout-effect": "1.1.4" + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -1732,17 +1920,18 @@ } } }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", - "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.23.tgz", + "integrity": "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==", "dev": true, "license": "MIT", "dependencies": { + "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-slot": "1.3.3" + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -1759,50 +1948,67 @@ } } }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", - "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", "dev": true, "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-context": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", - "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.15.tgz", + "integrity": "sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ==", "dev": true, "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-context-menu": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.7.tgz", - "integrity": "sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==", + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", + "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", "dev": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-controllable-state": "1.2.6" + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1819,24 +2025,172 @@ } } }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.1.23", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", - "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", + "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", "dev": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-dismissable-layer": "1.1.19", - "@radix-ui/react-focus-guards": "1.1.6", - "@radix-ui/react-focus-scope": "1.1.16", - "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz", + "integrity": "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.7.tgz", + "integrity": "sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", @@ -3594,41 +3948,616 @@ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", "cpu": [ - "riscv64" + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tabby_ai/hijri-converter": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", + "integrity": "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz", + "integrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "2.5.1", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "enhanced-resolve": "^5.24.1", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.3.3" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ - "linux" - ] + "freebsd" + ], + "engines": { + "node": ">= 20" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", - "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ - "riscv64" + "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">= 20" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", - "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ - "s390x" + "arm64" ], "dev": true, "libc": [ @@ -3638,74 +4567,105 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">= 20" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", - "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ - "x64" + "arm64" ], "dev": true, "libc": [ - "glibc" + "musl" ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">= 20" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", - "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], "dev": true, "libc": [ - "musl" + "glibc" ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">= 20" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", - "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ - "openbsd" - ] + "linux" + ], + "engines": { + "node": ">= 20" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", - "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "openharmony" - ] + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", - "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -3714,26 +4674,15 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", - "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", - "cpu": [ - "ia32" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": ">= 20" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", - "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -3742,43 +4691,24 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", - "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" + "engines": { + "node": ">= 20" + } }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "dev": true, - "license": "MIT" - }, - "node_modules/@tabby_ai/hijri-converter": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", - "integrity": "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==", "license": "MIT", - "engines": { - "node": ">=16.0.0" + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "node_modules/@tanstack/devtools-event-client": { @@ -4340,6 +5270,19 @@ "balanced-match": "^1.0.0" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -4629,6 +5572,20 @@ "dev": true, "license": "MIT" }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -4776,6 +5733,19 @@ } } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -4850,6 +5820,13 @@ "node": ">=6" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/graphql": { "version": "16.14.2", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", @@ -4962,6 +5939,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4972,6 +5959,19 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-node-process": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", @@ -4979,6 +5979,16 @@ "dev": true, "license": "MIT" }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.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", @@ -4986,6 +5996,16 @@ "dev": true, "license": "MIT" }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -5462,6 +6482,33 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -5501,6 +6548,16 @@ "ufo": "^1.6.3" } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5594,6 +6651,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6595,6 +7659,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -6679,6 +7764,19 @@ "dev": true, "license": "MIT" }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/tough-cookie": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", diff --git a/package.json b/package.json index 47d86d4..a80871d 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "node": ">=20.19.0" }, "scripts": { - "build": "tsup && node scripts/copy-assets.mjs", + "build": "tsup && npm run build:css", + "build:css": "tailwindcss -i src/datagrid.src.css -o dist/datagrid.css", "typecheck": "tsc -b", "lint": "oxlint", "format": "prettier --write .", @@ -74,6 +75,8 @@ "@glideapps/glide-data-grid": "6.0.4-alpha24", "@radix-ui/themes": "^3.3.0", "@reduxjs/toolkit": "^2.12.0", + "@tailwindcss/cli": "^4.3.3", + "@tailwindcss/vite": "^4.3.3", "@tanstack/react-query": "^5.101.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -89,6 +92,7 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-redux": "^9.3.0", + "tailwindcss": "^4.3.3", "tsup": "^8.5.0", "typescript": "~6.0.2", "vite": "^8.1.1", diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs deleted file mode 100644 index 34b3df5..0000000 --- a/scripts/copy-assets.mjs +++ /dev/null @@ -1,8 +0,0 @@ -// tsup emits JS and types; the stylesheet is a plain file that just needs to -// land beside them, because `exports["./datagrid.css"]` points into dist/. -import { copyFileSync, mkdirSync } from "node:fs"; - -mkdirSync("dist", { recursive: true }); -copyFileSync("src/datagrid.css", "dist/datagrid.css"); - -console.log("copied src/datagrid.css -> dist/datagrid.css"); diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx deleted file mode 100644 index 73612c9..0000000 --- a/src/components/ui/calendar.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from "lucide-react"; -import { type ComponentProps } from "react"; -import { DayPicker } from "react-day-picker"; -import { type ChevronProps } from "react-day-picker"; -import "react-day-picker/style.css"; -import { cx } from "../../lib/utils"; - -export type CalendarProps = ComponentProps; - -/** - * shadcn-style Calendar: a thin react-day-picker wrapper. Accent colors come - * from react-day-picker's own `--rdp-accent-color`, bound to Radix's `--accent-*` - * in the `.dg-date-editor` scope (see datagrid.css), so the calendar tracks the - * host app's Radix Themes config at runtime. - */ -export function Calendar({ className, showOutsideDays = true, ...props }: CalendarProps) { - return ( - { - const Icon = - orientation === "left" - ? ChevronLeft - : orientation === "right" - ? ChevronRight - : orientation === "up" - ? ChevronUp - : ChevronDown; - return ; - }, - }} - {...props} - /> - ); -} diff --git a/src/components/ui/datePicker.test.tsx b/src/components/ui/datePicker.test.tsx new file mode 100644 index 0000000..e210adc --- /dev/null +++ b/src/components/ui/datePicker.test.tsx @@ -0,0 +1,311 @@ +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactElement } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { DatePicker } from "./datePicker"; + +/** The picker paints with Radix tokens, so every case mounts it inside a Theme. */ +function mount(ui: ReactElement) { + return render({ui}); +} + +const dateInput = () => screen.getByLabelText("Date"); +const okButton = () => screen.getByRole("button", { name: "OK" }); + +/** Local midnight, matching how the picker reads a calendar day. */ +const localIso = (y: number, m: number, d: number, hh = 0, mm = 0) => + new Date(y, m - 1, d, hh, mm).toISOString(); + +const utcIso = (y: number, m: number, d: number) => new Date(Date.UTC(y, m - 1, d)).toISOString(); + +describe("DatePicker: date mode", () => { + it("shows the incoming value in the masked input", () => { + mount( + , + ); + + expect(dateInput()).toHaveValue("01/01/2000"); + }); + + it("types the slashes for the user", async () => { + const user = userEvent.setup(); + + mount(); + await user.type(dateInput(), "06202026"); + + expect(dateInput()).toHaveValue("06/20/2026"); + }); + + it("holds the edit until OK is pressed", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + + mount(); + await user.type(dateInput(), "06202026"); + + expect(onConfirm).not.toHaveBeenCalled(); + + await user.click(okButton()); + + expect(onConfirm).toHaveBeenCalledExactlyOnceWith(utcIso(2026, 6, 20)); + }); + + it("confirms a day picked on the calendar", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + + mount( + , + ); + await user.click(screen.getByRole("button", { name: /June 20th, 2026/ })); + await user.click(okButton()); + + expect(onConfirm).toHaveBeenCalledExactlyOnceWith(utcIso(2026, 6, 20)); + }); + + it("mirrors a calendar click back into the masked input", async () => { + const user = userEvent.setup(); + + mount( + , + ); + await user.click(screen.getByRole("button", { name: /June 20th, 2026/ })); + + expect(dateInput()).toHaveValue("06/20/2026"); + }); + + it("throws the edit away on Cancel", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + + mount(); + await user.type(dateInput(), "06202026"); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onCancel).toHaveBeenCalledOnce(); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it("cancels on Escape", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + + mount(); + await user.type(dateInput(), "{Escape}"); + + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it("confirms on Enter in the masked input", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + + mount(); + await user.type(dateInput(), "06202026{Enter}"); + + expect(onConfirm).toHaveBeenCalledExactlyOnceWith(utcIso(2026, 6, 20)); + }); + + it("blocks OK while the typed date is incomplete", async () => { + const user = userEvent.setup(); + + mount(); + await user.type(dateInput(), "0620"); + + expect(okButton()).toBeDisabled(); + }); + + it("blocks OK on an empty date when the field is not nullable", () => { + mount(); + + expect(okButton()).toBeDisabled(); + }); + + it("confirms null from an empty date when nullable", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + + mount( + , + ); + await user.click(okButton()); + + expect(onConfirm).toHaveBeenCalledExactlyOnceWith(null); + }); + + it("clears a set value back to null when nullable", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + + mount( + , + ); + await user.clear(dateInput()); + await user.click(okButton()); + + expect(onConfirm).toHaveBeenCalledExactlyOnceWith(null); + }); + + it("shows no time input", () => { + mount(); + + expect(screen.queryByLabelText("Time")).not.toBeInTheDocument(); + }); +}); + +describe("DatePicker: datetime mode", () => { + it("shows the instant's local time", () => { + mount( + , + ); + + expect(screen.getByLabelText("Time")).toHaveValue("13:30"); + }); + + it("confirms the typed date at the typed time", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + + mount(); + await user.type(dateInput(), "06202026"); + await user.clear(screen.getByLabelText("Time")); + await user.type(screen.getByLabelText("Time"), "09:15"); + await user.click(okButton()); + + expect(onConfirm).toHaveBeenCalledExactlyOnceWith(localIso(2026, 6, 20, 9, 15)); + }); +}); + +describe("DatePicker: range mode", () => { + it("shows a start and an end input", () => { + mount(); + + expect(screen.getByLabelText("Start date")).toBeInTheDocument(); + expect(screen.getByLabelText("End date")).toBeInTheDocument(); + }); + + it("confirms both ends of the range", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + + mount(); + await user.type(screen.getByLabelText("Start date"), "06012026"); + await user.type(screen.getByLabelText("End date"), "06202026"); + await user.click(okButton()); + + expect(onConfirm).toHaveBeenCalledExactlyOnceWith({ + from: utcIso(2026, 6, 1), + to: utcIso(2026, 6, 20), + }); + }); + + it("shows the incoming range in both inputs", () => { + mount( + , + ); + + expect(screen.getByLabelText("Start date")).toHaveValue("06/01/2026"); + expect(screen.getByLabelText("End date")).toHaveValue("06/20/2026"); + }); + + it("blocks OK until both ends are complete", async () => { + const user = userEvent.setup(); + + mount(); + await user.type(screen.getByLabelText("Start date"), "06012026"); + + expect(okButton()).toBeDisabled(); + }); + + it("blocks OK when the end falls before the start", async () => { + const user = userEvent.setup(); + + mount(); + await user.type(screen.getByLabelText("Start date"), "06202026"); + await user.type(screen.getByLabelText("End date"), "06012026"); + + expect(okButton()).toBeDisabled(); + }); +}); + +describe("DatePicker: range-datetime mode", () => { + it("shows a time input for each end", () => { + mount( + , + ); + + expect(screen.getByLabelText("Start time")).toBeInTheDocument(); + expect(screen.getByLabelText("End time")).toBeInTheDocument(); + }); + + it("confirms both ends at their own times", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + + mount( + , + ); + await user.type(screen.getByLabelText("Start date"), "06012026"); + await user.type(screen.getByLabelText("Start time"), "09:00"); + await user.type(screen.getByLabelText("End date"), "06202026"); + await user.type(screen.getByLabelText("End time"), "17:30"); + await user.click(okButton()); + + expect(onConfirm).toHaveBeenCalledExactlyOnceWith({ + from: localIso(2026, 6, 1, 9, 0), + to: localIso(2026, 6, 20, 17, 30), + }); + }); +}); + +describe("DatePicker: calendar follows the typed date", () => { + it("jumps the calendar to the month the user types", async () => { + const user = userEvent.setup(); + + mount( + , + ); + + expect(screen.getByRole("button", { name: /June 20th, 2026/ })).toBeInTheDocument(); + + await user.clear(dateInput()); + await user.type(dateInput(), "11092001"); + + expect(screen.getByRole("button", { name: /November 9th, 2001/ })).toBeInTheDocument(); + }); + + it("leaves the calendar alone while the date is half typed", async () => { + const user = userEvent.setup(); + + mount( + , + ); + await user.clear(dateInput()); + await user.type(dateInput(), "1109"); + + expect(screen.getByRole("button", { name: /June 20th, 2026/ })).toBeInTheDocument(); + }); +}); diff --git a/src/components/ui/datePicker.tsx b/src/components/ui/datePicker.tsx new file mode 100644 index 0000000..ec957f3 --- /dev/null +++ b/src/components/ui/datePicker.tsx @@ -0,0 +1,330 @@ +import { Button, Flex, Text } from "@radix-ui/themes"; +import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from "lucide-react"; +import { useId, useState, type KeyboardEvent } from "react"; +import { + DayPicker, + type ChevronProps, + type DateRange as CalendarRange, +} from "react-day-picker"; +import { PatternFormat } from "react-number-format"; +import { dateToMask, maskToDate } from "../../lib/date/dateMask"; +import { composeIso, isoToCalendarDate, isoToTimeInput } from "../../lib/date/dateUtils"; +import { dayPickerClassNames } from "./dayPickerTheme"; +import { Input } from "./input"; + +/* + * How many years the year dropdown spans. It is a NATIVE select, so its length + * is a scroll the user pays for: a thousand entries is unusable. These cover a + * lifetime back and scheduling forward, and the masked input takes any date + * outside them without touching the dropdown at all. + */ +const YEARS_BACK = 120; +const YEARS_FORWARD = 10; + +/** A pair of canonical ISO strings. `null` on an end means that end is unset. */ +export interface DateRange { + from: string | null; + to: string | null; +} + +export type DatePickerMode = "date" | "datetime" | "range" | "range-datetime"; + +interface CommonProps { + /** Called when the user abandons the edit (Cancel, or Escape). */ + onCancel: () => void; + /** Whether an empty value may be confirmed. Defaults to false. */ + nullable?: boolean; + /** Focus the first field on mount. Defaults to true. */ + autoFocus?: boolean; +} + +export type DatePickerProps = + | (CommonProps & { + mode: "date" | "datetime"; + value: string | null; + onConfirm: (value: string | null) => void; + }) + | (CommonProps & { + mode: "range" | "range-datetime"; + value: DateRange | null; + onConfirm: (value: DateRange | null) => void; + }); + +/** The range arms of the props union, as one type. */ +type RangeProps = Extract; + +/** + * Narrows the props to their range arms. A plain boolean would answer the same + * question, but only a guard carries the answer into `props.value` and + * `props.onConfirm`, which change shape with the mode. + */ +function isRangeProps(props: DatePickerProps): props is RangeProps { + return props.mode === "range" || props.mode === "range-datetime"; +} + +/** One end of the picker's draft: the digits in its mask, and its time-of-day. */ +interface Endpoint { + digits: string; + time: string; +} + +function toEndpoint(iso: string | null, withTime: boolean): Endpoint { + return { + digits: dateToMask(isoToCalendarDate(iso, withTime)), + time: isoToTimeInput(iso), + }; +} + +/** + * A date, date+time, date range, or date-range+time picker. + * + * The edit is HELD until the user confirms: `onConfirm` fires once, on OK or on + * Enter in a text field, and never on an intermediate click. `onCancel` fires on + * Cancel or Escape. `mode` decides the shape of `value` and of `onConfirm`. + * + * Values are the canonical ISO strings the rest of the engine speaks (see + * `lib/date/dateUtils`): a date-only value is pinned to UTC midnight; a value + * with a time is a real instant read from the viewer's local wall clock. + * + * Renders inline, with no popover of its own — a grid cell editor already floats + * it. Wrap it in `DatePickerPopover` for a trigger in ordinary DOM. + */ +export function DatePicker(props: DatePickerProps) { + const { mode, nullable = false, onCancel, autoFocus = true } = props; + + const isRange = isRangeProps(props); + const withTime = mode === "datetime" || mode === "range-datetime"; + + const incoming: DateRange = isRangeProps(props) + ? (props.value ?? { from: null, to: null }) + : { from: props.value, to: null }; + + const [start, setStart] = useState(() => toEndpoint(incoming.from, withTime)); + const [end, setEnd] = useState(() => toEndpoint(incoming.to, withTime)); + + // The displayed month follows a COMPLETE date, typed or clicked. A half-typed + // mask leaves it where it is, so the calendar doesn't lurch on every keystroke. + const [month, setMonth] = useState(() => isoToCalendarDate(incoming.from, withTime)); + + const changeStart = (next: Endpoint) => { + setStart(next); + + const day = maskToDate(next.digits); + + if (day) setMonth(day); + }; + + const changeEnd = (next: Endpoint) => { + setEnd(next); + + const day = maskToDate(next.digits); + + if (day) setMonth(day); + }; + + const startDate = maskToDate(start.digits); + const endDate = maskToDate(end.digits); + + const canConfirm = (() => { + if (isRange) { + if (start.digits === "" && end.digits === "") return nullable; + if (!startDate || !endDate) return false; + + return startDate.getTime() <= endDate.getTime(); + } + + if (start.digits === "") return nullable; + + return Boolean(startDate); + })(); + + const confirm = () => { + if (!canConfirm) return; + + if (isRangeProps(props)) { + if (!startDate || !endDate) { + props.onConfirm(null); + return; + } + + props.onConfirm({ + from: composeIso(startDate, start.time, withTime), + to: composeIso(endDate, end.time, withTime), + }); + return; + } + + props.onConfirm(startDate ? composeIso(startDate, start.time, withTime) : null); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.stopPropagation(); + onCancel(); + return; + } + + // Enter only confirms from a text field. On a day button it belongs to the + // calendar, and on OK/Cancel the click handler already fires. + if (event.key === "Enter" && event.target instanceof HTMLInputElement) { + event.preventDefault(); + confirm(); + } + }; + + const handleSelectSingle = (day?: Date) => { + setStart((prev) => ({ ...prev, digits: dateToMask(day) })); + }; + + const handleSelectRange = (range?: CalendarRange) => { + setStart((prev) => ({ ...prev, digits: dateToMask(range?.from) })); + setEnd((prev) => ({ ...prev, digits: dateToMask(range?.to) })); + }; + + const currentYear = new Date().getFullYear(); + const classNames = dayPickerClassNames(isRange); + const components = { Chevron }; + + return ( +
+
+ + {withTime && ( + + )} + {isRange && ( + <> + + {withTime && } + + )} +
+ + {isRange ? ( + + ) : ( + + )} + + + + + +
+ ); +} + +function Chevron({ orientation }: ChevronProps) { + const Icon = + orientation === "left" + ? ChevronLeft + : orientation === "right" + ? ChevronRight + : orientation === "up" + ? ChevronUp + : ChevronDown; + + return ; +} + +interface FieldProps { + label: string; + endpoint: Endpoint; + onChange: (next: Endpoint) => void; + autoFocus?: boolean; +} + +function MaskedDateField({ label, endpoint, onChange, autoFocus = false }: FieldProps) { + const id = useId(); + const incomplete = endpoint.digits.length > 0 && !maskToDate(endpoint.digits); + + return ( + + onChange({ ...endpoint, digits: values.value })} + customInput={Input} + invalid={incomplete} + autoFocus={autoFocus} + className="dg:w-[7.5rem]" + /> + + ); +} + +function TimeField({ label, endpoint, onChange }: FieldProps) { + const id = useId(); + + return ( + + onChange({ ...endpoint, time: event.target.value })} + className="dg:w-[6.5rem]" + /> + + ); +} + +function FieldShell({ + id, + label, + children, +}: { + id: string; + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {label} + + {children} +
+ ); +} diff --git a/src/components/ui/datePickerPopover.test.tsx b/src/components/ui/datePickerPopover.test.tsx new file mode 100644 index 0000000..89933dc --- /dev/null +++ b/src/components/ui/datePickerPopover.test.tsx @@ -0,0 +1,88 @@ +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactElement } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { DatePickerPopover } from "./datePickerPopover"; + +function mount(ui: ReactElement) { + return render({ui}); +} + +const utcIso = (y: number, m: number, d: number) => new Date(Date.UTC(y, m - 1, d)).toISOString(); + +const trigger = () => screen.getByRole("button", { name: /date|1\/1\/2000|6\/1\/2026/i }); + +describe("DatePickerPopover", () => { + it("labels the trigger with the placeholder when there is no value", () => { + mount( + , + ); + + expect(screen.getByRole("button", { name: "Pick a date" })).toBeInTheDocument(); + }); + + it("labels the trigger with the formatted value", () => { + mount(); + + expect(screen.getByRole("button", { name: "1/1/2000" })).toBeInTheDocument(); + }); + + it("labels the trigger with both ends of a range", () => { + mount( + , + ); + + expect(screen.getByRole("button", { name: "6/1/2026 – 6/20/2026" })).toBeInTheDocument(); + }); + + it("keeps the picker closed until the trigger is clicked", () => { + mount(); + + expect(screen.queryByLabelText("Date")).not.toBeInTheDocument(); + }); + + it("opens the picker on the trigger", async () => { + const user = userEvent.setup(); + + mount(); + await user.click(trigger()); + + expect(screen.getByLabelText("Date")).toBeInTheDocument(); + }); + + it("reports the confirmed value and closes", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + mount(); + await user.click(trigger()); + await user.type(screen.getByLabelText("Date"), "06202026"); + await user.click(screen.getByRole("button", { name: "OK" })); + + expect(onChange).toHaveBeenCalledExactlyOnceWith(utcIso(2026, 6, 20)); + expect(screen.queryByLabelText("Date")).not.toBeInTheDocument(); + }); + + it("reports nothing and closes on Cancel", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + mount(); + await user.click(trigger()); + await user.type(screen.getByLabelText("Date"), "06202026"); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onChange).not.toHaveBeenCalled(); + expect(screen.queryByLabelText("Date")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/ui/datePickerPopover.tsx b/src/components/ui/datePickerPopover.tsx new file mode 100644 index 0000000..b0c66a8 --- /dev/null +++ b/src/components/ui/datePickerPopover.tsx @@ -0,0 +1,106 @@ +import { Button, Popover } from "@radix-ui/themes"; +import { CalendarIcon } from "lucide-react"; +import { useState } from "react"; +import { formatDateDisplay } from "../../lib/date/dateUtils"; +import { DatePicker, type DateRange } from "./datePicker"; + +interface PopoverExtras { + /** Trigger label when there is no value. Defaults to "Select date". */ + placeholder?: string; + /** Whether an empty value may be confirmed. Defaults to false. */ + nullable?: boolean; + disabled?: boolean; +} + +export type DatePickerPopoverProps = + | (PopoverExtras & { + mode: "date" | "datetime"; + value: string | null; + /** Called once with the confirmed value. Never called on Cancel. */ + onChange: (value: string | null) => void; + }) + | (PopoverExtras & { + mode: "range" | "range-datetime"; + value: DateRange | null; + /** Called once with the confirmed value. Never called on Cancel. */ + onChange: (value: DateRange | null) => void; + }); + +/** The range arms of the props union, as one type. */ +type RangePopoverProps = Extract; + +function isRangeProps(props: DatePickerPopoverProps): props is RangePopoverProps { + return props.mode === "range" || props.mode === "range-datetime"; +} + +const EN_DASH = "–"; + +function triggerLabel(props: DatePickerPopoverProps, placeholder: string): string { + const withTime = props.mode === "datetime" || props.mode === "range-datetime"; + + if (isRangeProps(props)) { + if (!props.value?.from || !props.value.to) return placeholder; + + const from = formatDateDisplay(props.value.from, withTime); + const to = formatDateDisplay(props.value.to, withTime); + + return `${from} ${EN_DASH} ${to}`; + } + + if (!props.value) return placeholder; + + return formatDateDisplay(props.value, withTime); +} + +/** + * The DOM-side adapter for `DatePicker`: a Radix `Popover` whose trigger shows + * the current value and whose content is the picker itself. + * + * Grid cells do NOT use this — glide-data-grid already floats a cell's editor, + * so those mount `DatePicker` bare. Everything outside the canvas uses this one. + * + * The popover closes on confirm AND on cancel; `onChange` fires only on confirm. + */ +export function DatePickerPopover(props: DatePickerPopoverProps) { + const { placeholder = "Select date", nullable, disabled } = props; + const [open, setOpen] = useState(false); + + const close = () => setOpen(false); + + return ( + + + + + + + {isRangeProps(props) ? ( + { + close(); + props.onChange(value); + }} + onCancel={close} + /> + ) : ( + { + close(); + props.onChange(value); + }} + onCancel={close} + /> + )} + + + ); +} diff --git a/src/components/ui/dayPickerTheme.ts b/src/components/ui/dayPickerTheme.ts new file mode 100644 index 0000000..d920304 --- /dev/null +++ b/src/components/ui/dayPickerTheme.ts @@ -0,0 +1,119 @@ +import type { ClassNames } from "react-day-picker"; + +/** + * The shadcn calendar's shape, painted in Radix tokens. + * + * We do NOT import `react-day-picker/style.css`. That sheet is the library's own + * look, and layering our classes on top of it would leave which rule wins up to + * stylesheet order. Every slot below is the whole look for that slot instead. + * + * Two consequences of the package's own rules are load-bearing here: + * + * 1. NO PREFLIGHT. `datagrid.src.css` omits Tailwind's reset on purpose, so a + * bare ` + + + + dispatch(instance.actions.toggleColumn(field))} + onReset={() => dispatch(instance.actions.resetColumns())} + /> + + + ); +} diff --git a/src/features/dataGrid/ColumnPickerList.test.tsx b/src/features/dataGrid/ColumnPickerList.test.tsx new file mode 100644 index 0000000..19d0c7a --- /dev/null +++ b/src/features/dataGrid/ColumnPickerList.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ColumnPickerList } from "./ColumnPickerList"; + +const COLUMNS = [ + { field: "id", title: "ID", hidden: false }, + { field: "name", title: "Name", hidden: false }, + { field: "region", title: "Region", hidden: false, grouped: true }, + { field: "contact", title: "Contact", hidden: true }, +]; + +function renderList(props: Partial[0]> = {}) { + return render( + {}} onReset={() => {}} {...props} />, + ); +} + +describe("ColumnPickerList", () => { + it("lists every column, hidden ones included", () => { + renderList(); + + expect(screen.getAllByRole("checkbox")).toHaveLength(4); + expect(screen.getByRole("checkbox", { name: /contact/i })).toBeInTheDocument(); + }); + + it("shows which columns the grid is currently drawing", () => { + renderList(); + + expect(screen.getByRole("checkbox", { name: /^name/i })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: /contact/i })).not.toBeChecked(); + }); + + it("names the field, not the position, when a column is toggled", async () => { + const onToggle = vi.fn(); + renderList({ onToggle }); + + await userEvent.click(screen.getByRole("checkbox", { name: /contact/i })); + + expect(onToggle).toHaveBeenCalledWith("contact"); + }); + + it("lets the grouped column be hidden like any other", async () => { + // The group banner names the value on its own, so the column it came from + // is the one a user is most likely to want out of the way. + const onToggle = vi.fn(); + renderList({ onToggle }); + + const region = screen.getByRole("checkbox", { name: /region/i }); + expect(region).toBeEnabled(); + + await userEvent.click(region); + expect(onToggle).toHaveBeenCalledWith("region"); + }); + + it("says which column the grid groups by", () => { + renderList(); + + expect(screen.getByRole("checkbox", { name: /grouped by/i })).toBeInTheDocument(); + }); + + it("puts every column back the way the descriptor described it", async () => { + const onReset = vi.fn(); + renderList({ onReset }); + + await userEvent.click(screen.getByRole("button", { name: /reset/i })); + + expect(onReset).toHaveBeenCalledTimes(1); + }); + + it("toggles from the row's text, not just the box itself", async () => { + const onToggle = vi.fn(); + renderList({ onToggle }); + + await userEvent.click(screen.getByText("Name")); + + expect(onToggle).toHaveBeenCalledWith("name"); + }); +}); diff --git a/src/features/dataGrid/ColumnPickerList.tsx b/src/features/dataGrid/ColumnPickerList.tsx new file mode 100644 index 0000000..9c91f00 --- /dev/null +++ b/src/features/dataGrid/ColumnPickerList.tsx @@ -0,0 +1,84 @@ +import { Badge, Button, Checkbox, Flex, Text } from "@radix-ui/themes"; +import { useId } from "react"; + +export interface PickerColumn { + field: string; + title: string; + + /** The grid is not drawing this column. */ + hidden: boolean; + + /** + * The grid groups by this column. + */ + grouped?: boolean; +} + +/** + * The body of the column picker: one checkbox per column, plus a reset. + * + * Show and hide only. Reordering is already a drag on the column itself, and a + * second way to do one thing is a second set of edge cases — a drag here and a + * drag there disagreeing about where a column went. + * + * Split out of the popover it lives in so it can be tested without one; every + * decision the picker makes is here, where a plain render reaches it. + */ +export function ColumnPickerList({ + columns, + onToggle, + onReset, +}: { + columns: readonly PickerColumn[]; + onToggle: (field: string) => void; + onReset: () => void; +}) { + // Scoped per mount rather than per field. Field names are unique within a + // grid but not across two grids on one page, and `aria-labelledby` resolves + // against the whole document. + const scope = useId(); + + return ( + + + + Columns + + + + + + + {columns.map((c) => { + const titleId = `${scope}-${c.field}-title`; + const groupId = `${scope}-${c.field}-grouped`; + + return ( + + + onToggle(c.field)} + // The name has to carry the grouping as well as the title, so + // the badge below is not the only place it is written. + aria-labelledby={c.grouped === true ? `${titleId} ${groupId}` : titleId} + /> + + {c.title} + + {c.grouped === true && ( + + Grouped by + + )} + + + ); + })} + + + ); +} diff --git a/src/features/dataGrid/DataGrid.tsx b/src/features/dataGrid/DataGrid.tsx index 33d0d5a..feadd0a 100644 --- a/src/features/dataGrid/DataGrid.tsx +++ b/src/features/dataGrid/DataGrid.tsx @@ -8,19 +8,43 @@ import { specFromGridSort } from "./data/sortSpec"; import { ensureGridPortal } from "./ensurePortal"; import { useRowSync } from "./data/sync/useRowSync"; import type { DisplayModel } from "./displayModel"; -import { GridStatusBar } from "./GridStatusBar"; +import { GroupHeaderLayer } from "./GroupHeaderLayer"; +import { COLUMN_HEADER_HEIGHT } from "./rowHeights"; +import { ColumnPicker } from "./ColumnPicker"; +import { useColumnSortMenu } from "./hooks/useColumnSortMenu"; +import { GridHeaderBar } from "./GridHeaderBar"; import { sortHeaderIcons } from "./headerIcons"; import { useCellRenderer } from "./hooks/useCellRenderer"; import { useDisplayModel } from "./hooks/useDisplayModel"; import { useGridColumns } from "./hooks/useGridColumns"; import { useGridData } from "./hooks/useGridData"; import { useGridSelection } from "./hooks/useGridSelection"; -import { useGridSort } from "./hooks/useGridSort"; +import { useGroupBanner } from "./hooks/useGroupBanner"; +import { useGroupHeaders } from "./hooks/useGroupHeaders"; import { useRepaintRows } from "./hooks/useRepaintRows"; import { useWindowRange, type RangeLoaded } from "./hooks/useWindowRange"; import type { GridInstance } from "./types"; import { useGridDispatch } from "./useGridDispatch"; +/** + * Width of the row-marker column, pinned rather than left to glide. + * + * Glide sizes it from the row count (32 up to 48), and the header layer has to + * start clear of it. Two places deriving the same number from a count that + * changes as pages load is a drift waiting to happen; one number both sides + * read is not. + */ +const ROW_MARKER_WIDTH = 40; + +/** + * Room left at the right edge of a group header for the vertical scrollbar. + * + * A header spans the full width of the canvas, and the scrollbar sits on top of + * the last few pixels of it. Without this the last thing a host puts in a + * header is half hidden under it. + */ +const SCROLLBAR_GUTTER = 18; + export function DataGrid({ instance, }: { @@ -50,10 +74,15 @@ export function DataGrid(null); const gridTheme = useGridTheme(); + const dispatch = useGridDispatch(); + const pageSize = instance.descriptor.pageSize ?? 100; + const grouping = instance.descriptor.grouping; + const { sort, collapsedGroups } = useSelector((s: unknown) => instance.selectRoot(s).groups); + const lastError = useSelector((s: unknown) => instance.selectRoot(s).edits.lastError); const spec = specFromGridSort(sort); @@ -85,15 +114,34 @@ export function DataGrid onRectChanged(model, rect), - [onRectChanged, model], + (rect: Rectangle, _tx: number, ty: number) => { + onRectChanged(model, rect); + + // `ty` is the sub-row scroll offset for THIS region, and it is the only + // way to place a header on the frame being drawn. Asking glide where the + // row is answers about the frame before it — see `headerPlacements`. + trackRegion(rect, ty); + }, + [onRectChanged, trackRegion, model], ); return (
- } /> {/* @@ -154,21 +212,49 @@ export function DataGrid instance.descriptor.cells.validateCell(newValue)} columns={columns} rows={model.rowCount} + rowHeight={rowHeight} + headerHeight={COLUMN_HEADER_HEIGHT} getCellContent={getCellContent} + getCellsForSelection={true} onCellEdited={onCellEdited} - onCellClicked={onHeaderOrCellClicked} onVisibleRegionChanged={onVisibleRegionChanged} onColumnResize={onColumnResize} onColumnMoved={onColumnMoved} - rowMarkers="checkbox-visible" + rowMarkers={{ kind: "checkbox-visible", width: ROW_MARKER_WIDTH }} gridSelection={gridSelection} onGridSelectionChange={onGridSelectionChange} - onHeaderClicked={onHeaderClicked} + onHeaderMenuClick={onHeaderMenuClick} width="100%" height="100%" smoothScrollX smoothScrollY /> + + {/* + The group headers, in React, over the canvas holes reserved for them. + A canvas cannot hold a component, so this is the only place a host's + per-group components can live. See `GroupHeaderLayer`. + */} + {grouping !== undefined && ( + + )} + + {/* + The sort menu, parked over the header arrow the user pressed. It + anchors in viewport coordinates, so it does not care that it is + rendered here rather than beside the canvas. See `ColumnHeaderMenu`. + */} + {sortMenu}
); diff --git a/src/features/dataGrid/GridHeaderBar.test.tsx b/src/features/dataGrid/GridHeaderBar.test.tsx new file mode 100644 index 0000000..6df42cc --- /dev/null +++ b/src/features/dataGrid/GridHeaderBar.test.tsx @@ -0,0 +1,136 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { GridHeaderBar } from "./GridHeaderBar"; + +const EMEA = { label: "EMEA", collapsed: false, onToggle: () => {} }; + +/** The quiet case: nothing loading, nothing wrong, nothing grouped. */ +function renderQuiet(props: Partial[0]> = {}) { + return render( + {}} + group={null} + {...props} + />, + ); +} + +describe("GridHeaderBar", () => { + it("stays on screen when there is nothing to report", () => { + // The whole point of folding the status bar into this one. A bar that came + // and went would move the grid under the pointer every time a load settled. + const { container } = renderQuiet(); + + expect(container).not.toBeEmptyDOMElement(); + }); + + it("names the group the grid is currently inside", () => { + renderQuiet({ group: EMEA }); + + expect(screen.getByText("EMEA")).toBeInTheDocument(); + }); + + it("leaves the group zone out of a grid that is not grouping", () => { + renderQuiet({ group: null }); + + expect(screen.queryByRole("button", { name: /collapse|expand/i })).not.toBeInTheDocument(); + }); + + it("keeps the column picker reachable even with no grouping", () => { + renderQuiet({ group: null, picker: }); + + expect(screen.getByRole("button", { name: "Columns" })).toBeInTheDocument(); + }); + + it("shows the host's components for the current group", () => { + renderQuiet({ group: EMEA, groupSlot: }); + + expect(screen.getByRole("button", { name: "Export EMEA" })).toBeInTheDocument(); + }); + + it("spends no row on a group whose host supplied nothing", () => { + const { container } = renderQuiet({ group: EMEA }); + + expect(container.querySelector("[data-dg-group-slot]")).toBeNull(); + }); + + it("collapses the current group from the bar", async () => { + const onToggle = vi.fn(); + renderQuiet({ group: { ...EMEA, onToggle } }); + + await userEvent.click(screen.getByRole("button", { name: /collapse emea/i })); + + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("says a first load is loading, because a grid with no rows has no cells to say it", () => { + renderQuiet({ status: "loading", rowCount: 0 }); + + expect(screen.getByRole("status")).toHaveTextContent(/loading rows/i); + }); + + it("stays quiet while rows load into a grid that already has rows", () => { + renderQuiet({ status: "loading", rowCount: 500 }); + + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("says so while the grid is showing the rows of the state the user just left", () => { + renderQuiet({ stale: true }); + + expect(screen.getByRole("status")).toHaveTextContent(/loading/i); + }); + + it("offers a retry when a load fails", async () => { + const onRetry = vi.fn(); + renderQuiet({ status: "error", onRetry }); + + expect(screen.getByRole("alert")).toHaveTextContent(/could not load/i); + await userEvent.click(screen.getByRole("button", { name: /retry/i })); + + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it("keeps the column picker reachable while a load has failed", () => { + // The old status bar replaced everything above the grid with the alert. + // Here the alert is a ZONE, so losing rows never costs the user the one + // control that decides what the grid shows. + renderQuiet({ status: "error", picker: }); + + expect(screen.getByRole("button", { name: "Columns" })).toBeInTheDocument(); + }); + + it("shows an edit error on top of a ready grid", () => { + renderQuiet({ error: "Failed to save price" }); + + expect(screen.getByRole("alert")).toHaveTextContent("Failed to save price"); + }); + + it("offers a dismiss for an edit error, so a user who stops editing is not stuck with it", async () => { + const onDismissError = vi.fn(); + renderQuiet({ error: "Failed to save price", onDismissError }); + + await userEvent.click(screen.getByRole("button", { name: /dismiss/i })); + + expect(onDismissError).toHaveBeenCalledTimes(1); + }); + + it("lets a failed load outrank an edit error, because it speaks for every row", () => { + renderQuiet({ status: "error", error: "Failed to save price" }); + + expect(screen.getByRole("alert")).toHaveTextContent(/could not load/i); + }); + + it("holds the slot row's space open while the group is not known yet", () => { + // Null means "there will be components here". A row that came and went as + // the grid worked out which group it was in changed the bar's height, and + // the grid below jumped with it on every scrollbar drag. + const { container } = renderQuiet({ group: null, groupSlot: null }); + + expect(container.querySelector("[data-dg-group-slot]")).not.toBeNull(); + }); +}); diff --git a/src/features/dataGrid/GridHeaderBar.tsx b/src/features/dataGrid/GridHeaderBar.tsx new file mode 100644 index 0000000..7a6beaa --- /dev/null +++ b/src/features/dataGrid/GridHeaderBar.tsx @@ -0,0 +1,173 @@ +import { Button, Spinner } from "@radix-ui/themes"; +import { CircleAlert } from "lucide-react"; +import type { ReactNode } from "react"; +import { GroupHeaderRow } from "./GroupHeaderRow"; + +/** The group the grid is currently inside, or null when it is not grouping. */ +export interface BannerGroup { + label: string; + + /** Resolved CSS colour for the name. Omit to inherit. */ + textColor?: string; + + collapsed: boolean; + onToggle: () => void; +} + +export interface GridHeaderBarProps { + status: "loading" | "ready" | "error"; + error: string | null; + + /** Rows the grid is currently drawing. Zero means there are no cells to speak for a load. */ + rowCount: number; + + /** The grid is drawing the previous state while the next one loads. */ + stale?: boolean; + + onRetry: () => void; + + /** Omit to render the edit error without a dismiss. */ + onDismissError?: () => void; + + group: BannerGroup | null; + + /** The host's components for the current group. */ + groupSlot?: ReactNode; + + /** The column picker. */ + picker?: ReactNode; +} + +function Working({ children }: { children: ReactNode }) { + return ( + + + {children} + + ); +} + +function Wrong({ children }: { children: ReactNode }) { + return ( + + + {children} + + ); +} + +/** + * The middle zone: what the grid is doing, or what went wrong. + * + * The order is a ranking, not a sequence. A load speaks for every row on + * screen, so it outranks an edit error, which speaks for one cell. The wait + * costs the user nothing: the edit error names its cell by ROW KEY — see + * `hooks/useCellRenderer.ts` — and not by position, so a state change cannot + * make it point at the wrong row. It stays in the slice and comes back the + * moment the load settles. + * + * A load stays silent while the grid HAS rows, because the loading cells + * already say it. `rowCount` is what tells the two apart: on a first load the + * total is not known yet, so there are no cells and no loading cells either. + * + * `stale` gets a line for the opposite reason. The grid then holds a full set + * of rows and draws no loading cell at all, because every one of those rows + * belongs to the state the user has just left. Nothing on the canvas says so. + */ +function StatusZone({ + status, + error, + rowCount, + stale, + onRetry, + onDismissError, +}: Pick< + GridHeaderBarProps, + "status" | "error" | "rowCount" | "stale" | "onRetry" | "onDismissError" +>) { + if (status === "error") { + return ( + + Could not load rows. + + + ); + } + + if (stale === true) { + return Loading…; + } + + if (error !== null) { + return ( + + {error} + {onDismissError !== undefined && ( + + )} + + ); + } + + if (status === "loading" && rowCount === 0) { + return Loading rows…; + } + + return null; +} + +/** + * The one permanent bar above the grid. + * + * It never renders as nothing, which is the whole reason the old status bar was + * folded into it: a bar that came and went moved the grid under the pointer + * every time a load settled. + * + * The group zone shows the group the user is currently inside. It carries the + * SAME `GroupHeaderRow` the in-grid headers use, minus the host's components — + * those need a row of their own here, because the status and the picker have + * already spent the first one. + */ +export function GridHeaderBar({ group, groupSlot, picker, ...status }: GridHeaderBarProps) { + return ( +
+
+ {group !== null && ( +
+ +
+ )} + +
+ +
+ + {picker !== undefined &&
{picker}
} +
+ + {groupSlot !== undefined && ( +
+ {groupSlot} +
+ )} +
+ ); +} diff --git a/src/features/dataGrid/GridStatusBar.test.tsx b/src/features/dataGrid/GridStatusBar.test.tsx deleted file mode 100644 index a92b68c..0000000 --- a/src/features/dataGrid/GridStatusBar.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import { GridStatusBar } from "./GridStatusBar"; - -describe("GridStatusBar", () => { - it("renders nothing while rows load into a grid that already has rows", () => { - const { container } = render( - {}} />, - ); - expect(container).toBeEmptyDOMElement(); - }); - - it("says a first load is loading, because a grid with no rows has no cells to say it", () => { - render( {}} />); - expect(screen.getByRole("status")).toHaveTextContent(/loading rows/i); - }); - - it("renders nothing when the grid is ready and has no error", () => { - const { container } = render( - {}} />, - ); - expect(container).toBeEmptyDOMElement(); - }); - - it("offers a retry when a load fails", async () => { - const onRetry = vi.fn(); - render(); - - expect(screen.getByRole("alert")).toHaveTextContent(/could not load/i); - await userEvent.click(screen.getByRole("button", { name: /retry/i })); - expect(onRetry).toHaveBeenCalledTimes(1); - }); - - it("shows an edit error on top of a ready grid", () => { - render( - {}} - />, - ); - expect(screen.getByRole("alert")).toHaveTextContent("Failed to save price"); - }); - - it("offers a dismiss for an edit error, so a user who stops editing is not stuck with it", async () => { - const onDismissError = vi.fn(); - render( - {}} - onDismissError={onDismissError} - />, - ); - - await userEvent.click(screen.getByRole("button", { name: /dismiss/i })); - expect(onDismissError).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/features/dataGrid/GridStatusBar.tsx b/src/features/dataGrid/GridStatusBar.tsx deleted file mode 100644 index e245bf3..0000000 --- a/src/features/dataGrid/GridStatusBar.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { Button, Callout, Flex, Spinner } from "@radix-ui/themes"; -import { CircleAlert } from "lucide-react"; -import type { ReactNode } from "react"; - -/** - * The bar sits flush against the grid, so it drops the Callout radius and keeps - * only the bottom edge of the ring. `--accent-a*` follows the `color` prop, so - * one style serves both the red and the gray bar. - */ -function StatusCallout({ - color, - role, - icon, - children, -}: { - color: "red" | "gray"; - role: "alert" | "status"; - icon: ReactNode; - children: ReactNode; -}) { - return ( - - {icon} - {children} - - ); -} - -/** - * Where a grid failure becomes visible. - * - * Before this existed, a failed window left the grid drawing loading cells for - * ever, with no way to tell "still loading" from "gave up". - * - * A load stays silent while the grid HAS rows, because the loading cells - * already say it. `rowCount` is what tells the two apart: on the first load the - * total is not known yet, so there are no cells and no loading cells either, - * and a silent bar over an empty canvas reads as a grid that finished and found - * nothing. That case gets a line of its own. - * - * `stale` gets a line for the opposite reason. The grid then holds a full set of - * rows and draws no loading cell at all, because every one of those rows belongs - * to the state the user has just left. Nothing on the canvas says so. - */ -export function GridStatusBar({ - status, - error, - rowCount, - stale, - onRetry, - onDismissError, -}: { - status: "loading" | "ready" | "error"; - error: string | null; - /** Rows the grid is currently drawing. Zero means there are no cells to speak for a load. */ - rowCount: number; - /** The grid is drawing the previous state while the next one loads. */ - stale?: boolean; - onRetry: () => void; - /** Omit to render the edit error without a dismiss. */ - onDismissError?: () => void; -}) { - if (status === "error") { - return ( - }> - - Could not load rows. - - - - - ); - } - - // Above the edit error on purpose. The bar shows one line at a time, and a - // load speaks for every row on screen while an edit error speaks for one - // cell. - // - // The wait costs the user nothing. The edit error names its cell by ROW KEY — - // `hooks/useCellRenderer.ts` builds `${rowKey}:${field}` — and not by - // position, so a state change cannot make it point at the wrong row. It stays - // in the slice and comes back to the bar the moment the load settles. - if (stale) { - return ( - }> - Loading… - - ); - } - - if (error !== null) { - return ( - }> - - {error} - - {onDismissError !== undefined && ( - - )} - - - ); - } - - if (status === "loading" && rowCount === 0) { - return ( - }> - Loading rows… - - ); - } - - return null; -} diff --git a/src/features/dataGrid/GroupHeaderLayer.test.tsx b/src/features/dataGrid/GroupHeaderLayer.test.tsx new file mode 100644 index 0000000..babd49b --- /dev/null +++ b/src/features/dataGrid/GroupHeaderLayer.test.tsx @@ -0,0 +1,129 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createRef, act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { + GroupHeaderLayer, + type GroupHeaderLayerHandle, + type GroupHeaderLayerProps, +} from "./GroupHeaderLayer"; +import type { VisibleHeader } from "./displayModel"; + +const HEADERS: VisibleHeader[] = [ + { displayRow: 0, group: "EMEA", collapsed: false }, + { displayRow: 5, group: "Americas", collapsed: true }, +]; + +function renderLayer(props: Partial> = {}) { + const ref = createRef(); + + const view = render( + g} + onToggle={() => {}} + {...props} + />, + ); + + return { ...view, ref }; +} + +/** What `DataGrid` calls on every visible-region change. */ +function place( + ref: { current: GroupHeaderLayerHandle | null }, + at: Record, +) { + act(() => ref.current?.place(new Map(Object.entries(at).map(([k, v]) => [Number(k), v])))); +} + +/** Both headers on their holes, which is the state a mounted grid is in. */ +function placeAll(ref: { current: GroupHeaderLayerHandle | null }) { + place(ref, { 0: { top: 36, height: 40 }, 5: { top: 206, height: 40 } }); +} + +function headerFor(name: string): HTMLElement { + return screen.getByText(name).closest("[data-dg-group-header]") as HTMLElement; +} + +describe("GroupHeaderLayer", () => { + it("draws one header per group the viewport can see", () => { + const { container } = renderLayer(); + + expect(container.querySelectorAll("[data-dg-group-header]")).toHaveLength(2); + }); + + it("names each group", () => { + renderLayer(); + + expect(screen.getByText("EMEA")).toBeInTheDocument(); + expect(screen.getByText("Americas")).toBeInTheDocument(); + }); + + it("gives each group the components its host asked for", () => { + const { ref } = renderLayer({ + slot: (g) => , + }); + placeAll(ref); + + expect(screen.getByRole("button", { name: "Export EMEA" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Export Americas" })).toBeInTheDocument(); + }); + + it("names the group, not the row, when a header is collapsed", async () => { + const onToggle = vi.fn(); + const { ref } = renderLayer({ onToggle }); + placeAll(ref); + + await userEvent.click(screen.getByRole("button", { name: /collapse emea/i })); + + expect(onToggle).toHaveBeenCalledWith("EMEA"); + }); + + it("shows a collapsed group as collapsed, so its rows can be found again", () => { + const { ref } = renderLayer(); + placeAll(ref); + + expect(screen.getByRole("button", { name: /expand americas/i })).toBeInTheDocument(); + }); + + it("moves a header to where the canvas put its hole", () => { + const { ref } = renderLayer(); + + place(ref, { 0: { top: 36, height: 40 }, 5: { top: 206, height: 40 } }); + + expect(headerFor("EMEA").style.transform).toBe("translateY(36px)"); + expect(headerFor("Americas").style.transform).toBe("translateY(206px)"); + }); + + it("takes the hole's height, so the header covers it exactly", () => { + const { ref } = renderLayer(); + + place(ref, { 0: { top: 36, height: 64 } }); + + expect(headerFor("EMEA").style.height).toBe("64px"); + }); + + it("hides a header the canvas has not placed yet, rather than stacking it at the top", () => { + // React commits the new header first and the placement arrives on the next + // frame. Drawn at zero in between, every unplaced header would pile up over + // the first row. + const { ref } = renderLayer(); + + place(ref, { 0: { top: 36, height: 40 } }); + + expect(headerFor("EMEA")).toBeVisible(); + expect(headerFor("Americas")).not.toBeVisible(); + }); + + it("places without re-rendering, because a scroll frame cannot wait for React", () => { + const label = vi.fn((g: string) => g); + const { ref } = renderLayer({ label }); + const before = label.mock.calls.length; + + place(ref, { 0: { top: 36, height: 40 }, 5: { top: 206, height: 40 } }); + + expect(label.mock.calls.length).toBe(before); + }); +}); diff --git a/src/features/dataGrid/GroupHeaderLayer.tsx b/src/features/dataGrid/GroupHeaderLayer.tsx new file mode 100644 index 0000000..d05e362 --- /dev/null +++ b/src/features/dataGrid/GroupHeaderLayer.tsx @@ -0,0 +1,134 @@ +import { forwardRef, useCallback, useImperativeHandle, useRef, type ReactNode } from "react"; +import type { VisibleHeader } from "./displayModel"; +import { GroupHeaderRow } from "./GroupHeaderRow"; + +/** Where the canvas put one header's hole, in pixels inside the layer. */ +export interface HeaderPlacement { + top: number; + height: number; +} + +export interface GroupHeaderLayerHandle { + /** + * Move every header onto its hole. Keyed by display row. + * + * A header with no entry is hidden rather than left where it was: it means + * the canvas no longer draws that hole. + */ + place: (placements: ReadonlyMap) => void; +} + +export interface GroupHeaderLayerProps { + /** The headers the viewport can see, from `headersInDisplayRange`. */ + headers: readonly VisibleHeader[]; + + label: (group: TGroup) => string; + + /** Resolved CSS colour for each group's name. */ + textColor?: (group: TGroup) => string | undefined; + + /** The host's components for a group. */ + slot?: (group: TGroup) => ReactNode; + + /** Resolved CSS fill for a header row. See `GroupHeaderRow.background`. */ + background?: string; + + /** Width of the row marker the canvas draws under every header. */ + markerWidth?: number; + + /** Room to leave at the right edge, for the grid's vertical scrollbar. */ + gutterRight?: number; + + onToggle: (group: TGroup) => void; +} + +/** + * The group headers, in React, floating over the canvas. + * + * Two owners, deliberately split. React owns WHICH headers exist — a set that + * changes only when the viewport crosses a group boundary, a few times a + * scroll. The DOM owns WHERE they are, through `place`, which runs on every + * visible-region change glide reports. + * + * Routing the positions through React state instead would re-render the whole + * layer on every scroll frame, and the headers would trail the rows they name + * by a frame each time. This is the same bargain `useRepaintRows` strikes for + * changed rows: React for what exists, direct writes for what moves. + * + * The layer takes no pointer events; each header takes them back. A header + * covers only the hole the canvas reserved for it, but the layer spans the + * whole grid, and a layer that swallowed clicks would make every row under it + * unselectable. + */ +function GroupHeaderLayerInner( + { + headers, + label, + textColor, + slot, + background, + markerWidth, + gutterRight, + onToggle, + }: GroupHeaderLayerProps, + ref: React.ForwardedRef, +) { + const nodes = useRef(new Map()); + + const place = useCallback((placements: ReadonlyMap) => { + for (const [displayRow, node] of nodes.current) { + const at = placements.get(displayRow); + + if (at === undefined) { + node.style.visibility = "hidden"; + continue; + } + + node.style.visibility = "visible"; + node.style.transform = `translateY(${at.top}px)`; + node.style.height = `${at.height}px`; + } + }, []); + + useImperativeHandle(ref, () => ({ place }), [place]); + + return ( +
+ {headers.map((h) => ( +
{ + if (node === null) { + nodes.current.delete(h.displayRow); + return; + } + + nodes.current.set(h.displayRow, node); + }} + // Hidden until the first `place`. React commits a new header before + // the placement arrives, and a header drawn at zero in between would + // pile up over the grid's first row. + style={{ visibility: "hidden" }} + className="dg:pointer-events-auto dg:absolute dg:top-0 dg:right-0 dg:left-0" + > + onToggle(h.group)} + > + {slot?.(h.group)} + +
+ ))} +
+ ); +} + +export const GroupHeaderLayer = forwardRef(GroupHeaderLayerInner) as ( + props: GroupHeaderLayerProps & { ref?: React.ForwardedRef }, +) => ReturnType; diff --git a/src/features/dataGrid/GroupHeaderRow.test.tsx b/src/features/dataGrid/GroupHeaderRow.test.tsx new file mode 100644 index 0000000..e0ad013 --- /dev/null +++ b/src/features/dataGrid/GroupHeaderRow.test.tsx @@ -0,0 +1,96 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { GroupHeaderRow } from "./GroupHeaderRow"; + +describe("GroupHeaderRow", () => { + it("names the group", () => { + render( {}} />); + + expect(screen.getByText("EMEA")).toBeInTheDocument(); + }); + + it("renders the host's components beside the name", () => { + render( + {}}> + + , + ); + + expect(screen.getByRole("button", { name: "Export EMEA" })).toBeInTheDocument(); + }); + + it("collapses the group when its control is clicked", async () => { + const onToggle = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: /collapse emea/i })); + + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("offers to expand a group that is already collapsed", async () => { + const onToggle = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: /expand emea/i })); + + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("reports whether the group is open, so a screen reader can say so", () => { + const { rerender } = render( + {}} />, + ); + expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true"); + + rerender( {}} />); + expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "false"); + }); + + it("paints the name in the group's own colour", () => { + render( + {}} />, + ); + + expect(screen.getByText("EMEA")).toHaveStyle({ color: "#AB6400" }); + }); + + it("paints an opaque background, so the canvas underneath does not show through", () => { + // The canvas keeps drawing a row marker on a header's row, and the header + // floats over it. Without a fill of its own the checkbox shows through the + // group's name. + const { container } = render( + {}} />, + ); + + expect(container.firstElementChild).toHaveStyle({ backgroundColor: "#F7F9FA" }); + }); + + it("starts the name clear of the row marker the canvas draws", () => { + const { container } = render( + {}} />, + ); + + expect(container.firstElementChild).toHaveStyle({ paddingLeft: "40px" }); + }); + + it("counts its padding inside its width, not on top of it", () => { + // This package ships no CSS reset — deliberately, so it cannot vandalise + // the host's styles — which means `box-sizing` is whatever the browser + // defaults to, and that is `content-box`. A full-width row with padding is + // then WIDER than the box it sits in, and whatever the host put on the + // right hangs off the edge of the grid. + const { container } = render( + {}} + />, + ); + + expect(container.firstElementChild).toHaveStyle({ boxSizing: "border-box" }); + }); +}); diff --git a/src/features/dataGrid/GroupHeaderRow.tsx b/src/features/dataGrid/GroupHeaderRow.tsx new file mode 100644 index 0000000..6ea2f3b --- /dev/null +++ b/src/features/dataGrid/GroupHeaderRow.tsx @@ -0,0 +1,100 @@ +import { IconButton, Text } from "@radix-ui/themes"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import type { ReactNode } from "react"; + +/** + * One group's header: the collapse control, the group's name, and whatever the + * host wants to show for that group. + * + * It takes a resolved CSS colour rather than a Radix scale on purpose. The + * scale-to-colour step reads CSS vars off the live Themes root, which is a + * document lookup; keeping it outside leaves this a pure function of its props, + * and that is what lets it be rendered in two very different places — a hole in + * the grid canvas, and the bar above it — without either caring. + */ +export function GroupHeaderRow({ + label, + textColor, + background, + markerWidth = 0, + gutterRight = 0, + collapsed, + onToggle, + children, +}: { + label: string; + + /** Resolved CSS colour for the name. Omit to inherit. */ + textColor?: string; + + /** + * Resolved CSS colour for the row's fill. + * + * Not decoration. The canvas goes on drawing a row marker on the header's + * row, and this row floats over it, so without an opaque fill the checkbox + * shows through the group's name. + */ + background?: string; + + /** Width of the row marker the canvas draws, so the name starts clear of it. */ + markerWidth?: number; + + /** Room to leave at the right edge, for the grid's vertical scrollbar. */ + gutterRight?: number; + + collapsed: boolean; + onToggle: () => void; + + /** The host's components for this group. */ + children?: ReactNode; +}) { + const Icon = collapsed ? ChevronRight : ChevronDown; + + return ( +
+ + + + + + {label} + + + {children !== undefined && ( +
{children}
+ )} +
+ ); +} diff --git a/src/features/dataGrid/displayModel.test.ts b/src/features/dataGrid/displayModel.test.ts index 374b9e6..c8c5a27 100644 --- a/src/features/dataGrid/displayModel.test.ts +++ b/src/features/dataGrid/displayModel.test.ts @@ -7,6 +7,9 @@ import { displayRowsOfData, displayToData, firstAffectedDataIndex, + groupAtDisplayRow, + headersInDisplayRange, + sameHeaders, type Boundary, } from "./displayModel"; @@ -399,3 +402,116 @@ describe("firstAffectedDataIndex", () => { expect(firstAffectedDataIndex(model, 0)).toBe(0); }); }); + +/** header(0) d d header(1) d d header(2) d d => 9 display rows. */ +const THREE_GROUPS = buildDisplayModel( + { + boundaries: [ + { dataIndex: 0, group: 0 }, + { dataIndex: 2, group: 1 }, + { dataIndex: 4, group: 2 }, + ], + leadingGroup: null, + total: 6, + collapsedGroups: [], + discoveredGroups: [0, 1, 2], + }, + order, +); + +describe("headersInDisplayRange", () => { + it("reports each header on screen, and where it sits", () => { + expect(headersInDisplayRange(THREE_GROUPS, 0, 8)).toEqual([ + { displayRow: 0, group: 0, collapsed: false }, + { displayRow: 3, group: 1, collapsed: false }, + { displayRow: 6, group: 2, collapsed: false }, + ]); + }); + + it("leaves out the headers the viewport has scrolled past", () => { + expect(headersInDisplayRange(THREE_GROUPS, 4, 8).map((h) => h.group)).toEqual([2]); + }); + + it("includes a header the range only just reaches", () => { + expect(headersInDisplayRange(THREE_GROUPS, 3, 3).map((h) => h.group)).toEqual([1]); + }); + + it("reads the range whichever way round it is given", () => { + expect(headersInDisplayRange(THREE_GROUPS, 8, 4).map((h) => h.group)).toEqual([2]); + }); + + it("finds nothing in a flat grid, which draws no headers at all", () => { + expect(headersInDisplayRange(buildFlatModel(50), 0, 20)).toEqual([]); + }); + + it("reports a collapsed group's header, because it is the only way back", () => { + const m = buildDisplayModel( + { + boundaries: [{ dataIndex: 0, group: 0 }], + leadingGroup: null, + total: 2, + collapsedGroups: [1], + discoveredGroups: [0, 1], + }, + order, + ); + + expect(headersInDisplayRange(m, 0, m.rowCount - 1)).toContainEqual( + expect.objectContaining({ group: 1, collapsed: true }), + ); + }); +}); + +describe("groupAtDisplayRow", () => { + it("names the group a header row belongs to", () => { + expect(groupAtDisplayRow(THREE_GROUPS, 3)).toBe(1); + }); + + it("names the group a data row belongs to", () => { + expect(groupAtDisplayRow(THREE_GROUPS, 5)).toBe(1); + }); + + it("keeps naming the group above while the viewport sits deep inside it", () => { + expect(groupAtDisplayRow(THREE_GROUPS, 8)).toBe(2); + }); + + it("answers null for a flat grid, which is inside no group", () => { + expect(groupAtDisplayRow(buildFlatModel(50), 10)).toBeNull(); + }); + + it("answers null past the last row, rather than a group that is not there", () => { + expect(groupAtDisplayRow(THREE_GROUPS, 99)).toBeNull(); + }); +}); + +describe("sameHeaders", () => { + const a = { displayRow: 0, group: 0, collapsed: false }; + const b = { displayRow: 5, group: 1, collapsed: false }; + + it("says a scroll inside one group changed nothing", () => { + // The whole point: this runs on every visible-region change, and answering + // "changed" re-renders the header layer. Inside a tall group that is every + // frame of a scroll, for a set that never moved. + expect(sameHeaders([a, b], [a, b])).toBe(true); + }); + + it("compares by value, not by array identity", () => { + expect(sameHeaders([{ ...a }], [{ ...a }])).toBe(true); + }); + + it("notices a header scrolling into view", () => { + expect(sameHeaders([a], [a, b])).toBe(false); + }); + + it("notices a header scrolling out of view", () => { + expect(sameHeaders([a, b], [b])).toBe(false); + }); + + it("notices a header that moved, because the rows above it changed height", () => { + expect(sameHeaders([a], [{ ...a, displayRow: 3 }])).toBe(false); + }); + + it("notices a group folding shut, so its arrow turns round", () => { + expect(sameHeaders([a], [{ ...a, collapsed: true }])).toBe(false); + }); +}); diff --git a/src/features/dataGrid/displayModel.ts b/src/features/dataGrid/displayModel.ts index b615900..416dc27 100644 --- a/src/features/dataGrid/displayModel.ts +++ b/src/features/dataGrid/displayModel.ts @@ -434,3 +434,96 @@ export function dataRangeInDisplayRange( return { min, max }; } + +/** A group header the viewport can currently see. */ +export interface VisibleHeader { + displayRow: number; + group: TGroup; + collapsed: boolean; +} + +/** + * Every group header inside a display range, in the order they are drawn. + * + * This is what the header layer renders. It reads SEGMENTS rather than walking + * the range row by row, so a viewport 40 rows tall costs one pass over the + * groups rather than 40 lookups — and the cost does not grow with the row + * height the host chose. + * + * A collapsed group is included. Its header is the only way back to its rows, + * so a layer that left it out would strand them. + */ +export function headersInDisplayRange( + model: DisplayModel, + fromDisplay: number, + toDisplay: number, +): VisibleHeader[] { + const rangeStart = Math.max(0, Math.min(fromDisplay, toDisplay)); + const rangeEnd = Math.max(fromDisplay, toDisplay); + + return model.segments + .filter( + (segment) => + segment.hasHeader && + segment.group !== null && + segment.displayStart >= rangeStart && + segment.displayStart <= rangeEnd, + ) + .map((segment) => ({ + displayRow: segment.displayStart, + group: segment.group as TGroup, + collapsed: segment.collapsed, + })) + .sort((a, b) => a.displayRow - b.displayRow); +} + +/** + * The group a display row belongs to, header row and data rows alike. + * + * The banner asks this about the FIRST visible row, which is what makes it name + * the group the user is inside rather than the last header they scrolled past — + * the two differ for every group taller than the viewport, which is most of + * them. + * + * Answers null for a flat grid, for the leading segment of a moved window + * (whose group is not known yet), and for a row past the end. All three mean + * the same thing to a caller: there is no group to name here. + */ +export function groupAtDisplayRow( + model: DisplayModel, + displayRow: number, +): TGroup | null { + for (const segment of model.segments) { + const end = segment.displayStart + headerRowCount(segment) + segment.dataCount; + + if (displayRow >= segment.displayStart && displayRow < end) { + return segment.group; + } + } + + return null; +} + +/** + * Whether two runs of visible headers describe the same thing. + * + * This decides whether the header layer re-renders, and it is asked on every + * visible-region change glide reports — which inside a group taller than the + * viewport is every frame of a scroll, for a set that never moved. Answering by + * VALUE rather than by array identity is what keeps those frames free. + */ +export function sameHeaders( + a: readonly VisibleHeader[], + b: readonly VisibleHeader[], +): boolean { + if (a.length !== b.length) { + return false; + } + + return a.every( + (header, i) => + header.displayRow === b[i].displayRow && + header.group === b[i].group && + header.collapsed === b[i].collapsed, + ); +} diff --git a/src/features/dataGrid/gridStatusWiring.test.tsx b/src/features/dataGrid/gridStatusWiring.test.tsx index 7bdf5bd..12892dc 100644 --- a/src/features/dataGrid/gridStatusWiring.test.tsx +++ b/src/features/dataGrid/gridStatusWiring.test.tsx @@ -8,7 +8,7 @@ import { Provider, useSelector } from "react-redux"; import { describe, expect, it, vi } from "vitest"; import { lightGridTheme } from "../../theme/gridThemes"; import { buildFlatModel } from "./displayModel"; -import { GridStatusBar } from "./GridStatusBar"; +import { GridHeaderBar } from "./GridHeaderBar"; import { useCellRenderer } from "./hooks/useCellRenderer"; import { useGridData } from "./hooks/useGridData"; import { createGridInstance } from "./store/createGridInstance"; @@ -25,7 +25,7 @@ const WINDOW = { offset: 0, limit: 100 }; const noRepaint = () => {}; /** - * Renders exactly what `DataGrid` renders above the grid body: `GridStatusBar` + * Renders exactly what `DataGrid` renders above the grid body: `GridHeaderBar` * wired to `useGridData`'s `status`/`retry` and `edits.lastError` off the store. * A real `DataEditor` mount needs jsdom polyfills this project doesn't carry * (ResizeObserver, canvas), so this proves the same wiring without it. @@ -35,11 +35,12 @@ function StatusBarUnderTest({ instance }: { instance: GridInstance instance.selectRoot(s).edits.lastError); return ( - ); } @@ -103,7 +104,7 @@ describe("DataGrid status wiring", () => { /** * The edit banner end to end: a real row store and edit overlay whose * `updateRow` decides the outcome, `useCellRenderer` turning that outcome into - * `edits.lastError`, and `GridStatusBar` turning that into the banner the user + * `edits.lastError`, and `GridHeaderBar` turning that into the banner the user * actually sees. * * It asserts on the banner rather than on `lastError`, because "the banner @@ -137,12 +138,13 @@ function EditBannerUnderTest({ instance }: { instance: GridInstance - {}} onDismissError={() => dispatch(instance.actions.editErrorCleared())} + group={null} /> + ))} + {menu} + + ); + } + + render( + + + + + , + ); + + const sort = () => (store.getState() as Record>).demo.groups.sort; + + return { store, sort }; +} + +const openArrow = (field: string) => + userEvent.click(screen.getByRole("button", { name: `arrow ${field}` })); + +const pick = async (name: RegExp) => + userEvent.click(await screen.findByRole("menuitem", { name })); + +describe("useColumnSortMenu", () => { + it("opens the menu on the column whose arrow was pressed", async () => { + harness(); + + await openArrow("name"); + + expect(await screen.findByRole("menu")).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: /sort ascending/i })).toBeInTheDocument(); + }); + + it("opens nothing on a column the server cannot order by", async () => { + harness(); + + await openArrow("id"); + + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + + it("stores the order the user picked", async () => { + const h = harness(); + + await openArrow("name"); + await pick(/sort descending/i); + + expect(h.sort()).toEqual({ field: "name", dir: "desc" }); + }); + + it("shuts the menu once an order is picked", async () => { + harness(); + + await openArrow("name"); + await pick(/sort ascending/i); + + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + + it("does not reload the grid when the order already in force is picked again", async () => { + const h = harness(); + + await openArrow("name"); + await pick(/sort ascending/i); + const first = h.sort(); + + await openArrow("name"); + await pick(/sort ascending/i); + + expect(h.sort()).toBe(first); + }); + + it("clears the sort", async () => { + const h = harness(); + + await openArrow("name"); + await pick(/sort ascending/i); + + await openArrow("name"); + await pick(/clear sort/i); + + expect(h.sort()).toBeNull(); + }); +}); diff --git a/src/features/dataGrid/hooks/useColumnSortMenu.tsx b/src/features/dataGrid/hooks/useColumnSortMenu.tsx new file mode 100644 index 0000000..3c81fd2 --- /dev/null +++ b/src/features/dataGrid/hooks/useColumnSortMenu.tsx @@ -0,0 +1,77 @@ +// src/features/dataGrid/hooks/useColumnSortMenu.tsx +import type { Rectangle } from "@glideapps/glide-data-grid"; +import { type ReactNode, useCallback, useMemo, useState } from "react"; +import { useSelector } from "react-redux"; +import { ColumnHeaderMenu, type ColumnMenuTarget } from "../ColumnHeaderMenu"; +import { sortablePredicate } from "../sortable"; +import type { GridInstance, GridSort } from "../types"; +import { useGridDispatch } from "../useGridDispatch"; + +/** + * The column header's sort menu, from the press on the arrow to the reload. + * + * The caller learns two things: the handler to hand the grid, and the node to + * render beside it. Where the menu is, what it offers, and which press is worth + * a reload are all behind that. + * + * The menu is what sorts; a click on the header itself does not. A header click + * is also how a user selects a column, and one gesture answering to two + * intentions puts a full reload of the grid behind a mis-click. + */ +export function useColumnSortMenu< + TRow extends object, + TGroup, + TKey extends string | number = number, +>( + instance: GridInstance, + visibleFields: string[], +): { onHeaderMenuClick: (colIndex: number, bounds: Rectangle) => void; menu: ReactNode } { + const dispatch = useGridDispatch(); + + const defs = instance.descriptor.columns.defs; + + const sort = useSelector((s: unknown) => instance.selectRoot(s).groups.sort) ?? null; + + const [target, setTarget] = useState(null); + + const sortable = useMemo( + () => sortablePredicate(defs), + [defs], + ); + + const onHeaderMenuClick = useCallback( + (colIndex: number, bounds: Rectangle) => { + const field = visibleFields[colIndex]; + + // The grid only draws an arrow on a sortable column, so this is a guard + // against the two disagreeing, not an expected path. + if (field === undefined || !sortable(field)) return; + + setTarget({ field, bounds }); + }, + [visibleFields, sortable], + ); + + const close = useCallback(() => setTarget(null), []); + + const onSort = useCallback( + (next: GridSort | null) => { + setTarget(null); + + // The sort is part of every window's cache key, so changing it is the + // whole refetch: the mounted windows re-request themselves on the new + // key. Re-picking the order already in force must therefore change + // nothing, or the menu reloads the grid to arrive where it started. + if (next?.field === sort?.field && next?.dir === sort?.dir) return; + + dispatch(instance.actions.setSort(next)); + }, + [dispatch, instance, sort], + ); + + const menu = ( + + ); + + return { onHeaderMenuClick, menu }; +} diff --git a/src/features/dataGrid/hooks/useDisplayModel.test.tsx b/src/features/dataGrid/hooks/useDisplayModel.test.tsx index fa50af5..a7e806d 100644 --- a/src/features/dataGrid/hooks/useDisplayModel.test.tsx +++ b/src/features/dataGrid/hooks/useDisplayModel.test.tsx @@ -102,8 +102,8 @@ describe("useDisplayModel", () => { expect(collapsedSegment?.hasHeader).toBe(true); expect(collapsedSegment?.collapsed).toBe(true); - // And the header is clickable back into an expanded group. - act(() => result.current.onHeaderOrCellClicked([0, collapsedSegment!.displayStart])); + // And the header takes the group back out of the collapsed set. + act(() => result.current.toggleGroup(1)); expect(groupsOf(instance, store.getState()).collapsedGroups).not.toContain(1); }); @@ -138,15 +138,16 @@ describe("useDisplayModel", () => { { wrapper }, ); - const header = result.current.model.segments.find((s) => s.group === 1); - - act(() => result.current.onHeaderOrCellClicked([0, header!.displayStart])); + act(() => result.current.toggleGroup(1)); // Group 1 opens at data row 3. Rows 0 to 2 keep their index either way. expect(instance.carryFromRef.current).toBe(3); }); - it("leaves the boundary alone when the click lands on a data row", () => { + it("hands one collapse path to the header, the banner and anything else", () => { + // `carryFromRef` has to be written by EVERY dispatcher of `toggleCollapse`, + // or that collapse silently carries pages measured against the old + // positions. One function is how that stays true as callers are added. const { instance, wrapper } = makeHarness(); const { result } = renderHook( @@ -154,9 +155,8 @@ describe("useDisplayModel", () => { { wrapper }, ); - act(() => result.current.onHeaderOrCellClicked([0, 1])); - - expect(instance.carryFromRef.current).toBeNull(); + expect(typeof result.current.toggleGroup).toBe("function"); + expect("onHeaderOrCellClicked" in result.current).toBe(false); }); it("keeps its rows while a moved window is still loading", () => { diff --git a/src/features/dataGrid/hooks/useDisplayModel.ts b/src/features/dataGrid/hooks/useDisplayModel.ts index a4998e3..a99f86d 100644 --- a/src/features/dataGrid/hooks/useDisplayModel.ts +++ b/src/features/dataGrid/hooks/useDisplayModel.ts @@ -1,12 +1,10 @@ // src/features/dataGrid/hooks/useDisplayModel.ts -import type { Item } from "@glideapps/glide-data-grid"; import { useCallback, useEffect, useMemo } from "react"; import { useSelector } from "react-redux"; import { buildDisplayModel, buildFlatModel, detectBoundaries, - displayToData, firstAffectedDataIndex, type Boundary, type DisplayModel, @@ -22,7 +20,20 @@ interface Args { interface Result { model: DisplayModel; - onHeaderOrCellClicked: (cellIndex: Item) => void; + + /** + * Collapse a group, or expand it again. + * + * THE ONLY dispatcher of `toggleCollapse`, and it has to stay that way. + * Every collapse must first record where it starts, and only this hook + * holds the display model that knows. A second dispatcher that skipped + * that step would carry pages measured against the old positions, and the + * rows would come back under the wrong indexes with nothing to say so. + * + * The in-grid header, the banner, and anything added later all come + * through here. + */ + toggleGroup: (group: TGroup) => void; } /** What the current span says about groups: where they start, and which ones it holds. */ @@ -117,27 +128,18 @@ export function useDisplayModel { - const cell = displayToData(model, cellIndex[1]); - - if (cell.kind !== "header") { - return; - } - + const toggleGroup = useCallback( + (group: TGroup) => { // Where the collapse starts, in the coordinates of the store the screen // is reading right now. Only the model knows it, and the model is in // scope here alone. `useGridData` takes it on the next render and uses it // to keep the pages ABOVE the group instead of asking for them again. - // - // This is the only dispatcher of `toggleCollapse`. A second one must - // write this cell too, or its collapse silently carries stale pages. - instance.carryFromRef.current = firstAffectedDataIndex(model, cell.group); + instance.carryFromRef.current = firstAffectedDataIndex(model, group); - dispatch(instance.actions.toggleCollapse(cell.group)); + dispatch(instance.actions.toggleCollapse(group)); }, [model, dispatch, instance], ); - return { model, onHeaderOrCellClicked }; + return { model, toggleGroup }; } diff --git a/src/features/dataGrid/hooks/useGridColumns.test.tsx b/src/features/dataGrid/hooks/useGridColumns.test.tsx index c7abede..04883a5 100644 --- a/src/features/dataGrid/hooks/useGridColumns.test.tsx +++ b/src/features/dataGrid/hooks/useGridColumns.test.tsx @@ -17,17 +17,23 @@ interface Row { const FIELDS = ["id", "name"]; -function makeHarness() { +function makeHarness(overrides: { unsortable?: string } = {}) { const descriptor = { name: "demo", rowKey: (r: Row) => r.id, columns: { defs: { - id: { field: "id", title: "Id", defaultWidth: 80, editable: false, type: "number" }, + id: { + field: "id", + title: "Id", + defaultWidth: 80, + editable: false, + type: "number", + sortable: overrides.unsortable !== "id", + }, name: { field: "name", title: "Name", defaultWidth: 120, editable: false, type: "text" }, }, defaultOrder: FIELDS, - sortable: () => true, }, api: { updateRow: async () => ({ ok: true }), @@ -56,6 +62,26 @@ const render = (h: ReturnType) => const indicators = (columns: { indicatorIcon?: string }[]) => columns.map((c) => c.indicatorIcon); +describe("useGridColumns sort menu arrow", () => { + it("draws an arrow on the columns that can be sorted", async () => { + const h = makeHarness(); + const { result } = render(h); + + await waitFor(() => expect(result.current.columns).toHaveLength(2)); + + expect(result.current.columns.map((c) => c.hasMenu)).toEqual([true, true]); + }); + + it("draws no arrow on a column that says the server cannot order by it", async () => { + const h = makeHarness({ unsortable: "id" }); + const { result } = render(h); + + await waitFor(() => expect(result.current.columns).toHaveLength(2)); + + expect(result.current.columns.map((c) => c.hasMenu)).toEqual([false, true]); + }); +}); + describe("useGridColumns sort indicator", () => { it("shows no indicator on any column while the grid is unsorted", async () => { const h = makeHarness(); diff --git a/src/features/dataGrid/hooks/useGridColumns.ts b/src/features/dataGrid/hooks/useGridColumns.ts index 6ef5506..b061550 100644 --- a/src/features/dataGrid/hooks/useGridColumns.ts +++ b/src/features/dataGrid/hooks/useGridColumns.ts @@ -1,8 +1,9 @@ // src/features/dataGrid/hooks/useGridColumns.ts -import type { GridColumn } from "@glideapps/glide-data-grid"; +import { type GridColumn, GridColumnMenuIcon } from "@glideapps/glide-data-grid"; import { useCallback, useEffect, useMemo } from "react"; import { useSelector } from "react-redux"; import { SORT_ASC_ICON, SORT_DESC_ICON } from "../headerIcons"; +import { sortablePredicate } from "../sortable"; import type { GridInstance } from "../types"; import { useGridDispatch } from "../useGridDispatch"; @@ -28,6 +29,11 @@ export function useGridColumns order.filter((f) => !hidden.includes(f)), [order, hidden]); + const sortable = useMemo( + () => sortablePredicate(defs), + [defs], + ); + const columns: GridColumn[] = useMemo( () => visibleFields.map((f) => ({ @@ -36,8 +42,14 @@ export function useGridColumns, model: DisplayModel, rowAt: (dataIndex: number) => TRow | undefined, + store: RowStore, ): { gridSelection: GridSelection; onGridSelectionChange: (sel: GridSelection) => void; @@ -20,14 +24,38 @@ export function useGridSelection< const dispatch = useGridDispatch(); const { rowKey } = instance.descriptor; - const [gridSelection, setGridSelection] = useState({ + const selectedIds = useSelector((s: unknown) => instance.selectRoot(s).selection.selectedIds); + + // Columns and the focused cell only. The selected ROWS are not kept here: + // they are positions, and positions move. + const [columnsAndCell, setColumnsAndCell] = useState({ columns: CompactSelection.empty(), rows: CompactSelection.empty(), }); + /** + * The rows glide draws a marker on, worked out fresh from the selected KEYS. + * + * Holding them as display rows instead was a real bug: collapsing a group + * above a selected row shifts every row below it up, and the remembered + * position then rings a different record. The store stayed right the whole + * time — `selectedIds` is by key — so only the screen lied, which is the + * worst way for it to be wrong. + */ + const rows = useMemo( + () => + selectedDisplayRows(selectedIds as TKey[], (key) => store.indexOfKey(key), model).reduce( + (acc, row) => acc.add(row), + CompactSelection.empty(), + ), + [selectedIds, store, model], + ); + + const gridSelection = useMemo(() => ({ ...columnsAndCell, rows }), [columnsAndCell, rows]); + const onGridSelectionChange = useCallback( (sel: GridSelection) => { - setGridSelection(sel); + setColumnsAndCell(sel); const ids: TKey[] = []; diff --git a/src/features/dataGrid/hooks/useGridSort.test.tsx b/src/features/dataGrid/hooks/useGridSort.test.tsx deleted file mode 100644 index 1c72d52..0000000 --- a/src/features/dataGrid/hooks/useGridSort.test.tsx +++ /dev/null @@ -1,122 +0,0 @@ -// src/features/dataGrid/hooks/useGridSort.test.tsx -import { configureStore } from "@reduxjs/toolkit"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, renderHook } from "@testing-library/react"; -import type { ReactNode } from "react"; -import { Provider } from "react-redux"; -import { describe, expect, it } from "vitest"; -import { createGridInstance } from "../store/createGridInstance"; -import type { GridDescriptor } from "../types"; -import { useGridSort } from "./useGridSort"; - -interface Row { - id: number; - name: string; -} - -const FIELDS = ["id", "name", "notes"]; - -function makeHarness() { - const descriptor = { - name: "demo", - rowKey: (r: Row) => r.id, - columns: { - defs: { - id: { field: "id", title: "Id", defaultWidth: 80, editable: false, type: "number" }, - name: { field: "name", title: "Name", defaultWidth: 80, editable: false, type: "text" }, - notes: { field: "notes", title: "Notes", defaultWidth: 80, editable: false, type: "text" }, - }, - defaultOrder: FIELDS, - // `notes` is the unsortable one, so a click on it must change nothing. - sortable: (field: string) => field !== "notes", - }, - api: { - updateRow: async () => ({ ok: true }), - }, - cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true }, - } as unknown as GridDescriptor; - - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const instance = createGridInstance(descriptor); - const store = configureStore({ - reducer: { demo: instance.reducer }, - middleware: (getDefault) => getDefault({ serializableCheck: false }), - }); - - const wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - - ); - - const sortState = () => instance.selectRoot(store.getState()).groups.sort; - - return { instance, store, wrapper, sortState }; -} - -const render = (h: ReturnType) => - renderHook(() => useGridSort(h.instance, FIELDS), { wrapper: h.wrapper }); - -describe("useGridSort", () => { - it("sorts ascending on the first click", () => { - const h = makeHarness(); - const { result } = render(h); - - act(() => result.current.onHeaderClicked(0)); - - expect(h.sortState()).toEqual({ field: "id", dir: "asc" }); - }); - - it("reverses to descending when the same column is clicked again", () => { - const h = makeHarness(); - const { result } = render(h); - - act(() => result.current.onHeaderClicked(0)); - act(() => result.current.onHeaderClicked(0)); - - expect(h.sortState()).toEqual({ field: "id", dir: "desc" }); - }); - - it("clears the sort on the third click, so natural order is reachable", () => { - const h = makeHarness(); - const { result } = render(h); - - act(() => result.current.onHeaderClicked(0)); - act(() => result.current.onHeaderClicked(0)); - act(() => result.current.onHeaderClicked(0)); - - expect(h.sortState()).toBeNull(); - }); - - it("cycles from the start again after clearing", () => { - const h = makeHarness(); - const { result } = render(h); - - for (let i = 0; i < 4; i++) act(() => result.current.onHeaderClicked(0)); - - expect(h.sortState()).toEqual({ field: "id", dir: "asc" }); - }); - - it("starts a different column ascending rather than inheriting the direction", () => { - const h = makeHarness(); - const { result } = render(h); - - act(() => result.current.onHeaderClicked(0)); - act(() => result.current.onHeaderClicked(0)); - expect(h.sortState()).toEqual({ field: "id", dir: "desc" }); - - act(() => result.current.onHeaderClicked(1)); - - expect(h.sortState()).toEqual({ field: "name", dir: "asc" }); - }); - - it("ignores clicks on a column that is not sortable", () => { - const h = makeHarness(); - const { result } = render(h); - - act(() => result.current.onHeaderClicked(0)); - act(() => result.current.onHeaderClicked(2)); - - expect(h.sortState()).toEqual({ field: "id", dir: "asc" }); - }); -}); diff --git a/src/features/dataGrid/hooks/useGridSort.ts b/src/features/dataGrid/hooks/useGridSort.ts deleted file mode 100644 index d28842f..0000000 --- a/src/features/dataGrid/hooks/useGridSort.ts +++ /dev/null @@ -1,49 +0,0 @@ -// src/features/dataGrid/hooks/useGridSort.ts -import { useCallback, useMemo } from "react"; -import { useSelector } from "react-redux"; -import type { GridInstance, GridSort } from "../types"; -import { useGridDispatch } from "../useGridDispatch"; - -export function nextSort(current: GridSort | null, field: string): GridSort | null { - if (current?.field !== field) { - return { field, dir: "asc" }; - } - - return current.dir === "asc" ? { field, dir: "desc" } : null; -} - -export function useGridSort( - instance: GridInstance, - visibleFields: string[], -): { onHeaderClicked: (colIndex: number) => void } { - const dispatch = useGridDispatch(); - - const { descriptor } = instance; - - const sort = useSelector((s: unknown) => instance.selectRoot(s).groups.sort); - - // Default sortable predicate: editable columns only. Descriptors may override. - // Memoized so it stays referentially stable for the useCallback below - // (descriptor is fixed for the grid's lifetime). - const sortable = useMemo( - () => - descriptor.columns.sortable ?? - ((field: string) => descriptor.columns.defs[field]?.editable === true), - [descriptor], - ); - - const onHeaderClicked = useCallback( - (colIndex: number) => { - const field = visibleFields[colIndex]; - - if (!sortable(field)) return; - - // The sort is part of every window's cache key, so changing it is the - // whole refetch: the mounted windows re-request themselves on the new key. - dispatch(instance.actions.setSort(nextSort(sort, field))); - }, - [visibleFields, dispatch, instance, sortable, sort], - ); - - return { onHeaderClicked }; -} diff --git a/src/features/dataGrid/hooks/useGroupBanner.ts b/src/features/dataGrid/hooks/useGroupBanner.ts new file mode 100644 index 0000000..e32e88d --- /dev/null +++ b/src/features/dataGrid/hooks/useGroupBanner.ts @@ -0,0 +1,84 @@ +// src/features/dataGrid/hooks/useGroupBanner.ts +import { useCallback, useMemo } from "react"; +import { groupBanner, type GroupBanner } from "../groupBanner"; +import { groupHeaderTextColor } from "../groupHeaderColor"; +import type { GridGrouping } from "../types"; + +interface Args { + grouping: GridGrouping | undefined; + + /** The group the top of the viewport sits in, from `useGroupHeaders`. */ + group: TGroup | null; + + /** The groups the user has collapsed, straight from the store. */ + collapsedGroups: readonly TGroup[]; + + /** Colour for a group that names no scale of its own — the theme's header text. */ + fallbackColor: string | undefined; + + /** From `useDisplayModel`. THE ONLY dispatcher of a collapse. */ + toggleGroup: (group: TGroup) => void; + + /** From `useGroupHeaders`. */ + revealAfterCollapse: (group: TGroup) => void; +} + +interface Result extends GroupBanner { + /** + * Resolved CSS colour for any group's name. + * + * Returned as well as used, because the in-grid header layer paints the same + * names and the two must not answer differently. + */ + groupColor: (group: TGroup) => string | undefined; +} + +/** + * The header bar's group zone: which group the user is inside, and how folding + * it from up there behaves. + * + * Collapsing from the banner is how a user skips a group too tall to scroll + * past, and it is not the same act as clicking the in-grid chevron: the header + * being clicked is pinned, so the view has to be MOVED onto the real one + * afterwards. Collapsing a group removes the rows BELOW its header, so that + * header does not move, and the user lands on the group they just folded with + * the next one directly under it. + */ +export function useGroupBanner({ + grouping, + group, + collapsedGroups, + fallbackColor, + toggleGroup, + revealAfterCollapse, +}: Args): Result { + const groupColor = useCallback( + (of: TGroup) => groupHeaderTextColor(grouping?.color?.(of), fallbackColor), + [grouping, fallbackColor], + ); + + const collapseFromBanner = useCallback( + (of: TGroup) => { + toggleGroup(of); + + // Not scrolled here. The rows are dropped by the SERVER, so the grid this + // click would scroll is the one about to be replaced — see + // `useGroupHeaders.revealAfterCollapse`. + revealAfterCollapse(of); + }, + [toggleGroup, revealAfterCollapse], + ); + + const { banner, slot } = useMemo( + () => + groupBanner(group, { + grouping, + collapsedGroups, + textColor: groupColor, + onToggle: collapseFromBanner, + }), + [group, grouping, collapsedGroups, groupColor, collapseFromBanner], + ); + + return { banner, slot, groupColor }; +} diff --git a/src/features/dataGrid/hooks/useGroupHeaders.test.tsx b/src/features/dataGrid/hooks/useGroupHeaders.test.tsx new file mode 100644 index 0000000..44f83fb --- /dev/null +++ b/src/features/dataGrid/hooks/useGroupHeaders.test.tsx @@ -0,0 +1,267 @@ +import type { DataEditorRef, Rectangle } from "@glideapps/glide-data-grid"; +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { buildDisplayModel, buildFlatModel, type DisplayModel } from "../displayModel"; +import { DEFAULT_GROUP_HEADER_HEIGHT, DEFAULT_ROW_HEIGHT } from "../rowHeights"; +import type { GridGrouping } from "../types"; +import { useGroupHeaders } from "./useGroupHeaders"; + +const order = (g: number) => g; + +interface Row { + g: number; +} + +const GROUPING: GridGrouping = { + field: "g", + of: (row) => row.g, + order, + label: String, +}; + +/** Two groups of two rows: header(0) d d header(3) d d. */ +const OPEN: DisplayModel = buildDisplayModel( + { + boundaries: [ + { dataIndex: 0, group: 0 }, + { dataIndex: 2, group: 1 }, + ], + leadingGroup: null, + total: 4, + collapsedGroups: [], + discoveredGroups: [0, 1], + }, + order, +); + +/** + * The same grid once the server has answered a collapse of group 0: its rows + * are simply not sent, so only its header remains. + */ +const RELOADED: DisplayModel = buildDisplayModel( + { + boundaries: [{ dataIndex: 0, group: 1 }], + leadingGroup: null, + total: 2, + collapsedGroups: [0], + discoveredGroups: [0, 1], + }, + order, +); + +const VIEWPORT: Rectangle = { x: 0, y: 0, width: 5, height: 20 }; + +/** + * A grid whose rows have not arrived: no boundary, no preceding group. Every + * jump of the scrollbar passes through this state. + */ +const UNKNOWN: DisplayModel = buildDisplayModel( + { + boundaries: [], + leadingGroup: null, + total: 4, + collapsedGroups: [], + discoveredGroups: [0, 1], + }, + order, +); + +interface Props { + m: DisplayModel; + collapsed: number[]; +} + +/** A grid ref that records what the hook asks glide to do. */ +function fakeGrid() { + const scrollTo = vi.fn(); + + return { + scrollTo, + gridRef: { current: { scrollTo } } as unknown as React.RefObject, + }; +} + +function mount(initialProps: Props, grouping: GridGrouping | undefined = GROUPING) { + const { scrollTo, gridRef } = fakeGrid(); + + const view = renderHook( + ({ m, collapsed }: Props) => + useGroupHeaders({ model: m, collapsedGroups: collapsed, grouping, gridRef }), + { initialProps }, + ); + + // One region report, as glide makes on mount. + act(() => view.result.current.trackRegion(VIEWPORT, 0)); + + return { ...view, scrollTo }; +} + +/** + * The same, for a grid with no grouping at all. + * + * A helper of its own rather than `mount(props, undefined)`, because passing + * `undefined` to a parameter with a default gets the DEFAULT — so that call + * would quietly test a grouped grid instead of a flat one. + */ +function mountFlat(model: DisplayModel) { + const { scrollTo, gridRef } = fakeGrid(); + + const view = renderHook(() => + useGroupHeaders({ + model, + collapsedGroups: [], + grouping: undefined, + gridRef, + }), + ); + + act(() => view.result.current.trackRegion(VIEWPORT, 0)); + + return { ...view, scrollTo }; +} + +describe("useGroupHeaders", () => { + it("finds the headers the viewport can see", () => { + const { result } = mount({ m: OPEN, collapsed: [] }); + + expect(result.current.visibleHeaders.map((h) => h.group)).toEqual([0, 1]); + }); + + it("names the group the top of the viewport sits in", () => { + const { result } = mount({ m: OPEN, collapsed: [] }); + + expect(result.current.bannerGroup).toBe(0); + }); + + it("turns a chevron round the moment its group is collapsed, not when the server agrees", () => { + // A collapse is answered by the SERVER dropping the group's rows, which + // takes a round trip. Until then the model still holds them and reports the + // group as open. Reading the arrow from the model left it pointing the old + // way for the whole load, which reads as a click that did nothing. + const { result, rerender } = mount({ m: OPEN, collapsed: [] }); + + rerender({ m: OPEN, collapsed: [0] }); + + expect(result.current.visibleHeaders.find((h) => h.group === 0)?.collapsed).toBe(true); + }); + + it("brings the next group's header up when the one above it folds away", () => { + // No scroll happens here. The rows arrive, the model changes, and the + // headers have to follow it on their own. + const { result, rerender } = mount({ m: OPEN, collapsed: [] }); + const before = result.current.visibleHeaders.find((h) => h.group === 1)?.displayRow; + + rerender({ m: RELOADED, collapsed: [0] }); + + expect(before).toBe(3); + expect(result.current.visibleHeaders.find((h) => h.group === 1)?.displayRow).toBe(1); + }); + + it("keeps a collapsed group's header, because it is the only way back", () => { + const { result, rerender } = mount({ m: OPEN, collapsed: [] }); + + rerender({ m: RELOADED, collapsed: [0] }); + + expect(result.current.visibleHeaders.map((h) => h.group)).toEqual([0, 1]); + }); + + it("leaves a grid that is not grouping alone", () => { + const { result } = mountFlat(OPEN); + + expect(result.current.visibleHeaders).toEqual([]); + expect(result.current.bannerGroup).toBeNull(); + }); + + it("reserves a hole as tall as the header the host asked for", () => { + // The canvas reserves the hole by THIS number and nothing measures the + // React drawn into it, so the hole and the placement must be one number. + const { result } = mount({ m: OPEN, collapsed: [] }, { ...GROUPING, headerHeight: 64 }); + + expect(result.current.rowHeight(0)).toBe(64); + expect(result.current.rowHeight(1)).toBe(DEFAULT_ROW_HEIGHT); + }); + + it("falls a header back to the default height when the descriptor names none", () => { + const { result } = mount({ m: OPEN, collapsed: [] }); + + expect(result.current.rowHeight(0)).toBe(DEFAULT_GROUP_HEADER_HEIGHT); + }); + + it("leaves every row the grid's own height in a flat grid, which draws no headers", () => { + // The height is read off the MODEL, not off the grouping config: a flat + // grid is one whose model has no header rows to be tall. + const { result } = mountFlat(buildFlatModel(50)); + + expect([0, 1, 20].map(result.current.rowHeight)).toEqual([ + DEFAULT_ROW_HEIGHT, + DEFAULT_ROW_HEIGHT, + DEFAULT_ROW_HEIGHT, + ]); + }); + + it("keeps naming the last group it knew while the rows for a jump are loading", () => { + // Dropping the name the moment the model cannot answer made the banner + // blink out and back on every scrollbar jump, and the bar changed height + // with it, which shoved the grid up and down under the pointer. + const { result, rerender } = mount({ m: OPEN, collapsed: [] }); + + rerender({ m: UNKNOWN, collapsed: [] }); + + expect(result.current.bannerGroup).toBe(0); + }); + + it("takes the new group the moment the rows say what it is", () => { + // Holding the last name is for the gap, not past it. The instant the model + // can answer, the banner has to agree with the rows under it. + const { result, rerender } = mount({ m: OPEN, collapsed: [] }); + + rerender({ m: UNKNOWN, collapsed: [] }); + expect(result.current.bannerGroup).toBe(0); + + rerender({ m: OPEN, collapsed: [] }); + act(() => result.current.trackRegion({ ...VIEWPORT, y: 3 }, 0)); + + expect(result.current.bannerGroup).toBe(1); + }); + + it("waits for the collapse to land before moving the view", () => { + // The collapse is answered by the server. Scrolling straight after the + // click moves the view using the geometry of the grid that is about to be + // replaced, and the rows slide out from under it — the user ends up + // somewhere in the middle of the next group instead of on top of it. + const { result, scrollTo } = mount({ m: OPEN, collapsed: [] }); + + act(() => result.current.revealAfterCollapse(0)); + + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it("lands on the folded header once the rows without it arrive", () => { + const { result, rerender, scrollTo } = mount({ m: OPEN, collapsed: [] }); + + act(() => result.current.revealAfterCollapse(0)); + rerender({ m: RELOADED, collapsed: [0] }); + + expect(scrollTo).toHaveBeenCalledWith(0, 0, "vertical", 0, 0, { vAlign: "start" }); + }); + + it("moves the view once, and then leaves the user's scrolling alone", () => { + const { result, rerender, scrollTo } = mount({ m: OPEN, collapsed: [] }); + + act(() => result.current.revealAfterCollapse(0)); + rerender({ m: RELOADED, collapsed: [0] }); + rerender({ m: RELOADED, collapsed: [0] }); + + expect(scrollTo).toHaveBeenCalledTimes(1); + }); + + it("gives up if the group is opened again before its rows have gone", () => { + const { result, rerender, scrollTo } = mount({ m: OPEN, collapsed: [] }); + + act(() => result.current.revealAfterCollapse(0)); + rerender({ m: OPEN, collapsed: [] }); + rerender({ m: RELOADED, collapsed: [0] }); + + expect(scrollTo).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/dataGrid/hooks/useGroupHeaders.ts b/src/features/dataGrid/hooks/useGroupHeaders.ts new file mode 100644 index 0000000..8ee8139 --- /dev/null +++ b/src/features/dataGrid/hooks/useGroupHeaders.ts @@ -0,0 +1,238 @@ +// src/features/dataGrid/hooks/useGroupHeaders.ts +import type { DataEditorRef, Rectangle } from "@glideapps/glide-data-grid"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + groupAtDisplayRow, + headersInDisplayRange, + sameHeaders, + type DisplayModel, + type VisibleHeader, +} from "../displayModel"; +import { headerPlacements } from "../headerPlacements"; +import type { GroupHeaderLayerHandle, HeaderPlacement } from "../GroupHeaderLayer"; +import { + COLUMN_HEADER_HEIGHT, + DEFAULT_GROUP_HEADER_HEIGHT, + DEFAULT_ROW_HEIGHT, + rowHeightFor, +} from "../rowHeights"; +import type { GridGrouping } from "../types"; + +interface Args { + model: DisplayModel; + + /** + * The groups the user has collapsed, straight from the store. + * + * The chevrons read from HERE and not from the model. A collapse is answered + * by the server dropping the group's rows, which takes a round trip; until it + * lands the model still holds them and still reports the group as open. An + * arrow that waited for that would sit the wrong way round for the whole + * load, which reads as a click that did nothing. + */ + collapsedGroups: readonly TGroup[]; + + /** Absent → the grid is flat, and this hook does nothing. */ + grouping: GridGrouping | undefined; + + /** The grid itself, for putting a folded group's header back under the eye. */ + gridRef: React.RefObject; +} + +interface Result { + /** Hand to ``. */ + layerRef: React.RefObject; + + /** Hand to ``. */ + visibleHeaders: VisibleHeader[]; + + /** The group the top of the viewport sits in, for the banner. Null when flat. */ + bannerGroup: TGroup | null; + + /** + * Hand to ``. + * + * It comes out of THIS hook because it is the same number twice: the height + * the canvas reserves a header's hole by, and the height this hook measures + * every placement down from. Two owners deriving it separately is the drift + * that leaves a header floating off its own hole. + */ + rowHeight: (displayRow: number) => number; + + /** Call from `onVisibleRegionChanged`, passing its region and its `ty`. */ + trackRegion: (rect: Rectangle, translateY: number) => void; + + /** + * Ask to be put on a group's header once that group has actually folded. + * + * Call it alongside the collapse, not after it. A collapse is answered by + * the server dropping the group's rows, and moving the view before they go + * scrolls the grid that is about to be replaced — the rows slide out from + * under the offset and the user lands in the middle of the next group. + */ + revealAfterCollapse: (group: TGroup) => void; +} + +/** + * Keeps the header layer and the banner following the viewport. + * + * TWO CLOCKS, and reading only one of them was the shape of every bug this hook + * has had. Glide reports a region when the user SCROLLS. The model changes for + * reasons that produce no region at all — a collapse, a page landing, a sort — + * and each of those moves the holes or changes which headers exist. So the last + * region is remembered and the answer is recomputed after EVERY commit, against + * that commit's model. The scroll path still short-circuits on `sameHeaders`, + * so scrolling inside one tall group costs no render. + * + * The split that buys the smoothness: React state carries WHICH headers exist, + * a direct DOM write carries WHERE they are. + */ +export function useGroupHeaders({ + model, + collapsedGroups, + grouping, + gridRef, +}: Args): Result { + const layerRef = useRef(null); + + const grouped = grouping !== undefined; + + // A group header's row is taller than a data row, and glide asks per row. + // Passing a function replaces glide's default for EVERY row, so data rows + // have to be told the height they already had. + const rowHeight = useMemo( + () => + rowHeightFor( + model, + DEFAULT_ROW_HEIGHT, + grouping?.headerHeight ?? DEFAULT_GROUP_HEADER_HEIGHT, + ), + [model, grouping], + ); + + const [visibleHeaders, setVisibleHeaders] = useState[]>([]); + const [bannerGroup, setBannerGroup] = useState(null); + + // The last region glide reported, with the sub-row offset that came with it. + // A ref rather than state: both change on every scroll frame, and holding + // them in state would re-render the grid on each one — the exact cost this + // design exists to avoid. + const lastRect = useRef(null); + const lastTranslateY = useRef(0); + + const placements = useRef>(new Map()); + + // A group whose header the view owes a visit, once its rows are gone. + const pendingReveal = useRef(null); + + const revealAfterCollapse = useCallback((group: TGroup) => { + pendingReveal.current = group; + }, []); + + const scrollToRow = useCallback( + (displayRow: number) => { + gridRef.current?.scrollTo(0, displayRow, "vertical", 0, 0, { vAlign: "start" }); + }, + [gridRef], + ); + + const sync = useCallback( + ( + against: DisplayModel, + collapsed: readonly TGroup[], + rect: Rectangle, + translateY: number, + ) => { + const next = headersInDisplayRange(against, rect.y, rect.y + rect.height).map((header) => ({ + ...header, + collapsed: collapsed.includes(header.group), + })); + + setVisibleHeaders((prev) => (sameHeaders(prev, next) ? prev : next)); + + // The group of the FIRST VISIBLE ROW, not of the last header scrolled + // past. The two differ for every group taller than the viewport, which is + // the only case a banner is needed for at all. + // + // A null answer is HELD rather than shown. Every jump of the scrollbar + // lands on rows that have not arrived, and the model cannot name a group + // it has no rows for. Clearing the banner there made it blink out and + // back on every jump, taking the bar's height with it and shoving the + // grid up and down under the pointer. The last name is the better answer + // for that gap: it is where the user was, it is one scroll old at most, + // and the loading cells below already say the rows are not there yet. + const group = groupAtDisplayRow(against, rect.y); + + if (group !== null) { + setBannerGroup((prev) => (Object.is(prev, group) ? prev : group)); + } + + placements.current = headerPlacements(next, { + firstRow: rect.y, + translateY, + headerHeight: COLUMN_HEADER_HEIGHT, + heightOf: rowHeight, + }); + + layerRef.current?.place(placements.current); + }, + [rowHeight], + ); + + const trackRegion = useCallback( + (rect: Rectangle, translateY: number) => { + if (!grouped) { + return; + } + + lastRect.current = rect; + lastTranslateY.current = translateY; + + sync(model, collapsedGroups, rect, translateY); + }, + [collapsedGroups, grouped, model, sync], + ); + + // No dependency array, deliberately. Anything that re-renders the grid can + // have moved a hole or changed which headers exist, and none of it reaches + // `trackRegion`. Both setters short-circuit on an unchanged answer, so this + // settles in one extra pass rather than looping. + useLayoutEffect(() => { + const rect = lastRect.current; + + if (grouped && rect !== null) { + sync(model, collapsedGroups, rect, lastTranslateY.current); + } + }); + + // The other half of `revealAfterCollapse`. The MODEL is what says the collapse + // has landed: `collapsedGroups` flips on the click, but the group keeps its + // rows until the server answers without them, and only then is the geometry + // the one the view should scroll into. + useLayoutEffect(() => { + const group = pendingReveal.current; + + if (group === null) { + return; + } + + // Opened again while we waited. There is nothing to land on any more. + if (!collapsedGroups.includes(group)) { + pendingReveal.current = null; + return; + } + + const folded = model.segments.find( + (segment) => segment.group === group && segment.collapsed && segment.hasHeader, + ); + + if (folded === undefined) { + return; + } + + pendingReveal.current = null; + scrollToRow(folded.displayStart); + }, [collapsedGroups, model, scrollToRow]); + + return { layerRef, visibleHeaders, bannerGroup, rowHeight, trackRegion, revealAfterCollapse }; +} diff --git a/src/features/dataGrid/hooks/useRowPages.ts b/src/features/dataGrid/hooks/useRowPages.ts index 7767c4f..169e4d0 100644 --- a/src/features/dataGrid/hooks/useRowPages.ts +++ b/src/features/dataGrid/hooks/useRowPages.ts @@ -368,7 +368,7 @@ async function loadPage = { + id: { field: "id", title: "ID", defaultWidth: 80, editable: false, type: "int" }, + name: { field: "name", title: "Name", defaultWidth: 200, editable: true, type: "text" }, + region: { field: "region", title: "Region", defaultWidth: 120, editable: false, type: "region" }, +}; + +const ORDER = ["id", "name", "region"]; + +describe("pickerColumns", () => { + it("lists the columns in the order the grid draws them", () => { + const rows = pickerColumns(DEFS, ["region", "id", "name"], [], undefined); + + expect(rows.map((r) => r.field)).toEqual(["region", "id", "name"]); + }); + + it("takes each column's name from the descriptor", () => { + expect(pickerColumns(DEFS, ORDER, [], undefined)[0].title).toBe("ID"); + }); + + it("marks the columns the grid is not drawing", () => { + const rows = pickerColumns(DEFS, ORDER, ["name"], undefined); + + expect(rows.find((r) => r.field === "name")?.hidden).toBe(true); + expect(rows.find((r) => r.field === "id")?.hidden).toBe(false); + }); + + it("marks the column the grid groups by", () => { + const rows = pickerColumns(DEFS, ORDER, [], "region"); + + expect(rows.find((r) => r.field === "region")?.grouped).toBe(true); + expect(rows.find((r) => r.field === "id")?.grouped).toBe(false); + }); + + it("marks nothing in a grid that is not grouping", () => { + const rows = pickerColumns(DEFS, ORDER, [], undefined); + + expect(rows.some((r) => r.grouped)).toBe(false); + }); + + it("skips an ordered field the descriptor never defined", () => { + // A stored column order outlives the descriptor that wrote it. A field + // dropped from the code would otherwise crash the picker on a title read. + const rows = pickerColumns(DEFS, [...ORDER, "ghost"], [], undefined); + + expect(rows.map((r) => r.field)).toEqual(ORDER); + }); +}); diff --git a/src/features/dataGrid/pickerColumns.ts b/src/features/dataGrid/pickerColumns.ts new file mode 100644 index 0000000..53a8914 --- /dev/null +++ b/src/features/dataGrid/pickerColumns.ts @@ -0,0 +1,26 @@ +import type { PickerColumn } from "./ColumnPickerList"; +import type { ColumnDef } from "./types"; + +/** + * What the column picker shows, in the order the grid draws. + * + * A field in `order` with no `ColumnDef` is skipped rather than rendered blank. + * The order outlives the descriptor that wrote it — it is persisted by the + * host, through `api.saveColumns` — so a field dropped from the code comes back + * from storage long after there is anything to draw for it. + */ +export function pickerColumns( + defs: Record, + order: readonly string[], + hidden: readonly string[], + groupField: string | undefined, +): PickerColumn[] { + return order + .filter((field) => defs[field] !== undefined) + .map((field) => ({ + field, + title: defs[field].title, + hidden: hidden.includes(field), + grouped: field === groupField, + })); +} diff --git a/src/features/dataGrid/rowHeights.test.ts b/src/features/dataGrid/rowHeights.test.ts new file mode 100644 index 0000000..90ce3c0 --- /dev/null +++ b/src/features/dataGrid/rowHeights.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { buildDisplayModel, buildFlatModel } from "./displayModel"; +import { DEFAULT_GROUP_HEADER_HEIGHT, DEFAULT_ROW_HEIGHT, rowHeightFor } from "./rowHeights"; + +const order = (g: number) => g; + +/** header(0) d d header(1) d d => 6 display rows. */ +const GROUPED = buildDisplayModel( + { + boundaries: [ + { dataIndex: 0, group: 0 }, + { dataIndex: 2, group: 1 }, + ], + leadingGroup: null, + total: 4, + collapsedGroups: [], + discoveredGroups: [0, 1], + }, + order, +); + +describe("rowHeightFor", () => { + it("gives a group header the room its components need", () => { + expect(rowHeightFor(GROUPED, 34, 64)(0)).toBe(64); + }); + + it("leaves a data row at the grid's own row height", () => { + expect(rowHeightFor(GROUPED, 34, 64)(1)).toBe(34); + }); + + it("finds the header of a group further down the grid", () => { + expect(rowHeightFor(GROUPED, 34, 64)(3)).toBe(64); + }); + + it("makes every row the same in a flat grid, which draws no headers", () => { + const height = rowHeightFor(buildFlatModel(50), 34, 64); + + expect([0, 1, 20].map(height)).toEqual([34, 34, 34]); + }); + + it("answers a row past the end without throwing, because glide prefetches one", () => { + expect(rowHeightFor(GROUPED, 34, 64)(GROUPED.rowCount)).toBe(34); + }); + + it("names a default height, so a descriptor need not care until it does", () => { + expect(DEFAULT_GROUP_HEADER_HEIGHT).toBeGreaterThan(0); + }); + + it("keeps glide's own default row height, so passing a function changes nothing else", () => { + // Handing glide a rowHeight FUNCTION replaces its default for every row, + // headers and data alike. Data rows have to be told the height they already + // had, or the whole grid silently changes density. + expect(DEFAULT_ROW_HEIGHT).toBe(34); + }); +}); diff --git a/src/features/dataGrid/rowHeights.ts b/src/features/dataGrid/rowHeights.ts new file mode 100644 index 0000000..b6b3ab7 --- /dev/null +++ b/src/features/dataGrid/rowHeights.ts @@ -0,0 +1,44 @@ +import { displayToData, type DisplayModel } from "./displayModel"; + +/** The height a group header gets when the descriptor names none. */ +export const DEFAULT_GROUP_HEADER_HEIGHT = 40; + +/** + * Glide's own default row height. + * + * Handing glide a rowHeight FUNCTION replaces that default for every row, + * headers and data alike, so data rows have to be told the height they + * already had — otherwise adding group headers silently changes the density + * of the whole grid. + */ +export const DEFAULT_ROW_HEIGHT = 34; + +/** + * Height of the grid's own column-header row, pinned rather than left to glide. + * + * `useGroupHeaders` measures every hole down from this line, so the number it + * measures with and the number handed to `` have to be + * the same one. 36 is glide's default; naming it is what stops a change to one + * silently un-aligning the other. + */ +export const COLUMN_HEADER_HEIGHT = 36; + +/** + * The height of every display row, which glide asks for one row at a time. + * + * The canvas reserves a group header's hole by THIS number, and the React that + * fills the hole is measured by nothing. That is why `grouping.headerHeight` is + * a number the host sets rather than something the engine measures: measuring + * would mean draw, measure, resize, draw — on every scroll, and untestable. + * + * A row past the end answers the ordinary row height rather than throwing. + * Glide asks about one row beyond the last as a prefetch hint. + */ +export function rowHeightFor( + model: DisplayModel, + rowHeight: number, + headerHeight: number, +): (displayRow: number) => number { + return (displayRow) => + displayToData(model, displayRow).kind === "header" ? headerHeight : rowHeight; +} diff --git a/src/features/dataGrid/selectionRows.test.ts b/src/features/dataGrid/selectionRows.test.ts new file mode 100644 index 0000000..e23a052 --- /dev/null +++ b/src/features/dataGrid/selectionRows.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { buildDisplayModel, buildFlatModel, type DisplayModel } from "./displayModel"; +import { selectedDisplayRows } from "./selectionRows"; + +const order = (g: number) => g; + +/** Two groups of two rows: header(0) d0 d1 header(3) d2 d3. */ +const OPEN: DisplayModel = buildDisplayModel( + { + boundaries: [ + { dataIndex: 0, group: 0 }, + { dataIndex: 2, group: 1 }, + ], + leadingGroup: null, + total: 4, + collapsedGroups: [], + discoveredGroups: [0, 1], + }, + order, +); + +/** The same grid once group 0 is collapsed: its rows are gone. header(0) header(1) d0 d1. */ +const COLLAPSED: DisplayModel = buildDisplayModel( + { + boundaries: [{ dataIndex: 0, group: 1 }], + leadingGroup: null, + total: 2, + collapsedGroups: [0], + discoveredGroups: [0, 1], + }, + order, +); + +/** Row keys 10..13 sit at data indexes 0..3 while nothing is collapsed. */ +const openIndex = (key: number) => ({ 10: 0, 11: 1, 12: 2, 13: 3 })[key]; + +/** Once group 0 folds, its two rows are not sent, and 12/13 move to the front. */ +const collapsedIndex = (key: number) => ({ 12: 0, 13: 1 })[key]; + +describe("selectedDisplayRows", () => { + it("finds the row a selected key is currently drawn at", () => { + // Data 2 sits at display 4: header, two rows, header. + expect(selectedDisplayRows([12], openIndex, OPEN)).toEqual([4]); + }); + + it("follows a key when a collapse moves it", () => { + // The bug this exists for: the highlight was remembered as a display ROW, + // so folding a group above it left the ring sitting on whichever row had + // moved into that position — a different record entirely. + expect(selectedDisplayRows([12], collapsedIndex, COLLAPSED)).toEqual([2]); + }); + + it("keeps several selected rows in the order they are drawn", () => { + expect(selectedDisplayRows([13, 10], openIndex, OPEN)).toEqual([1, 5]); + }); + + it("drops a key whose row is no longer loaded, rather than guessing at one", () => { + // A selected row scrolled far out of the window is evicted from the store. + // It stays selected — `selectedIds` is what the host reads — but there is + // no row on screen to draw a ring around. + expect(selectedDisplayRows([10], collapsedIndex, COLLAPSED)).toEqual([]); + }); + + it("answers nothing when nothing is selected", () => { + expect(selectedDisplayRows([], openIndex, OPEN)).toEqual([]); + }); + + it("works on a flat grid, where a data row is its own display row", () => { + expect(selectedDisplayRows([13], openIndex, buildFlatModel(4))).toEqual([3]); + }); +}); diff --git a/src/features/dataGrid/selectionRows.ts b/src/features/dataGrid/selectionRows.ts new file mode 100644 index 0000000..a66b90f --- /dev/null +++ b/src/features/dataGrid/selectionRows.ts @@ -0,0 +1,33 @@ +import { displayRowsOfData, type DisplayModel } from "./displayModel"; + +/** + * The display rows a set of selected KEYS is currently drawn at. + * + * Selection is stored by key and drawn by position, and this is the translation + * between the two. Keeping the drawn set as positions instead is a bug with a + * name in this codebase's glossary: "positions move; keys do not". Fold a group + * above a selected row and every row below it shifts up, so a remembered + * display row ends up ringing a different record — one the host was never told + * about, because `selectedIds` had been right the whole time. + * + * A key the store cannot place is left out. That is a row evicted from the + * cache or excluded by a collapse: still selected, still in `selectedIds`, just + * not on screen to draw a ring around. It comes back when its rows do. + */ +export function selectedDisplayRows( + selectedIds: readonly TKey[], + indexOfKey: (key: TKey) => number | undefined, + model: DisplayModel, +): number[] { + const dataIndexes: number[] = []; + + for (const key of selectedIds) { + const index = indexOfKey(key); + + if (index !== undefined) { + dataIndexes.push(index); + } + } + + return displayRowsOfData(model, dataIndexes); +} diff --git a/src/features/dataGrid/sortable.test.ts b/src/features/dataGrid/sortable.test.ts new file mode 100644 index 0000000..26d3ccb --- /dev/null +++ b/src/features/dataGrid/sortable.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { sortablePredicate } from "./sortable"; +import type { ColumnDef } from "./types"; + +const DEFS: Record = { + id: { field: "id", title: "ID", defaultWidth: 80, editable: false, type: "int" }, + name: { field: "name", title: "Name", defaultWidth: 200, editable: true, type: "text" }, + value: { + field: "value", + title: "Value", + defaultWidth: 140, + editable: false, + type: "currency", + sortable: false, + }, +}; + +describe("sortablePredicate", () => { + it("offers every column that says nothing", () => { + const sortable = sortablePredicate(DEFS); + + expect(sortable("name")).toBe(true); + expect(sortable("id")).toBe(true); + }); + + it("does not tie the answer to whether the column can be edited", () => { + expect(sortablePredicate(DEFS)("id")).toBe(true); + }); + + it("refuses the column that says the server cannot order by it", () => { + expect(sortablePredicate(DEFS)("value")).toBe(false); + }); + + it("refuses a field the grid does not draw", () => { + expect(sortablePredicate(DEFS)("gone")).toBe(false); + }); +}); diff --git a/src/features/dataGrid/sortable.ts b/src/features/dataGrid/sortable.ts new file mode 100644 index 0000000..99e698c --- /dev/null +++ b/src/features/dataGrid/sortable.ts @@ -0,0 +1,20 @@ +import type { ColumnDef } from "./types"; + +/** + * Whether a column offers a sort, resolved once for the whole grid. + * + * Two readers must agree: `hooks/useGridColumns.ts`, which decides whether the + * header draws a menu arrow at all, and `hooks/useColumnSortMenu.tsx`, which + * decides whether a click on one opens anything. A header that draws an arrow + * onto a menu that refuses to open is the failure this shared answer prevents. + * + * The default is yes. Sorting reads a column; editing writes one, and a column + * being read-only says nothing about whether the server can order by it. A + * column that the server genuinely cannot order by says so itself, on its own + * `ColumnDef`. + */ +export function sortablePredicate( + defs: Record, +): (field: string) => boolean { + return (field: string) => defs[field] !== undefined && defs[field].sortable !== false; +} diff --git a/src/features/dataGrid/store/columnsSlice.test.ts b/src/features/dataGrid/store/columnsSlice.test.ts index d064714..1bfb6cb 100644 --- a/src/features/dataGrid/store/columnsSlice.test.ts +++ b/src/features/dataGrid/store/columnsSlice.test.ts @@ -86,4 +86,24 @@ describe("createColumnsSlice", () => { expect(await drain(loadColumns())).toEqual([]); }); }); + + it("resetColumns puts every column back the way the descriptor described it", () => { + const { reducer, actions } = make(); + let s = reducer(undefined, { type: "@@init" }); + s = reducer(s, actions.toggleColumn("b")); + s = reducer(s, actions.resizeColumn({ field: "a", width: 321 })); + s = reducer(s, actions.moveColumn({ from: 0, to: 2 })); + + s = reducer(s, actions.resetColumns()); + + expect(s).toEqual({ order: ["a", "b", "c"], widths: {}, hidden: [] }); + }); + + it("resetColumns does not hand back the same array the next reset would mutate", () => { + const { reducer, actions } = make(); + let s = reducer(reducer(undefined, { type: "@@init" }), actions.resetColumns()); + s = reducer(s, actions.moveColumn({ from: 0, to: 2 })); + + expect(reducer(s, actions.resetColumns()).order).toEqual(["a", "b", "c"]); + }); }); diff --git a/src/features/dataGrid/store/columnsSlice.ts b/src/features/dataGrid/store/columnsSlice.ts index e666a7f..7e2f218 100644 --- a/src/features/dataGrid/store/columnsSlice.ts +++ b/src/features/dataGrid/store/columnsSlice.ts @@ -26,6 +26,20 @@ export function createColumnsSlice( if (state.hidden.includes(f)) state.hidden = state.hidden.filter((x) => x !== f); else state.hidden.push(f); }, + /** + * Back to what the descriptor described: default order, no stored widths, + * nothing hidden. + * + * `defaultOrder` is copied rather than assigned. `moveColumn` splices + * `state.order` in place, so handing out the descriptor's own array would + * let the first drag after a reset rewrite the descriptor — and every + * later reset would then restore the dragged order. + */ + resetColumns(state) { + state.order = [...defaultOrder]; + state.widths = {}; + state.hidden = []; + }, // Hydration from a load. Merge: only overwrite a field when the loaded value // is meaningful, so a partial/empty stored payload can't blank the grid. setColumns(state, action: PayloadAction) { diff --git a/src/features/dataGrid/store/editsSlice.ts b/src/features/dataGrid/store/editsSlice.ts index 8920947..7362fec 100644 --- a/src/features/dataGrid/store/editsSlice.ts +++ b/src/features/dataGrid/store/editsSlice.ts @@ -18,7 +18,7 @@ import type { EditError, EditState } from "../types"; * no-op, which is what it should be. * * - `editErrorCleared` is the UNCONDITIONAL way out, dispatched by - * `GridStatusBar`'s dismiss. Dismiss means "I am done with this message", + * `GridHeaderBar`'s dismiss. Dismiss means "I am done with this message", * whatever cell it came from, so it carries no cell and matches nothing. * Clearing on success alone would leave a user who gives up editing staring * at the message for the life of the page; a dismiss is the only exit that diff --git a/src/features/dataGrid/store/effects/columnsPersistenceEffect.ts b/src/features/dataGrid/store/effects/columnsPersistenceEffect.ts index 92da682..b2bdf0d 100644 --- a/src/features/dataGrid/store/effects/columnsPersistenceEffect.ts +++ b/src/features/dataGrid/store/effects/columnsPersistenceEffect.ts @@ -9,8 +9,12 @@ export const columnsPersistenceEffect: GridEffect = (ctx, startListening) => { if (!save) return; - const { moveColumn, resizeColumn, toggleColumn } = ctx.actions.columns; - const watched = [moveColumn.type, resizeColumn.type, toggleColumn.type]; + const { moveColumn, resetColumns, resizeColumn, toggleColumn } = ctx.actions.columns; + + // `resetColumns` is watched for the same reason as the other three: it + // CHANGES the layout. Leaving it out would let a reset look like it worked + // and then hand the thrown-away layout straight back on the next load. + const watched = [moveColumn.type, resetColumns.type, resizeColumn.type, toggleColumn.type]; startListening({ predicate: (action: { type: string }) => watched.includes(action.type), diff --git a/src/features/dataGrid/store/effects/index.test.ts b/src/features/dataGrid/store/effects/index.test.ts index 870b5df..342a5f4 100644 --- a/src/features/dataGrid/store/effects/index.test.ts +++ b/src/features/dataGrid/store/effects/index.test.ts @@ -86,4 +86,23 @@ describe("grid effects on the host app's listener", () => { expect(typeof inst.stopEffects).toBe("function"); inst.stopEffects(); }); + + it("persists a reset, so a layout the user threw away does not come back on reload", async () => { + const save = vi.fn().mockResolvedValue(undefined); + const inst = createGridInstance(makeDescriptor("resetDemo", save), { + startListening: appListener.startListening, + }); + const store = makeStore("resetDemo", inst.reducer); + + store.dispatch(inst.actions.resizeColumn({ field: "id", width: 200 })); + await vi.advanceTimersByTimeAsync(600); + save.mockClear(); + + store.dispatch(inst.actions.resetColumns()); + await vi.advanceTimersByTimeAsync(600); + + expect(save).toHaveBeenCalledWith({ order: ["id"], widths: {}, hidden: [] }); + + inst.stopEffects(); + }); }); diff --git a/src/features/dataGrid/types.ts b/src/features/dataGrid/types.ts index d85197c..be68ee6 100644 --- a/src/features/dataGrid/types.ts +++ b/src/features/dataGrid/types.ts @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import type { Reducer } from "@reduxjs/toolkit"; import type { CellRegistry } from "../../lib/grid/cellRegistry"; import type { RadixColor } from "../../lib/grid/radixBadgePalette"; @@ -28,6 +29,16 @@ export interface ColumnDef { */ editable: boolean; + /** + * Whether this column's header offers a sort menu. Defaults to true. + * + * Set it false for a column the SERVER cannot order by: a column the row + * carries but the query has no index for, or one the client derives and the + * server has never heard of. The sort is sent to the server, so an arrow on + * such a column is an arrow onto an error. + */ + sortable?: boolean; + /** * Cell kind. Must match a cell registry entry. */ @@ -178,6 +189,36 @@ export interface GridGrouping { * not an error. */ color?: (g: TGroup) => RadixColor | undefined; + + /** + * The host's components for a group, drawn to the RIGHT of the group's name. + * + * The engine always draws the collapse control and the name itself, so a host + * cannot accidentally ship a group that can never be folded. What comes back + * from here fills the rest of the row. + * + * The SAME node is used in two places: the header above the group's rows, and + * the banner in the grid's header bar naming the group the user is currently + * inside. That is deliberate — one rule, so the pinned copy and the in-grid + * copy cannot drift apart. + * + * It is called during render, once per visible group. Keep it cheap, and keep + * it a pure function of the group: it is not the place to start a fetch. + */ + header?: (group: TGroup) => ReactNode; + + /** + * How tall a group header row is, in pixels. Defaults to 40. + * + * The canvas reserves the header's space by this number BEFORE React draws + * into it, and nothing measures what React drew. A header taller than this + * is clipped; a shorter one leaves a gap. Set it to fit the tallest thing + * `header` can return. + * + * It is a number rather than a measurement on purpose: measuring would mean + * draw, measure, resize, draw, on every scroll. + */ + headerHeight?: number; } /** @@ -208,11 +249,6 @@ export interface GridDescriptor boolean; }; /** diff --git a/src/index.ts b/src/index.ts index 968b1ae..5597199 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,6 +82,25 @@ export { export { drawSoftBadge, type ResolvedBadgeColors } from "./lib/grid/softBadge"; export { BADGE_SEQUENCE, radixColorByIndex, type RadixColor } from "./lib/grid/radixBadgePalette"; +// ------------------------------------------------------------ date picker -- + +/** + * The date picker the date cell edits through, exported for the host's own + * DOM: filters, toolbars, forms. `DatePicker` renders inline (a grid overlay + * already floats it); `DatePickerPopover` adds a trigger for ordinary DOM. + */ +export { + DatePicker, + type DatePickerMode, + type DatePickerProps, + type DateRange, +} from "./components/ui/datePicker"; + +export { + DatePickerPopover, + type DatePickerPopoverProps, +} from "./components/ui/datePickerPopover"; + // ---------------------------------------------------------------- theme -- export { diff --git a/src/lib/date/dateMask.test.ts b/src/lib/date/dateMask.test.ts new file mode 100644 index 0000000..5feb7f2 --- /dev/null +++ b/src/lib/date/dateMask.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { dateToMask, maskToDate } from "./dateMask"; + +describe("maskToDate", () => { + it("reads eight digits as a local-midnight MM/DD/YYYY date", () => { + expect(maskToDate("06202026")).toEqual(new Date(2026, 5, 20)); + }); + + it("accepts a leap day that exists", () => { + expect(maskToDate("02292024")).toEqual(new Date(2024, 1, 29)); + }); + + it("rejects an empty string", () => { + expect(maskToDate("")).toBeUndefined(); + }); + + it("rejects a partially typed date", () => { + expect(maskToDate("0620202")).toBeUndefined(); + }); + + it("rejects a month above twelve", () => { + expect(maskToDate("13012026")).toBeUndefined(); + }); + + it("rejects a zero month", () => { + expect(maskToDate("00012026")).toBeUndefined(); + }); + + it("rejects a zero day", () => { + expect(maskToDate("06002026")).toBeUndefined(); + }); + + it("rejects a day the month does not have", () => { + expect(maskToDate("02302026")).toBeUndefined(); + }); + + it("rejects a leap day in a non-leap year", () => { + expect(maskToDate("02292026")).toBeUndefined(); + }); + + it("rejects non-digit characters", () => { + expect(maskToDate("06/20/20")).toBeUndefined(); + }); +}); + +describe("dateToMask", () => { + it("writes a date as eight zero-padded digits", () => { + expect(dateToMask(new Date(2026, 5, 20))).toBe("06202026"); + }); + + it("pads a single-digit month and day", () => { + expect(dateToMask(new Date(2026, 0, 1))).toBe("01012026"); + }); + + it("returns an empty string for no date", () => { + expect(dateToMask(undefined)).toBe(""); + }); + + it("round-trips with maskToDate", () => { + const date = new Date(2001, 10, 9); + + expect(maskToDate(dateToMask(date))).toEqual(date); + }); +}); diff --git a/src/lib/date/dateMask.ts b/src/lib/date/dateMask.ts new file mode 100644 index 0000000..56184ba --- /dev/null +++ b/src/lib/date/dateMask.ts @@ -0,0 +1,52 @@ +/** + * The MM/DD/YYYY input mask, expressed as the eight bare digits the mask holds. + * Pure and grid-agnostic: the calendar side of the picker speaks local-midnight + * `Date`s (see `isoToCalendarDate` / `composeIso` in `dateUtils`), so these two + * translate the typed digits to and from exactly that. + */ + +/** How many digits a complete MM/DD/YYYY mask holds. */ +export const MASK_LENGTH = 8; + +/** + * Digits typed into an MM/DD/YYYY mask → a local-midnight Date, or undefined + * when the mask is incomplete or names a day that does not exist. A partially + * typed date is deliberately `undefined` rather than a guess: the picker leaves + * the calendar alone until the user has typed a real day. + */ +export function maskToDate(digits: string): Date | undefined { + if (digits.length !== MASK_LENGTH || !/^\d{8}$/.test(digits)) { + return undefined; + } + + const month = Number(digits.slice(0, 2)); + const day = Number(digits.slice(2, 4)); + const year = Number(digits.slice(4, 8)); + + const date = new Date(year, month - 1, day); + + // Rejects both out-of-range parts and overflow days (Feb 30 → Mar 2), since + // the Date constructor silently rolls those forward. + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return undefined; + } + + return date; +} + +/** A local-midnight calendar Date → the eight mask digits, or "" for no date. */ +export function dateToMask(date: Date | undefined): string { + if (!date || isNaN(date.getTime())) { + return ""; + } + + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const year = String(date.getFullYear()).padStart(4, "0"); + + return `${month}${day}${year}`; +} diff --git a/src/lib/grid/createDateCell.editor.test.tsx b/src/lib/grid/createDateCell.editor.test.tsx new file mode 100644 index 0000000..193e415 --- /dev/null +++ b/src/lib/grid/createDateCell.editor.test.tsx @@ -0,0 +1,109 @@ +import type { CustomCell } from "@glideapps/glide-data-grid"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ComponentType } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createDateCell, type DateCellData } from "./createDateCell"; + +const date = createDateCell({ kind: "date", nullable: true }); + +interface EditorHandlers { + value: CustomCell; + onChange: (updated: CustomCell) => void; + onFinishedEditing: (updated?: CustomCell) => void; + isValid?: boolean; +} + +/** Mount the cell's overlay editor the way glide-data-grid does. */ +function mountEditor(cell: CustomCell, handlers: Partial = {}) { + const Editor = date.renderer.provideEditor?.(cell) as unknown as ComponentType; + + const props: EditorHandlers = { + value: cell, + onChange: handlers.onChange ?? vi.fn(), + onFinishedEditing: handlers.onFinishedEditing ?? vi.fn(), + isValid: true, + }; + + render( + + + , + ); +} + +const utcIso = (y: number, m: number, d: number) => new Date(Date.UTC(y, m - 1, d)).toISOString(); + +describe("date cell editor", () => { + it("shows the cell's value in the masked input", () => { + mountEditor(date.makeCell(utcIso(2000, 1, 1), false)); + + expect(screen.getByLabelText("Date")).toHaveValue("01/01/2000"); + }); + + it("commits the new value only when OK is pressed", async () => { + const user = userEvent.setup(); + const onFinishedEditing = vi.fn(); + const onChange = vi.fn(); + + mountEditor(date.makeCell(null, false), { onFinishedEditing, onChange }); + await user.type(screen.getByLabelText("Date"), "06202026"); + + expect(onFinishedEditing).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "OK" })); + + expect(onFinishedEditing).toHaveBeenCalledOnce(); + expect(onFinishedEditing.mock.calls[0][0].data).toEqual({ + kind: "date", + value: utcIso(2026, 6, 20), + withTime: false, + }); + }); + + it("cancels the edit with no value on Cancel", async () => { + const user = userEvent.setup(); + const onFinishedEditing = vi.fn(); + + mountEditor(date.makeCell(null, false), { onFinishedEditing }); + await user.type(screen.getByLabelText("Date"), "06202026"); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onFinishedEditing).toHaveBeenCalledExactlyOnceWith(undefined); + }); + + it("offers a time input for a withTime cell", () => { + mountEditor(date.makeCell(new Date(2026, 5, 20, 13, 30).toISOString(), true)); + + expect(screen.getByLabelText("Time")).toHaveValue("13:30"); + }); + + it("commits null from an empty value because the cell is nullable", async () => { + const user = userEvent.setup(); + const onFinishedEditing = vi.fn(); + + mountEditor(date.makeCell(utcIso(2000, 1, 1), false), { onFinishedEditing }); + await user.clear(screen.getByLabelText("Date")); + await user.click(screen.getByRole("button", { name: "OK" })); + + expect(onFinishedEditing.mock.calls[0][0].data.value).toBeNull(); + }); + + it("blocks OK on an empty value when the cell is not nullable", () => { + const required = createDateCell({ kind: "required-date", nullable: false }); + const cell = required.makeCell(null, false); + const Editor = required.renderer.provideEditor?.( + cell, + ) as unknown as ComponentType>; + + render( + + + , + ); + + expect(screen.getByRole("button", { name: "OK" })).toBeDisabled(); + }); +}); diff --git a/src/lib/grid/createDateCell.tsx b/src/lib/grid/createDateCell.tsx index 59971f2..003df05 100644 --- a/src/lib/grid/createDateCell.tsx +++ b/src/lib/grid/createDateCell.tsx @@ -1,17 +1,8 @@ import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; -import { Text, Theme } from "@radix-ui/themes"; -import { useState, type ChangeEvent } from "react"; -import { Calendar } from "../../components/ui/calendar"; -import { Input } from "../../components/ui/input"; +import { Theme } from "@radix-ui/themes"; +import { DatePicker } from "../../components/ui/datePicker"; import { gridRadixTheme } from "../../theme/radixTheme"; -import { - composeIso, - formatDateDisplay, - isoToCalendarDate, - isoToTimeInput, - isValidDateValue, - parseDatePaste, -} from "../date/dateUtils"; +import { formatDateDisplay, isValidDateValue, parseDatePaste } from "../date/dateUtils"; import { createCustomCell, drawEmptyDash, @@ -19,10 +10,6 @@ import { type EditorProps, } from "./createCustomCell"; -/** How many years before/after the current year the calendar's year dropdown spans. */ -const CALENDAR_YEARS_BACK = 500; -const CALENDAR_YEARS_FORWARD = 500; - /** Cell payload. `value` is a canonical UTC ISO string, or null for an unset cell. */ export interface DateCellData { kind: string; @@ -43,8 +30,11 @@ export interface DateCell { /** * Build a reusable date cell: a canvas text `draw` (localized date, or date+time - * when the cell's `withTime` is set) plus a shadcn `Calendar` editor with an - * optional native time input. Stores canonical UTC ISO strings. + * when the cell's `withTime` is set) plus a `DatePicker` overlay editor. Stores + * canonical UTC ISO strings. + * + * The editor HOLDS the edit: nothing reaches the cell until the user presses OK, + * and Cancel or Escape discards it. A click on a day is no longer a commit. * * Call once at module scope (not inside a React render): the editor component * identity is tied to this call, so re-creating it per render would remount it. @@ -56,63 +46,17 @@ export function createDateCell({ kind: string; nullable?: boolean; }): DateCell { - const Editor = ({ value, onChange }: EditorProps) => { - const selected = isoToCalendarDate(value.value, value.withTime); - const currentYear = new Date().getFullYear(); - - const [time, setTime] = useState(isoToTimeInput(value.value)); - - const handleDay = (day?: Date) => { - if (!day) { - // Deselect clears the cell only when empty is allowed (mirrors onPaste). - if (nullable) { - onChange({ ...value, value: null }); - } - return; - } - onChange({ ...value, value: composeIso(day, time, value.withTime) }); - }; - - const handleTime = (e: ChangeEvent) => { - const next = e.target.value; - setTime(next); - - if (selected) { - onChange({ ...value, value: composeIso(selected, next, value.withTime) }); - } - }; - - return ( - -
- - {value.withTime && ( - - )} -
-
- ); - }; + const Editor = ({ value, onFinishedEditing }: EditorProps) => ( + + onFinishedEditing({ ...value, value: next })} + onCancel={() => onFinishedEditing(undefined)} + /> + + ); const renderer = createCustomCell({ kind, diff --git a/src/lib/grid/createEnumCell.tsx b/src/lib/grid/createEnumCell.tsx index 076a018..513320b 100644 --- a/src/lib/grid/createEnumCell.tsx +++ b/src/lib/grid/createEnumCell.tsx @@ -88,7 +88,19 @@ export function createEnumCell({ defaultOpen > - + {/* + The dropdown is PORTALLED out of the cell overlay, so glide sees a + press on an option as a click outside the editor. Its outside-click + handler runs on `pointerdown`, in the capture phase, which is before + the Select decides anything on `pointerup` — so the overlay closed + and the choice was thrown away. Only the keyboard worked, because it + never presses a pointer. + + `click-outside-ignore` is glide's own escape hatch for exactly this: + it walks up from whatever was pressed and stops if it finds the + class. See `internal/click-outside-container`. + */} + {nullable && ( None diff --git a/src/lib/grid/enumEditorClickOutside.test.tsx b/src/lib/grid/enumEditorClickOutside.test.tsx new file mode 100644 index 0000000..638cc6a --- /dev/null +++ b/src/lib/grid/enumEditorClickOutside.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from "@testing-library/react"; +import type { ComponentType } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createEnumCell } from "./createEnumCell"; + +const cell = createEnumCell({ + kind: "enum-click-outside-test", + options: [ + { value: 1, label: "One" }, + { value: 2, label: "Two" }, + ], +}); + +/** + * Glide's own outside-click test, copied from + * `internal/click-outside-container/click-outside-container.js`. + * + * It listens for `pointerdown` on the document in the CAPTURE phase, and if the + * target is not inside the overlay it walks up looking for this class. Finding + * it is the only thing that stops the overlay closing. + */ +function glideWouldCloseTheEditor(target: Element | null): boolean { + let node: Element | null = target; + + while (node !== null) { + if (node.classList.contains("click-outside-ignore")) { + return false; + } + + node = node.parentElement; + } + + return true; +} + +function renderEditor() { + const onFinishedEditing = vi.fn(); + const provided = cell.renderer.provideEditor?.(cell.makeCell(1)); + + const Editor = ( + typeof provided === "function" ? provided : (provided as { editor: unknown })?.editor + ) as ComponentType>; + + render( + {}} + onFinishedEditing={onFinishedEditing} + forceEditMode + initialValue="" + target={{ x: 0, y: 0, width: 100, height: 34 }} + isHighlighted={false} + />, + ); + + return { onFinishedEditing }; +} + +describe("the enum editor's dropdown, against glide's outside-click rule", () => { + it("is not treated as a click outside the editor", () => { + // The dropdown is PORTALLED out of the overlay, so a mouse press on an + // option lands outside it. Glide closes the editor on `pointerdown`, which + // runs before the Select's own selection on `pointerup` — so the choice was + // thrown away and only the keyboard, which never presses a pointer, could + // save an enum. + renderEditor(); + + const [option] = screen.getAllByRole("option"); + + expect(glideWouldCloseTheEditor(option)).toBe(false); + }); + + it("keeps the whole dropdown safe, not just the option that was pressed", () => { + // The padding around the items, the scroll buttons and the viewport are all + // pressable, and every one of them would otherwise cancel the edit. + renderEditor(); + + const [option] = screen.getAllByRole("option"); + const around = option.parentElement; + + expect(glideWouldCloseTheEditor(around)).toBe(false); + }); +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e0f3668..14d4f61 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,12 +1,20 @@ /** * Join class names, skipping anything falsy. * - * Deliberately not `clsx` + `tailwind-merge`: this package ships plain CSS - * classes rather than Tailwind utilities (Tailwind builds its stylesheet by - * scanning source, and never scans `node_modules`, so a utility class inside a - * published package produces no CSS at all). With no utilities to de-conflict, - * there is nothing for `tailwind-merge` to do — and a host app is still free to - * pass its own Tailwind classes in through `className`. + * Deliberately not `clsx` + `tailwind-merge`. This package DOES ship Tailwind + * utilities, but every one of them is `dg:`-prefixed and the stylesheet is + * generated at build time from `src/datagrid.src.css`, so a host app's own + * Tailwind and ours cannot collide — and `tailwind-merge` would not recognise + * the prefix anyway. + * + * De-conflicting inside this package is handled at the source instead: where + * two states set the same property, the components pick ONE whole string rather + * than layering an override on a base (see `components/ui/input.tsx`). Tailwind + * orders its output by utility, not by the order class names appear on an + * element, so an override written that way would be decided by the generated + * stylesheet rather than by the component. + * + * A host app is still free to pass its own classes in through `className`. */ export function cx(...parts: Array): string { return parts.filter(Boolean).join(" "); diff --git a/src/test/setup.ts b/src/test/setup.ts index d0de870..9ff9752 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1 +1,14 @@ import "@testing-library/jest-dom"; + +/** + * jsdom ships no `ResizeObserver`, and Radix's popovers construct one on mount. + * A stub that observes nothing is enough: the tests that mount a popover assert + * on what is IN it, never on where it was placed. + */ +if (!("ResizeObserver" in globalThis)) { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +}