From 5a24bc31aefd8f2bb6ec58d27dd2405fa9dd50d1 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sat, 22 Aug 2026 18:25:49 -0600 Subject: [PATCH 01/15] feat: add justfile for streamlined CI checks and version releases --- README.md | 37 ++++++++++++++++++++++++------- justfile | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 justfile diff --git a/README.md b/README.md index 6ae8d88..0bea0cf 100644 --- a/README.md +++ b/README.md @@ -157,22 +157,43 @@ npm run lint npm run build # dist/ — JS, .d.ts, and datagrid.css ``` +`just check` runs all four in one go. The recipes live in +[`justfile`](./justfile) and need [`just`](https://github.com/casey/just) +installed; everything they call is a plain `npm` script, so `just` is a +convenience, never a requirement. + ## Releasing Versions ship from CI on a `v*` tag, authenticated with npm **trusted publishing** (OIDC) — there is no `NPM_TOKEN` anywhere in this repo. ```bash -# bump package.json first, then: -git tag v0.1.1 -git push origin main --tags +just release patch # or: minor, major ``` -The workflow refuses to publish if the tag and `package.json` disagree. - -npm cannot enable OIDC for a package that does not exist yet -([npm/cli#8544](https://github.com/npm/cli/issues/8544)), so the very first -version was published by hand. See the header of +That is the whole process. `just release` runs the checks, bumps +`package.json`, commits, tags, and pushes — in that order, so the tag always +points at the code that actually ships. It refuses to run from a dirty tree, a +branch other than `main`, a branch out of sync with `origin`, or a failing +`just check`. + +Do not tag by hand. `git tag` on its own leaves `package.json` behind, and a +tag cut before a later fix keeps pointing at the old commit — CI then builds +code you already replaced. The workflow refuses to publish if the tag and +`package.json` disagree, but it cannot catch a stale tag that happens to match. + +Two things had to be set up once, and neither lives in this repo: + +- npm cannot enable OIDC for a package that does not exist yet + ([npm/cli#8544](https://github.com/npm/cli/issues/8544)), so `0.1.0` was + published by hand. +- The package must list this repo as a trusted publisher on npmjs.com + (package → Settings → Trusted publisher → GitHub Actions, workflow + `publish.yml`, no environment). Without it the publish fails at the last + step with `404 Not Found - PUT`, which reads like a missing package but + means npm rejected the credential. + +See the header of [`.github/workflows/publish.yml`](.github/workflows/publish.yml). ## Licence 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." From 10a9a02367a0e6a03184d4853b094c3c724a6c31 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sat, 22 Aug 2026 20:06:08 -0600 Subject: [PATCH 02/15] build: generate datagrid.css with Tailwind v4 under a dg: prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stylesheet was nine hand-written rules. The trigger for switching was always "when the second real UI surface lands", and the group banner, the column picker and the toolbar are that surface. Consumers see no change: same `@matthewhsu1/datagrid/datagrid.css` import, same `dg-` class names for the one rule that stays a class. Three things are load-bearing in `src/datagrid.src.css`: - Preflight is deliberately left out. `@import "tailwindcss"` pulls in a reset, and a package that resets its host's margins and form controls is a package that vandalises the app installing it. Importing `theme.css` and `utilities.css` separately takes the utilities without the reset. - `prefix(dg)` namespaces every utility and every theme variable, so a host app's own Tailwind and ours cannot collide. - `@source` is what makes any of this work. Tailwind builds its output by scanning source, and it never scans `node_modules` — which is why a package cannot ship unprefixed utilities and hope the consumer emits them. Scanning at build time and shipping the result closes that hole. `Input` gains an `invalid` prop rather than taking a `dg-input--invalid` class from outside. Tailwind orders its output by utility, not by the order class names appear on an element, so an override layered on a base would be decided by the generated stylesheet instead of by the component. The two states are now whole, mutually exclusive strings. The demo aliases the package stylesheet to the SOURCE and runs Tailwind itself, so the demo stops styling itself the moment that source stops compiling. --- demo/vite.config.ts | 9 +- package-lock.json | 1704 +++++++++++++++++++++----- package.json | 6 +- scripts/copy-assets.mjs | 8 - src/components/ui/calendar.tsx | 6 +- src/components/ui/input.tsx | 43 +- src/components/ui/validatedInput.tsx | 8 +- src/datagrid.css | 91 -- src/datagrid.src.css | 59 + src/lib/grid/createDateCell.tsx | 4 +- src/lib/utils.ts | 20 +- 11 files changed, 1535 insertions(+), 423 deletions(-) delete mode 100644 scripts/copy-assets.mjs delete mode 100644 src/datagrid.css create mode 100644 src/datagrid.src.css diff --git a/demo/vite.config.ts b/demo/vite.config.ts index 235f38c..3db492c 100644 --- a/demo/vite.config.ts +++ b/demo/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; import { fileURLToPath } from "node:url"; const here = (p: string) => fileURLToPath(new URL(p, import.meta.url)); @@ -13,10 +14,14 @@ const here = (p: string) => fileURLToPath(new URL(p, import.meta.url)); */ export default defineConfig({ root: here("."), - plugins: [react()], + // The package's stylesheet is GENERATED from `src/datagrid.src.css`, and the + // alias below points the demo at that source rather than at a built file. + // Tailwind therefore has to run here too — which is the point: the demo + // fails to style itself if the source stylesheet stops compiling. + 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/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 index 73612c9..ec23f8c 100644 --- a/src/components/ui/calendar.tsx +++ b/src/components/ui/calendar.tsx @@ -10,14 +10,14 @@ 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 + * in the `.dg-date-editor` scope (see datagrid.src.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 = @@ -28,7 +28,7 @@ export function Calendar({ className, showOutsideDays = true, ...props }: Calend : orientation === "up" ? ChevronUp : ChevronDown; - return ; + return ; }, }} {...props} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 0ab9bae..17b954a 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -1,9 +1,46 @@ import { forwardRef, type InputHTMLAttributes } from "react"; import { cx } from "../../lib/utils"; -export const Input = forwardRef>( - ({ className, type, ...props }, ref) => ( - +/** + * Everything the field looks like regardless of validity. + * + * The colours are Radix tokens with a literal fallback. The fallback matters: + * a cell editor portals out of the host's tree, and if it landed somewhere with + * no above it the var would resolve to nothing and the input would + * render borderless and transparent. + */ +const BASE = + "dg:box-border dg:flex dg:h-9 dg:rounded-md dg:border dg:px-3 dg:py-1 dg:text-sm " + + "dg:bg-[var(--color-panel-solid,#fff)] dg:text-[var(--gray-12,#18181b)] " + + "dg:outline-none dg:disabled:cursor-not-allowed dg:disabled:opacity-50"; + +/* + * The two states are written as WHOLE alternatives rather than a base plus an + * override, because Tailwind orders its output by utility, not by the order + * class names appear on the element. `border-gray-7` and `border-red-9` are the + * same utility, so which one won would be decided by the generated stylesheet, + * not by this file. Mutually exclusive strings take that decision back. + */ +const VALID = + "dg:border-[var(--gray-7,#d4d4d8)] " + + "dg:focus-visible:shadow-[0_0_0_2px_var(--accent-8,#6366f1)]"; + +const INVALID = + "dg:border-[var(--red-9,#e5484d)] " + "dg:focus-visible:shadow-[0_0_0_2px_var(--red-8,#eb8e90)]"; + +export interface InputProps extends InputHTMLAttributes { + /** Draw the field in its error colours. */ + invalid?: boolean; +} + +export const Input = forwardRef( + ({ className, type, invalid = false, ...props }, ref) => ( + ), ); Input.displayName = "Input"; diff --git a/src/components/ui/validatedInput.tsx b/src/components/ui/validatedInput.tsx index 7b3edd7..50082af 100644 --- a/src/components/ui/validatedInput.tsx +++ b/src/components/ui/validatedInput.tsx @@ -1,5 +1,4 @@ import { forwardRef, useEffect, useRef, useState, type InputHTMLAttributes } from "react"; -import { cx } from "../../lib/utils"; import { Input } from "./input"; import { validateText, type TextValidationOptions } from "../../lib/text/textValidation"; @@ -43,7 +42,7 @@ export const ValidatedInput = forwardRef( const showError = touched && !valid; return ( -
+
( }} maxLength={maxLength ?? validation.maxLength} aria-invalid={showError || undefined} - className={cx(showError && "dg-input--invalid", className)} + invalid={showError} + className={className} {...rest} /> - {showError && {error}} + {showError && {error}}
); }, diff --git a/src/datagrid.css b/src/datagrid.css deleted file mode 100644 index fe055d3..0000000 --- a/src/datagrid.css +++ /dev/null @@ -1,91 +0,0 @@ -/* - * The package's own styles. Import once, anywhere in the host app: - * - * import "@matthewhsu1/datagrid/datagrid.css"; - * - * Everything here is plain CSS on `dg-`-prefixed classes. There is no Tailwind: - * Tailwind builds its stylesheet by scanning source files and never scans - * `node_modules`, so a utility class shipped inside a package produces no rule - * at all — the element would carry the class and render unstyled, with no - * error. Colours come from Radix Themes tokens, which resolve because every - * grid popup mounts its own (see theme/radixTheme.ts). - */ - -/* ---------------------------------------------------------------- inputs -- */ - -.dg-input { - box-sizing: border-box; - display: flex; - height: 2.25rem; - padding: 0.25rem 0.75rem; - border: 1px solid var(--gray-7, #d4d4d8); - border-radius: 0.375rem; - background: var(--color-panel-solid, #fff); - color: var(--gray-12, #18181b); - font-size: 0.875rem; - line-height: 1.25rem; - outline: none; -} - -.dg-input:focus-visible { - box-shadow: 0 0 0 2px var(--accent-8, #6366f1); -} - -.dg-input:disabled { - cursor: not-allowed; - opacity: 0.5; -} - -/* Applied by ValidatedInput once the field has been touched and is invalid. */ -.dg-input--invalid { - border-color: var(--red-9, #e5484d); -} - -.dg-input--invalid:focus-visible { - box-shadow: 0 0 0 2px var(--red-8, #eb8e90); -} - -.dg-input--block { - width: 100%; -} - -/* ---------------------------------------------------------------- fields -- */ - -.dg-field { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.dg-field--stacked { - margin-top: 0.5rem; -} - -.dg-field__error { - font-size: 0.75rem; - line-height: 1rem; - color: var(--red-11, #ce2c31); -} - -/* -------------------------------------------------------------- calendar -- */ - -.dg-calendar { - padding: 0.75rem; -} - -.dg-calendar__chevron { - width: 1rem; - height: 1rem; -} - -/* - * react-day-picker accent binding for the date-cell editor. renders - * as a DESCENDANT of this class, so these vars cascade into the react-day-picker - * root and bind its accent to Radix's accent. That is what makes the calendar - * follow `configureGridTheme({ accentColor })` at runtime, with no code change. - * Kept scoped, because they apply to react-day-picker and nothing else. - */ -.dg-date-editor { - --rdp-accent-color: var(--accent-9); - --rdp-accent-background-color: var(--accent-3); -} diff --git a/src/datagrid.src.css b/src/datagrid.src.css new file mode 100644 index 0000000..4307082 --- /dev/null +++ b/src/datagrid.src.css @@ -0,0 +1,59 @@ +/* + * The SOURCE of the package's stylesheet. `dist/datagrid.css` is generated from + * this file by Tailwind; nothing hand-writes that output, and nothing imports + * this file at runtime. + * + * Consumers still import one plain stylesheet, exactly as before: + * + * import "@matthewhsu1/datagrid/datagrid.css"; + * + * Three things about this file are load-bearing: + * + * 1. PREFLIGHT IS DELIBERATELY ABSENT. `@import "tailwindcss"` would pull in + * Tailwind's reset, and a library stylesheet that resets the host's margins, + * heading sizes, and form controls is a package that vandalises the app that + * installs it. Importing `theme.css` and `utilities.css` separately is the + * documented way to take the utilities without the reset. + * + * 2. `prefix(dg)` makes every utility `dg:`-shaped and every theme variable + * `--dg-*`. Two packages can then both ship Tailwind without their + * `.flex` rules fighting, and a host app's own Tailwind cannot be + * overwritten by ours. + * + * 3. `@source` is what makes any of this exist. Tailwind builds its output by + * SCANNING source files for class names, and it never scans `node_modules`. + * That is why a package cannot ship unprefixed utility classes and hope the + * consumer's Tailwind emits them: the class would land on the element with + * no rule behind it, silently. Scanning here, at build time, and shipping + * the result is what closes that hole. + * + * Colours come from Radix Themes tokens, written as arbitrary values. They + * resolve because every grid popup mounts its own — see + * `theme/radixTheme.ts`. + */ + +@layer theme, base, components, utilities; + +@import "tailwindcss/theme.css" layer(theme) prefix(dg); +@import "tailwindcss/utilities.css" layer(utilities) prefix(dg); + +@source "./**/*.tsx"; + +/* -------------------------------------------------------------- calendar -- */ + +/* + * react-day-picker accent binding for the date-cell editor, and the one rule + * here that is not a utility. + * + * renders as a DESCENDANT of this class, so these vars cascade into + * the react-day-picker root and bind its accent to Radix's accent. That is what + * makes the calendar follow `configureGridTheme({ accentColor })` at runtime, + * with no code change. A utility cannot express it: the class does not style + * the element it sits on, it styles a subtree the library owns. + */ +@layer components { + .dg-date-editor { + --rdp-accent-color: var(--accent-9); + --rdp-accent-background-color: var(--accent-3); + } +} diff --git a/src/lib/grid/createDateCell.tsx b/src/lib/grid/createDateCell.tsx index 59971f2..d05abf9 100644 --- a/src/lib/grid/createDateCell.tsx +++ b/src/lib/grid/createDateCell.tsx @@ -96,7 +96,7 @@ export function createDateCell({ autoFocus /> {value.withTime && ( -
); diff --git a/src/features/dataGrid/GridHeaderBar.test.tsx b/src/features/dataGrid/GridHeaderBar.test.tsx index 9891684..6df42cc 100644 --- a/src/features/dataGrid/GridHeaderBar.test.tsx +++ b/src/features/dataGrid/GridHeaderBar.test.tsx @@ -124,4 +124,13 @@ describe("GridHeaderBar", () => { 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/GroupHeaderLayer.tsx b/src/features/dataGrid/GroupHeaderLayer.tsx index 922cc26..d05e362 100644 --- a/src/features/dataGrid/GroupHeaderLayer.tsx +++ b/src/features/dataGrid/GroupHeaderLayer.tsx @@ -30,6 +30,15 @@ export interface GroupHeaderLayerProps { /** 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; } @@ -52,7 +61,16 @@ export interface GroupHeaderLayerProps { * unselectable. */ function GroupHeaderLayerInner( - { headers, label, textColor, slot, onToggle }: GroupHeaderLayerProps, + { + headers, + label, + textColor, + slot, + background, + markerWidth, + gutterRight, + onToggle, + }: GroupHeaderLayerProps, ref: React.ForwardedRef, ) { const nodes = useRef(new Map()); @@ -97,6 +115,9 @@ function GroupHeaderLayerInner( onToggle(h.group)} > diff --git a/src/features/dataGrid/GroupHeaderRow.test.tsx b/src/features/dataGrid/GroupHeaderRow.test.tsx index 84c0be5..e0ad013 100644 --- a/src/features/dataGrid/GroupHeaderRow.test.tsx +++ b/src/features/dataGrid/GroupHeaderRow.test.tsx @@ -55,4 +55,42 @@ describe("GroupHeaderRow", () => { 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 index a95809a..6ea2f3b 100644 --- a/src/features/dataGrid/GroupHeaderRow.tsx +++ b/src/features/dataGrid/GroupHeaderRow.tsx @@ -1,3 +1,4 @@ +import { IconButton, Text } from "@radix-ui/themes"; import { ChevronDown, ChevronRight } from "lucide-react"; import type { ReactNode } from "react"; @@ -14,6 +15,9 @@ import type { ReactNode } from "react"; export function GroupHeaderRow({ label, textColor, + background, + markerWidth = 0, + gutterRight = 0, collapsed, onToggle, children, @@ -23,6 +27,21 @@ export function GroupHeaderRow({ /** 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; @@ -32,23 +51,46 @@ export function GroupHeaderRow({ 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 02fa63a..c8c5a27 100644 --- a/src/features/dataGrid/displayModel.test.ts +++ b/src/features/dataGrid/displayModel.test.ts @@ -9,6 +9,7 @@ import { firstAffectedDataIndex, groupAtDisplayRow, headersInDisplayRange, + sameHeaders, type Boundary, } from "./displayModel"; @@ -482,3 +483,35 @@ describe("groupAtDisplayRow", () => { 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 adcbc24..416dc27 100644 --- a/src/features/dataGrid/displayModel.ts +++ b/src/features/dataGrid/displayModel.ts @@ -503,3 +503,27 @@ export function groupAtDisplayRow( 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/groupBanner.test.ts b/src/features/dataGrid/groupBanner.test.ts new file mode 100644 index 0000000..c1743d0 --- /dev/null +++ b/src/features/dataGrid/groupBanner.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { groupBanner } from "./groupBanner"; +import type { GridGrouping } from "./types"; + +interface Row { + region: number; +} + +const GROUPING: GridGrouping = { + field: "region", + of: (row) => row.region, + order: (g) => g, + label: (g) => `Region ${g}`, +}; + +const ARGS = { + grouping: GROUPING, + collapsedGroups: [] as readonly number[], + textColor: () => "#111", + onToggle: () => {}, +}; + +describe("groupBanner", () => { + it("names the group the viewport is inside", () => { + expect(groupBanner(0, ARGS).banner?.label).toBe("Region 0"); + }); + + it("colours the name with the colour it is handed", () => { + expect(groupBanner(0, { ...ARGS, textColor: () => "#c0ffee" }).banner?.textColor).toBe( + "#c0ffee", + ); + }); + + it("reads collapsed from the store, not from the model", () => { + // A collapse is answered by the server dropping the group's rows. The + // model still reports the group as open until they go, so an arrow read + // from it sits the wrong way round for the whole load. + expect(groupBanner(1, { ...ARGS, collapsedGroups: [1] }).banner?.collapsed).toBe(true); + }); + + it("toggles the group it names, and nothing else", () => { + const onToggle = vi.fn(); + + groupBanner(2, { ...ARGS, onToggle }).banner?.onToggle(); + + expect(onToggle).toHaveBeenCalledWith(2); + }); + + it("shows no banner for a grid that is not grouping", () => { + expect(groupBanner(0, { ...ARGS, grouping: undefined })).toEqual({ + banner: null, + slot: undefined, + }); + }); + + it("shows no banner while the group is unknown", () => { + expect(groupBanner(null, ARGS).banner).toBeNull(); + }); + + it("draws the host's components for the current group", () => { + const grouping = { ...GROUPING, header: (g: number) => `slot ${g}` }; + + expect(groupBanner(3, { ...ARGS, grouping }).slot).toBe("slot 3"); + }); + + it("leaves no slot row at all when the host has no components", () => { + expect(groupBanner(0, ARGS).slot).toBeUndefined(); + }); + + it("keeps an EMPTY slot row while the group is unknown, so the bar cannot change height", () => { + // Dropping the row on every scrollbar jump took its height with it and + // shoved the grid up and down under the pointer. `null` holds the space. + const grouping = { ...GROUPING, header: (g: number) => `slot ${g}` }; + + expect(groupBanner(null, { ...ARGS, grouping }).slot).toBeNull(); + }); +}); diff --git a/src/features/dataGrid/groupBanner.ts b/src/features/dataGrid/groupBanner.ts new file mode 100644 index 0000000..d8dc672 --- /dev/null +++ b/src/features/dataGrid/groupBanner.ts @@ -0,0 +1,85 @@ +import type { ReactNode } from "react"; +import type { BannerGroup } from "./GridHeaderBar"; +import type { GridGrouping } from "./types"; + +/** What the header bar shows for the group the viewport is currently inside. */ +export interface GroupBanner { + /** Hand to ``. Null when there is no group to name. */ + banner: BannerGroup | null; + + /** + * Hand to ``. + * + * `undefined` and `null` mean different things here, deliberately. `undefined` + * is "this host has no components for a group", and draws no slot row at all. + * `null` is "it has some, but the group is not known yet", and draws the row + * EMPTY — keeping its height, so the bar does not grow and shrink, and shove + * the grid up and down, every time a scrollbar jump lands on rows that have + * not arrived. + */ + slot: ReactNode | undefined; +} + +/** A grid that is not grouping: no name to show, and no row to keep space for. */ +const NO_BANNER: GroupBanner = { banner: null, slot: undefined }; + +export interface GroupBannerArgs { + grouping: GridGrouping | undefined; + + /** The groups the user has collapsed, straight from the store. */ + collapsedGroups: readonly TGroup[]; + + /** Resolved CSS colour for the group's name. See `groupHeaderColor`. */ + textColor: (group: TGroup) => string | undefined; + + onToggle: (group: TGroup) => void; +} + +function slotFor( + grouping: GridGrouping, + group: TGroup | null, +): ReactNode | undefined { + if (grouping.header === undefined) { + return undefined; + } + + return group === null ? null : grouping.header(group); +} + +/** + * The banner and its slot, from ONE reading of the current group. + * + * One derivation with two answers, rather than two derivations. The two + * disagree on purpose about a group that is not known yet — see + * `GroupBanner.slot` — and a rule that subtle, written out twice, is a rule + * that drifts. + * + * `collapsed` comes from the STORE's collapsed set and not from the display + * model, for the same reason the in-grid chevrons do: a collapse is answered by + * the server dropping the group's rows, and the model still reports the group + * as open until they go. See `useGroupHeaders`. + */ +export function groupBanner( + group: TGroup | null, + { grouping, collapsedGroups, textColor, onToggle }: GroupBannerArgs, +): GroupBanner { + if (grouping === undefined) { + return NO_BANNER; + } + + const slot = slotFor(grouping, group); + + if (group === null) { + return { banner: null, slot }; + } + + return { + banner: { + label: grouping.label(group), + textColor: textColor(group), + collapsed: collapsedGroups.includes(group), + onToggle: () => onToggle(group), + }, + slot, + }; +} diff --git a/src/features/dataGrid/headerPlacements.test.ts b/src/features/dataGrid/headerPlacements.test.ts new file mode 100644 index 0000000..fe703fe --- /dev/null +++ b/src/features/dataGrid/headerPlacements.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { headerPlacements, type CanvasGeometry } from "./headerPlacements"; +import type { VisibleHeader } from "./displayModel"; + +const HEADERS: VisibleHeader[] = [ + { displayRow: 0, group: "EMEA", collapsed: false }, + { displayRow: 5, group: "Americas", collapsed: false }, +]; + +const HEADER_HEIGHT = 36; + +/** Every row 34 tall, the two group headers 44. */ +const heightOf = (row: number) => (row === 0 || row === 5 ? 44 : 34); + +const geometry = (over: Partial = {}): CanvasGeometry => ({ + firstRow: 0, + translateY: 0, + headerHeight: HEADER_HEIGHT, + heightOf, + ...over, +}); + +describe("headerPlacements", () => { + it("puts the first visible row directly under the column headers", () => { + expect(headerPlacements(HEADERS, geometry()).get(0)?.top).toBe(36); + }); + + it("stacks the rows between the top of the view and the header", () => { + // Row 0 is a 44-tall header, rows 1 to 4 are 34 each: 44 + 136 = 180. + expect(headerPlacements(HEADERS, geometry()).get(5)?.top).toBe(36 + 180); + }); + + it("follows the scroll within a row, which is what a fast scroll is made of", () => { + // `ty` is the sub-row offset glide reports with the region. Reading the + // position back off the canvas instead lands a frame behind, because glide + // has not re-rendered when it calls us — that is a header sliding off its + // band whenever the scroll is quick. + expect(headerPlacements(HEADERS, geometry({ translateY: -17 })).get(0)?.top).toBe(19); + }); + + it("places a header the view has scrolled up past, at a negative offset", () => { + // Viewport starts at row 5; row 0's header is above it by 44 + 34*4. + expect(headerPlacements(HEADERS, geometry({ firstRow: 5 })).get(0)?.top).toBe(36 - 180); + }); + + it("covers its own row exactly, and not a pixel of the row beneath", () => { + // It used to take one pixel more, to hide the border glide draws under the + // row. That pixel is where the row below draws the TOP edge of its + // selection ring, so a selected cell directly under a group header looked + // like it had no top border. + expect(headerPlacements(HEADERS, geometry()).get(0)?.height).toBe(44); + }); + + it("answers an empty map for a grid with no headers on screen", () => { + expect(headerPlacements([], geometry()).size).toBe(0); + }); +}); diff --git a/src/features/dataGrid/headerPlacements.ts b/src/features/dataGrid/headerPlacements.ts new file mode 100644 index 0000000..bc89484 --- /dev/null +++ b/src/features/dataGrid/headerPlacements.ts @@ -0,0 +1,72 @@ +import type { VisibleHeader } from "./displayModel"; +import type { HeaderPlacement } from "./GroupHeaderLayer"; + +/** Everything needed to say where a display row is drawn, as of one region report. */ +export interface CanvasGeometry { + /** First display row in the viewport — `rect.y`. */ + firstRow: number; + + /** The sub-row pixel offset glide reports alongside the region — its `ty`. */ + translateY: number; + + /** Height of the grid's own column-header row. */ + headerHeight: number; + + /** Height of any display row. */ + heightOf: (displayRow: number) => number; +} + +/** + * Where one display row is drawn, measured from the top of the canvas. + * + * This is glide's own arithmetic, repeated. That is a real cost and it was not + * the first choice: the obvious move is to ask `getBounds` and let glide answer + * about itself. + * + * It cannot. `getBounds` reads glide's visible-region STATE, and glide reports + * a region change before React has committed that state — the ref is updated, + * the state is not. So the answer describes the frame BEFORE the one being + * scrolled into. At reading speed the next report corrects it and nothing shows; + * on a fast scroll back and forth the header sits visibly off its band. + * + * `firstRow` and `translateY` both arrive in the same callback, so this answers + * about the frame actually being drawn. + */ +function topOfRow(displayRow: number, geometry: CanvasGeometry): number { + const { firstRow, translateY, headerHeight, heightOf } = geometry; + + let top = headerHeight + translateY; + + const direction = firstRow > displayRow ? -1 : 1; + + for (let row = firstRow; row !== displayRow; row += direction) { + top += heightOf(direction === 1 ? row : row - 1) * direction; + } + + return top; +} + +/** + * Where each visible header goes, keyed by display row. + * + * The height is the row's own, exactly — not the row plus the border pixel + * glide reports in a cell's bounds. Taking that pixel too covers the first line + * of the row BELOW, which is where a selected cell draws the top edge of its + * ring: a cell directly under a group header then looked like it had no top + * border at all. + */ +export function headerPlacements( + headers: readonly VisibleHeader[], + geometry: CanvasGeometry, +): Map { + const placements = new Map(); + + for (const header of headers) { + placements.set(header.displayRow, { + top: topOfRow(header.displayRow, geometry), + height: geometry.heightOf(header.displayRow), + }); + } + + return placements; +} 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/useGridSelection.ts b/src/features/dataGrid/hooks/useGridSelection.ts index 80c10d4..7179017 100644 --- a/src/features/dataGrid/hooks/useGridSelection.ts +++ b/src/features/dataGrid/hooks/useGridSelection.ts @@ -1,7 +1,10 @@ // src/features/dataGrid/hooks/useGridSelection.ts import { CompactSelection, type GridSelection } from "@glideapps/glide-data-grid"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { useSelector } from "react-redux"; +import type { RowStore } from "../data/rowStore"; import { displayToData, type DisplayModel } from "../displayModel"; +import { selectedDisplayRows } from "../selectionRows"; import type { GridInstance } from "../types"; import { useGridDispatch } from "../useGridDispatch"; @@ -13,6 +16,7 @@ export function useGridSelection< instance: GridInstance, 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/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/rowHeights.test.ts b/src/features/dataGrid/rowHeights.test.ts index 5e97cf0..90ce3c0 100644 --- a/src/features/dataGrid/rowHeights.test.ts +++ b/src/features/dataGrid/rowHeights.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildDisplayModel, buildFlatModel } from "./displayModel"; -import { DEFAULT_GROUP_HEADER_HEIGHT, rowHeightFor } from "./rowHeights"; +import { DEFAULT_GROUP_HEADER_HEIGHT, DEFAULT_ROW_HEIGHT, rowHeightFor } from "./rowHeights"; const order = (g: number) => g; @@ -45,4 +45,11 @@ describe("rowHeightFor", () => { 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 index 3fca87b..b6b3ab7 100644 --- a/src/features/dataGrid/rowHeights.ts +++ b/src/features/dataGrid/rowHeights.ts @@ -3,6 +3,26 @@ 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. * 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/types.ts b/src/features/dataGrid/types.ts index d85197c..e8abe15 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"; @@ -178,6 +179,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; } /** 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); + }); +}); From 945702742e025de7cc05f26bab848b85ff74f80a Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sat, 22 Aug 2026 23:57:54 -0600 Subject: [PATCH 12/15] feat(grid): update column grouping logic and tests to use 'grouped' property --- .../dataGrid/ColumnPickerList.test.tsx | 13 +++++++------ src/features/dataGrid/ColumnPickerList.tsx | 18 ++++++++---------- src/features/dataGrid/pickerColumns.test.ts | 10 +++++----- src/features/dataGrid/pickerColumns.ts | 2 +- 4 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src/features/dataGrid/ColumnPickerList.test.tsx b/src/features/dataGrid/ColumnPickerList.test.tsx index 8682473..19d0c7a 100644 --- a/src/features/dataGrid/ColumnPickerList.test.tsx +++ b/src/features/dataGrid/ColumnPickerList.test.tsx @@ -6,7 +6,7 @@ import { ColumnPickerList } from "./ColumnPickerList"; const COLUMNS = [ { field: "id", title: "ID", hidden: false }, { field: "name", title: "Name", hidden: false }, - { field: "region", title: "Region", hidden: false, locked: true }, + { field: "region", title: "Region", hidden: false, grouped: true }, { field: "contact", title: "Contact", hidden: true }, ]; @@ -40,19 +40,20 @@ describe("ColumnPickerList", () => { expect(onToggle).toHaveBeenCalledWith("contact"); }); - it("will not let the grouped column be hidden", async () => { - // The banner would then name a group with no column anywhere to point at. + 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).toBeDisabled(); + expect(region).toBeEnabled(); await userEvent.click(region); - expect(onToggle).not.toHaveBeenCalled(); + expect(onToggle).toHaveBeenCalledWith("region"); }); - it("says why the grouped column cannot be hidden", () => { + it("says which column the grid groups by", () => { renderList(); expect(screen.getByRole("checkbox", { name: /grouped by/i })).toBeInTheDocument(); diff --git a/src/features/dataGrid/ColumnPickerList.tsx b/src/features/dataGrid/ColumnPickerList.tsx index e9eb5e9..9c91f00 100644 --- a/src/features/dataGrid/ColumnPickerList.tsx +++ b/src/features/dataGrid/ColumnPickerList.tsx @@ -9,10 +9,9 @@ export interface PickerColumn { hidden: boolean; /** - * The column cannot be hidden. Set for the column the grid groups by: the - * banner would otherwise name a group with no column anywhere to point at. + * The grid groups by this column. */ - locked?: boolean; + grouped?: boolean; } /** @@ -54,7 +53,7 @@ export function ColumnPickerList({ {columns.map((c) => { const titleId = `${scope}-${c.field}-title`; - const lockId = `${scope}-${c.field}-lock`; + const groupId = `${scope}-${c.field}-grouped`; return ( @@ -62,17 +61,16 @@ export function ColumnPickerList({ onToggle(c.field)} - // The name has to carry the lock reason as well as the title, - // so the badge below is not the only place it is written. - aria-labelledby={c.locked === true ? `${titleId} ${lockId}` : titleId} + // 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.locked === true && ( - + {c.grouped === true && ( + Grouped by )} diff --git a/src/features/dataGrid/pickerColumns.test.ts b/src/features/dataGrid/pickerColumns.test.ts index 2aa42db..8627b90 100644 --- a/src/features/dataGrid/pickerColumns.test.ts +++ b/src/features/dataGrid/pickerColumns.test.ts @@ -28,17 +28,17 @@ describe("pickerColumns", () => { expect(rows.find((r) => r.field === "id")?.hidden).toBe(false); }); - it("locks the column the grid groups by", () => { + it("marks the column the grid groups by", () => { const rows = pickerColumns(DEFS, ORDER, [], "region"); - expect(rows.find((r) => r.field === "region")?.locked).toBe(true); - expect(rows.find((r) => r.field === "id")?.locked).toBe(false); + expect(rows.find((r) => r.field === "region")?.grouped).toBe(true); + expect(rows.find((r) => r.field === "id")?.grouped).toBe(false); }); - it("locks nothing in a grid that is not grouping", () => { + it("marks nothing in a grid that is not grouping", () => { const rows = pickerColumns(DEFS, ORDER, [], undefined); - expect(rows.some((r) => r.locked)).toBe(false); + expect(rows.some((r) => r.grouped)).toBe(false); }); it("skips an ordered field the descriptor never defined", () => { diff --git a/src/features/dataGrid/pickerColumns.ts b/src/features/dataGrid/pickerColumns.ts index 7dc49a6..53a8914 100644 --- a/src/features/dataGrid/pickerColumns.ts +++ b/src/features/dataGrid/pickerColumns.ts @@ -21,6 +21,6 @@ export function pickerColumns( field, title: defs[field].title, hidden: hidden.includes(field), - locked: field === groupField, + grouped: field === groupField, })); } From 508d50828187378244670d8532574c97dd7c6823 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sun, 23 Aug 2026 00:40:13 -0600 Subject: [PATCH 13/15] feat: implement date picker component with popover and testing --- src/components/ui/calendar.tsx | 37 --- src/components/ui/datePicker.test.tsx | 311 +++++++++++++++++ src/components/ui/datePicker.tsx | 330 +++++++++++++++++++ src/components/ui/datePickerPopover.test.tsx | 88 +++++ src/components/ui/datePickerPopover.tsx | 106 ++++++ src/components/ui/dayPickerTheme.ts | 119 +++++++ src/datagrid.src.css | 23 +- src/features/dataGrid/ColumnPicker.test.tsx | 10 +- src/index.ts | 19 ++ src/lib/date/dateMask.test.ts | 64 ++++ src/lib/date/dateMask.ts | 52 +++ src/lib/grid/createDateCell.editor.test.tsx | 109 ++++++ src/lib/grid/createDateCell.tsx | 94 ++---- 13 files changed, 1232 insertions(+), 130 deletions(-) delete mode 100644 src/components/ui/calendar.tsx create mode 100644 src/components/ui/datePicker.test.tsx create mode 100644 src/components/ui/datePicker.tsx create mode 100644 src/components/ui/datePickerPopover.test.tsx create mode 100644 src/components/ui/datePickerPopover.tsx create mode 100644 src/components/ui/dayPickerTheme.ts create mode 100644 src/lib/date/dateMask.test.ts create mode 100644 src/lib/date/dateMask.ts create mode 100644 src/lib/grid/createDateCell.editor.test.tsx diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx deleted file mode 100644 index ec23f8c..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.src.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 `
); diff --git a/src/features/dataGrid/headerIcons.ts b/src/features/dataGrid/headerIcons.ts index 43b0092..418a0b8 100644 --- a/src/features/dataGrid/headerIcons.ts +++ b/src/features/dataGrid/headerIcons.ts @@ -4,7 +4,7 @@ import type { SpriteMap } from "@glideapps/glide-data-grid"; /** * Sort chevrons for the column headers. * - * Glide has no sort state of its own — it hands over `onHeaderClicked` and + * Glide has no sort state of its own — it hands over `onHeaderMenuClick` and * nothing else — but it does own the header's rendering, so the indicator has * to go in through the slot it provides: a named sprite here, referenced by * `indicatorIcon` on the column. Drawing over the canvas ourselves would fight diff --git a/src/features/dataGrid/hooks/useColumnSortMenu.test.tsx b/src/features/dataGrid/hooks/useColumnSortMenu.test.tsx new file mode 100644 index 0000000..36ae3d7 --- /dev/null +++ b/src/features/dataGrid/hooks/useColumnSortMenu.test.tsx @@ -0,0 +1,143 @@ +// src/features/dataGrid/hooks/useColumnSortMenu.test.tsx +import { Theme } from "@radix-ui/themes"; +import { configureStore } from "@reduxjs/toolkit"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Provider } from "react-redux"; +import { describe, expect, it } from "vitest"; +import { createGridInstance } from "../store/createGridInstance"; +import type { GridDescriptor, GridSliceState } from "../types"; +import { useColumnSortMenu } from "./useColumnSortMenu"; + +interface Row { + id: number; + name: string; +} + +const FIELDS = ["id", "name"]; + +const BOUNDS = { x: 40, y: 4, width: 16, height: 32 }; + +function harness() { + const descriptor = { + name: "demo", + rowKey: (r: Row) => r.id, + columns: { + defs: { + id: { + field: "id", + title: "Id", + defaultWidth: 80, + editable: false, + type: "number", + sortable: false, + }, + name: { field: "name", title: "Name", defaultWidth: 120, editable: true, type: "text" }, + }, + defaultOrder: FIELDS, + }, + api: { updateRow: async () => ({ ok: true }) }, + cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true }, + } as unknown as GridDescriptor; + + const instance = createGridInstance(descriptor); + const store = configureStore({ + reducer: { demo: instance.reducer }, + middleware: (getDefault) => getDefault({ serializableCheck: false }), + }); + + function Probe() { + const { onHeaderMenuClick, menu } = useColumnSortMenu(instance, FIELDS); + + return ( + <> + {FIELDS.map((f, i) => ( + + ))} + {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/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 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/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/types.ts b/src/features/dataGrid/types.ts index e8abe15..be68ee6 100644 --- a/src/features/dataGrid/types.ts +++ b/src/features/dataGrid/types.ts @@ -29,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. */ @@ -239,11 +249,6 @@ export interface GridDescriptor boolean; }; /** From 635506f7bb177b153ef8842971f90de18b5383ef Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sun, 23 Aug 2026 01:09:42 -0600 Subject: [PATCH 15/15] feat(grid): enable cell selection functionality in DataGrid --- src/features/dataGrid/DataGrid.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/dataGrid/DataGrid.tsx b/src/features/dataGrid/DataGrid.tsx index dbd9fa6..feadd0a 100644 --- a/src/features/dataGrid/DataGrid.tsx +++ b/src/features/dataGrid/DataGrid.tsx @@ -215,6 +215,7 @@ export function DataGrid