diff --git a/.ai/STACK.md b/.ai/STACK.md index 0016d238856..b971cca8232 100644 --- a/.ai/STACK.md +++ b/.ai/STACK.md @@ -106,7 +106,8 @@ **SWC pipeline (replaces Babel for everything except Jest):** - Rspack bundles (`rspack.config.js` + `.config/*`) - JS transpiled by `builtin:swc-loader` using targets from `browser-targets.js` - `handsontable/scripts/swc-transpile.mjs` - File-per-file transpiler driven by `@swc/core`; produces the `tmp/` output consumed by wrappers. Handles CJS (`build:commonjs`), ESM (`build:es`, `.mjs` output), and i18n language packs with auto-registration (`build:languages.es --lang-registration`) -- `handsontable/scripts/parallel-build.mjs` - Build orchestrator invoked by `npm run build`; runs the Rspack and SWC tasks above concurrently where the dependency graph allows +- `handsontable/scripts/run.mjs` - Unified dispatcher for all `npm run` scripts; reads task definitions and pipeline graphs from `handsontable/scripts/tasks.json`; runs tasks quietly with spinner on TTY, supports `--parallel` (DAG scheduler) and `--sequential` modes +- `handsontable/scripts/tasks.json` - Single source of truth for all build/lint/test task commands, their `deps`, `mode`, and `cwd`; also defines named `pipelines` (ordered step sequences). Edit this file to add, remove, or change any build/lint/test step — do not add raw shell commands to `package.json` scripts for the core package **Linting:** - `.eslintrc.js` (root) - Monorepo-level ESLint config diff --git a/.ai/TESTING.md b/.ai/TESTING.md index 97377a5ea1c..2e741fe56b4 100644 --- a/.ai/TESTING.md +++ b/.ai/TESTING.md @@ -32,8 +32,6 @@ npm run test:unit --testPathPattern=cellMeta # Run specific E2E test pattern (must be run from handsontable/ directory): # The pattern is baked into the Rspack bundle at dump time via __ENV_ARGS__.testPathPattern. -# rspack.config.js copies the lowercase npm_config_testpathpattern to npm_config_testPathPattern -# so the standard npm --key=value syntax works. # Step 1: rebuild the test bundle with the pattern (skips full UMD build): npm run test:e2e.dump --testPathPattern=filters # Step 2: run puppeteer against the filtered bundle: @@ -56,7 +54,7 @@ npm run test:unit -- --coverage - Per-run Puppeteer runner: `handsontable/test/E2ERunner-.html` - Generic dev runner (always regenerated alongside): `handsontable/test/E2ERunner.html` -`run-puppeteer.mjs` computes the same hash from `npm_config_testpathpattern` / `npm_config_theme` and opens the matching HTML. It also binds the local HTTP server to the first free port starting at `8086` (retries on `EADDRINUSE`, up to 100 ports), so each concurrent run gets its own port. Any number of `npm run test:e2e --testPathPattern=` invocations with distinct patterns (or themes) can run in parallel without further configuration -- the practical limit is machine resources, not the tooling. +`run-puppeteer.mjs` computes the same hash from `npm_config_testpathpattern` / `npm_config_theme` (env vars set by the `test-e2e.mjs` wrapper) and opens the matching HTML. It also binds the local HTTP server to the first free port starting at `8086` (retries on `EADDRINUSE`, up to 100 ports), so each concurrent run gets its own port. Any number of `npm run test:e2e --testPathPattern=` invocations with distinct patterns (or themes) can run in parallel without further configuration -- the practical limit is machine resources, not the tooling. The helper that derives the hash lives in `handsontable/.config/helper/run-id.js` -- used by both the Rspack config and the Puppeteer script so they stay in lockstep. diff --git a/.changelogs/12508.json b/.changelogs/12508.json new file mode 100644 index 00000000000..5a6e6db99cd --- /dev/null +++ b/.changelogs/12508.json @@ -0,0 +1,8 @@ +{ + "issuesOrigin": "public", + "title": "Fixed merged cells not following their data when columns or rows are reordered with `manualColumnMove`, `manualRowMove`, or `manualColumnFreeze`. Merges now translate with the underlying data; merges whose physical span becomes non-contiguous after a reorder auto-split into smaller merges, and any single-cell fragment left behind is dropped.", + "type": "fixed", + "issueOrPR": 12508, + "breaking": false, + "framework": "none" +} diff --git a/.changelogs/12514.json b/.changelogs/12514.json new file mode 100644 index 00000000000..1a038b71776 --- /dev/null +++ b/.changelogs/12514.json @@ -0,0 +1,8 @@ +{ + "issuesOrigin": "public", + "title": "Fixed the loading overlay resetting the grid scroll position to the top when no cell was selected before showing the overlay.", + "type": "fixed", + "issueOrPR": 12514, + "breaking": false, + "framework": "none" +} diff --git a/.changelogs/12515.json b/.changelogs/12515.json new file mode 100644 index 00000000000..b12eaa36d69 --- /dev/null +++ b/.changelogs/12515.json @@ -0,0 +1,8 @@ +{ + "issuesOrigin": "public", + "title": "Fixed manual column and row resize handle position after scrolling when `preventOverflow` is set.", + "type": "fixed", + "issueOrPR": 12515, + "breaking": false, + "framework": "none" +} diff --git a/.changelogs/12519.json b/.changelogs/12519.json deleted file mode 100644 index 8c482d22440..00000000000 --- a/.changelogs/12519.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issuesOrigin": "public", - "title": "Fixed the `@handsontable/angular-wrapper` ERROR RuntimeError: NG0100: ExpressionChangedAfterItHasBeenCheckedError", - "type": "fixed", - "issueOrPR": 12519, - "breaking": false, - "framework": "angular" -} diff --git a/.changelogs/12533.json b/.changelogs/12533.json new file mode 100644 index 00000000000..e54dda1f420 --- /dev/null +++ b/.changelogs/12533.json @@ -0,0 +1,8 @@ +{ + "issuesOrigin": "public", + "title": "Fixed Prevent crash when Handsontable is initialized inside a hidden container, rowsRenderCalculator and columnsRenderCalculator on Viewport are never assigned and remain undefined.", + "type": "fixed", + "issueOrPR": 12533, + "breaking": false, + "framework": "none" +} diff --git a/.claude/skills/handsontable-css-dev/SKILL.md b/.claude/skills/handsontable-css-dev/SKILL.md index 1a15dedafe5..7856bda9319 100644 --- a/.claude/skills/handsontable-css-dev/SKILL.md +++ b/.claude/skills/handsontable-css-dev/SKILL.md @@ -139,6 +139,6 @@ Before committing token work, mentally walk the four layers plus tests: 2. JS token in all 3 runtime files? 3. Registered in `validation.js` allow-list? 4. Added to `TokenKey` in `themes.d.ts`? -5. `npm run test:unit --prefix handsontable -- --testPathPattern=themes` passes? +5. `npm run test:unit --prefix handsontable --testPathPattern=themes` passes? 6. `npm run test:types --prefix handsontable` passes? 7. Docs table updated? diff --git a/.claude/skills/handsontable-demo-page/SKILL.md b/.claude/skills/handsontable-demo-page/SKILL.md index c4a7bcdf2d1..28848764bad 100644 --- a/.claude/skills/handsontable-demo-page/SKILL.md +++ b/.claude/skills/handsontable-demo-page/SKILL.md @@ -1,20 +1,20 @@ --- name: demo-page -description: Use when creating a demo or test page for manual testing of Handsontable. Trigger when the user asks to create a demo, test page, repro page, reproduction case, manual test, or wants to verify a bug fix or feature visually. Also trigger when the user mentions dev-generated.html, dev.html, or wants to compare behavior between a released version and a local build. Use this for any PR that needs a manual testing artifact. +description: Use when creating a demo or test page for manual testing of Handsontable. Trigger when the user asks to create a demo, test page, repro page, reproduction case, manual test, or wants to verify a bug fix or feature visually. Also trigger when the user mentions dev-generated.html, dev-pr.html, dev-latest.html, dev.html, or wants to compare behavior between a released version and a local build. Use this for any PR that needs a manual testing artifact. --- # Demo Page Generator -Generate a self-contained HTML demo page at `handsontable/dev-generated.html` for manual testing. This file is gitignored (`dev*.html` pattern), so it never pollutes the repo. +Generate two self-contained HTML demo pages for manual testing: -The demo has two tabs so a reviewer can instantly compare behavior: +| File | Loads from | Purpose | +|------|-----------|---------| +| `handsontable/dev-latest.html` | jsDelivr CDN (latest published version) | Shows the current/buggy behavior | +| `handsontable/dev-pr.html` | Local `dist/` (built from the branch) | Shows the fix or new feature | -| Tab | Loads from | Purpose | -|-----|-----------|---------| -| **Released** | jsDelivr CDN (latest published version) | Shows the current/buggy behavior | -| **PR Build** | Local `dist/` (built from the branch) | Shows the fix or new feature | +Both files are gitignored (`dev*.html` pattern), so they never pollute the repo. -This side-by-side comparison makes PR review faster — the reviewer sees the bug on the Released tab and verifies the fix on the PR Build tab without switching branches. +Each file links to the other at the top, so a reviewer can switch back and forth without losing scroll position or state context. **Two separate files means complete JS/CSS isolation** — no dual-instance loading tricks, no stylesheet toggling, no shared globals between versions. ## Step 1 — Analyze the PR context @@ -41,19 +41,21 @@ If missing or stale, build: npm run build --prefix handsontable ``` -## Step 3 — Generate the demo page +## Step 3 — Generate the two demo files -Write `handsontable/dev-generated.html` using the template structure below. Adapt the Handsontable configuration in each tab to target the specific bug or feature being tested. +Write both files using the templates below. Both are gitignored (`dev*.html`), so they never pollute the repo. -> **File already exists?** `dev-generated.html` is always throwaway — it was generated by a previous task and contains nothing worth preserving. Skip reading it. Wipe it first with a Bash command, then write the new file: +> **Files already exist?** Both files are throwaway — generated by a previous task. Skip reading them. Wipe them first, then write fresh: > > ```bash -> rm handsontable/dev-generated.html +> rm -f handsontable/dev-pr.html handsontable/dev-latest.html > ``` > -> Then use the Write tool to create the new content from scratch. Do **not** read the old file before writing. +> Then use the Write tool to create each file from scratch. -### Template structure +Each file is a standalone single-instance page. The nav bar at the top links to the other file — the current file's link is styled as active (non-clickable) so the reviewer always knows where they are. + +### Template — `handsontable/dev-latest.html` (Released / CDN) ```html @@ -61,162 +63,148 @@ Write `handsontable/dev-generated.html` using the template structure below. Adap - Manual Test — [short description] - - - [short description] — Released v__RELEASED_VERSION__ + - - - - +

[Short description of what is being tested]

-

- [Short description of what is being tested] -

- -
- - -
+
How to test: [Step-by-step reproduction instructions]
-
-
-
- -
-
-
- - - +
- + + + +``` - - - +### Template — `handsontable/dev-pr.html` (PR Build / local) - + ``` -### Filling in the template +### Filling in the templates -Replace these placeholders: +Replace these placeholders **in both files**: | Placeholder | Value | |-------------|-------| -| `__RELEASED_VERSION__` | The latest published version from `handsontable/package.json` (e.g., `17.0.1`). If the PR branch itself bumped the version, use the version from the base branch (`git show origin/develop:handsontable/package.json`). | +| `__RELEASED_VERSION__` | The latest published version from `handsontable/package.json` (e.g., `17.0.1`). If the PR branch bumped the version, use the version from the base branch (`git show origin/develop:handsontable/package.json`). | | `[Short description...]` | A one-line summary, e.g., "Filters dropdown closes on Android touch" | | `[Step-by-step reproduction...]` | Numbered steps the reviewer should follow, e.g., "1. Double-tap cell A1. 2. The editor should open and stay open." | +Keep the ` ``` -Load third-party libraries once (before both Handsontable scripts) so both tabs share them. - ## Step 4 — Serve and verify Start a local server from the `handsontable/` directory: @@ -252,20 +238,23 @@ Start a local server from the `handsontable/` directory: python3 -m http.server 8767 --directory handsontable & ``` -Verify both tabs work: +Verify both files are reachable: ```bash -curl -s -o /dev/null -w "%{http_code}" http://localhost:8767/dev-generated.html +curl -s -o /dev/null -w "dev-latest: %{http_code}\n" http://localhost:8767/dev-latest.html && \ +curl -s -o /dev/null -w "dev-pr: %{http_code}\n" http://localhost:8767/dev-pr.html ``` -The page is at: `http://localhost:8767/dev-generated.html` +Tell the user both URLs: + +- `http://localhost:8767/dev-latest.html` — Released version (CDN) +- `http://localhost:8767/dev-pr.html` — PR Build (local) -Tell the user the URL so they can open it in a browser and test. +The nav bar in each file links to the other, so the reviewer can switch back and forth without re-typing URLs. ## Important notes -- The file is gitignored — it will not appear in `git status` or get committed. -- Each tab has its own Handsontable instance. The CDN version is saved to `window.HandsontableReleased` before the local build script overwrites `window.Handsontable`. -- Stylesheet switching uses the `disabled` attribute on `` elements so only one theme is active at a time. -- If the released version used the old CSS system (pre-v17, `dist/handsontable.full.css`), adjust the CDN CSS link accordingly. -- For very old version comparisons, check that the API used in `createInstance()` exists in both versions. +- Both files are gitignored (`dev*.html`) — they will not appear in `git status` or get committed. +- Each file loads only one Handsontable build — no dual-instance tricks needed. +- If the released version used the old CSS system (pre-v17, `dist/handsontable.full.css`), adjust the CDN CSS link in `dev-latest.html` accordingly. +- For very old version comparisons, check that the API used in the config block exists in both versions. diff --git a/.claude/skills/handsontable-e2e-testing/SKILL.md b/.claude/skills/handsontable-e2e-testing/SKILL.md index cb585125020..1d6a136dff5 100644 --- a/.claude/skills/handsontable-e2e-testing/SKILL.md +++ b/.claude/skills/handsontable-e2e-testing/SKILL.md @@ -131,23 +131,21 @@ Use `it.flaky()` for timing-sensitive tests (auto-retries up to 3 times). ## Run commands - **All:** `npm run test:e2e --prefix handsontable` -- **Targeted:** `npm run test:e2e --testPathPattern= --prefix handsontable` -- the pattern is matched against test file paths during the Rspack `.dump` step (e.g. `collapsibleColumns`, `ghostTable`, `textEditor`, `nestedHeaders/__tests__/hidingColumns`) -- **With theme:** `npm run test:e2e --testPathPattern= --theme=horizon --prefix handsontable` (available themes: `classic`, `main`, `horizon`; default when `--theme` is omitted: `main`) +- **Targeted:** `npm run test:e2e --prefix handsontable --testPathPattern=` -- the pattern is matched against test file paths during the Rspack `.dump` step (e.g. `collapsibleColumns`, `ghostTable`, `textEditor`, `nestedHeaders/__tests__/hidingColumns`) +- **With theme:** `npm run test:e2e --prefix handsontable --testPathPattern= --theme=horizon` (available themes: `classic`, `main`, `horizon`; default when `--theme` is omitted: `main`) - **Rebuild first:** The E2E runner loads `dist/handsontable.js`. After changing `src/**`, run `npm run build --prefix handsontable` before running E2E tests. -**Important:** Do NOT use `--` before `--testPathPattern`. The flag is consumed by npm during the `.dump` step (Rspack build), not by Puppeteer. Using `npm run test:e2e -- --testPathPattern=...` passes it only to the Puppeteer runner, which doesn't support it. - -**Parallel runs:** Multiple `npm run test:e2e --testPathPattern=` invocations with different patterns (or themes) can run simultaneously. The dump step hashes `testPathPattern + theme` into a short run ID and writes per-run artifacts (`test/dist/main.entry..js` and `test/E2ERunner-.html`), and the Puppeteer runner picks its own free port starting at `8086` (retries up to 100 ports). Nothing special needs to be passed -- just launch the commands; the practical limit is machine resources, not the tooling. +**Parallel runs:** Multiple `npm run test:e2e --prefix handsontable --testPathPattern=` invocations with different patterns (or themes) can run simultaneously. The dump step hashes `testPathPattern + theme` into a short run ID and writes per-run artifacts (`test/dist/main.entry..js` and `test/E2ERunner-.html`), and the Puppeteer runner picks its own free port starting at `8086` (retries up to 100 ports). Nothing special needs to be passed -- just launch the commands; the practical limit is machine resources, not the tooling. **Iterating on a single area:** Prefer `test:e2e.watch` -- it leaves the dev server running and re-bundles + re-runs on every source change, so you don't have to stop and restart between edits: ```bash -npm run test:e2e.watch --testPathPattern=filters --theme=horizon +npm run test:e2e.watch --prefix handsontable --testPathPattern=filters --theme=horizon ``` Under the hood it spawns the regular Rspack dump in `--watch` mode and reopens the browser page, reusing the generic `test/E2ERunner.html` (no run ID needed -- the dump and puppeteer halves share one npm process, so the flags propagate automatically). -**One-shot run:** Use the combined `npm run test:e2e --testPathPattern= --theme=` -- a single npm invocation passes the flags to both dump and puppeteer via env, so there's no risk of a mismatch. +**One-shot run:** Use `npm run test:e2e --prefix handsontable --testPathPattern= --theme=` -- the wrapper script passes the flags to both dump and puppeteer via env, so there's no risk of a mismatch. **Split dump + puppeteer** (what CI does): if you invoke the two steps in separate `npm run` commands, pass `--testPathPattern` AND `--theme` to **both**. Each `npm run` is its own npm process with its own env, and the Puppeteer script recomputes the same hash as dump to find the runner HTML -- a mismatch fails with "Runner HTML not found at ...". `.github/workflows/test.yml` is the canonical example; the same rule applies to `test:production.dump` + `test:e2e.puppeteer`. diff --git a/.claude/skills/handsontable-unit-testing/SKILL.md b/.claude/skills/handsontable-unit-testing/SKILL.md index e566600c4d4..4bbab80a603 100644 --- a/.claude/skills/handsontable-unit-testing/SKILL.md +++ b/.claude/skills/handsontable-unit-testing/SKILL.md @@ -40,10 +40,8 @@ For custom mocking, use `jest.fn()` for stubs and `jest.spyOn(object, 'method')` ## Run Commands - **All unit tests:** `npm run test:unit --prefix handsontable` -- **Targeted:** `npm run test:unit --testPathPattern= --prefix handsontable` -- the pattern is matched against test file paths (e.g. `filters`, `ghostTable.unit`, `metaManager`) -- **Example:** `npm run test:unit --testPathPattern=filters --prefix handsontable` - -**Important:** Do NOT use `--` before `--testPathPattern`. The flag is consumed by npm's script runner, not by Jest directly. +- **Targeted:** `npm run test:unit --prefix handsontable --testPathPattern=` -- the pattern is matched against test file paths (e.g. `filters`, `ghostTable.unit`, `metaManager`) +- **Example:** `npm run test:unit --prefix handsontable --testPathPattern=filters` ## Large Dataset Testing diff --git a/.claude/skills/node-scripts-dev/SKILL.md b/.claude/skills/node-scripts-dev/SKILL.md index a8e2ded8c25..0f6145ffdfe 100644 --- a/.claude/skills/node-scripts-dev/SKILL.md +++ b/.claude/skills/node-scripts-dev/SKILL.md @@ -11,9 +11,60 @@ All Node.js-side code in the monorepo -- scripts, utilities, and library modules - **Extension:** Always `.mjs` (never `.js` or `.cjs` for Node.js-side code). - **Location:** `scripts/` for CLI-invoked scripts. `lib/` for shared utilities and library modules. Package-specific paths are fine (e.g., `performance-tests/lib/`, `wrappers/react-wrapper/scripts/`). -- **Invocation:** `node scripts/your-script.mjs` from `package.json` scripts. +- **Invocation:** `node scripts/your-script.mjs` from `package.json` scripts, or as a `cmd` value in `handsontable/scripts/tasks.json` (see below). - **Scope:** These conventions apply to all `.mjs` files -- standalone scripts, library modules, Playwright helpers, build tooling, etc. +## Adding npm scripts to the handsontable core package + +The `handsontable/` package uses a unified dispatcher. **Do not add raw shell commands directly to `handsontable/package.json` scripts.** Instead: + +1. Add the task to `handsontable/scripts/tasks.json`: + +```json +"my-task": { + "cmd": "node scripts/my-script.mjs", + "deps": ["build:styles"], + "mode": "inherit" +} +``` + +2. Add a thin shim to `package.json` that delegates to the dispatcher: + +```json +"my-task": "node scripts/run.mjs my-task" +``` + +### tasks.json schema + +| Field | Required | Values | Purpose | +|-------|----------|--------|---------| +| `cmd` | yes | shell string | Command run via `spawn(..., { shell: true })` | +| `deps` | no | task name array | Tasks that must complete first (resolved by DAG scheduler in parallel mode; resolved sequentially in direct invocation mode) | +| `mode` | no | `quiet` (default) \| `inherit` \| `interactive` | `quiet` = suppress output with spinner; `inherit` = stream output (linters); `interactive` = full TTY pass-through (Jest) | +| `cwd` | no | path relative to `handsontable/` | Working directory override | +| `passthrough` | no | boolean | Append extra CLI flags (after `--`) to the cmd | +| `note` | no | string | Human annotation only, ignored at runtime | + +### Pipeline definitions + +To group tasks into an ordered pipeline (e.g., a build or test sequence), add to the `pipelines` block: + +```json +"pipelines": { + "my-pipeline": { + "before": ["clean"], + "tasks": ["my-task", "other-task"], + "after": ["prepare-package-for-publish"] + } +} +``` + +`before` and `after` steps run sequentially. `tasks` run sequentially with `--sequential` or via DAG with `--parallel`. + +### Other packages + +For wrapper packages (`wrappers/react-wrapper/`, `wrappers/angular-wrapper/`, `wrappers/vue3/`) and other monorepo packages (`performance-tests/`, `visual-tests/`), add directly to that package's `package.json` scripts as usual — those packages do not use the `run.mjs` dispatcher. + ## Native module imports Always use the `node:` protocol prefix for built-in modules. This makes it explicit that the import is a Node.js built-in, not a third-party package. diff --git a/.claude/skills/pr-creation/SKILL.md b/.claude/skills/pr-creation/SKILL.md index b5c7b85b3af..6a2e1674bc5 100644 --- a/.claude/skills/pr-creation/SKILL.md +++ b/.claude/skills/pr-creation/SKILL.md @@ -37,10 +37,10 @@ npm run stylelint --prefix handsontable npm run build --prefix handsontable # Unit tests for the area you changed -npm run test:unit --testPathPattern= --prefix handsontable +npm run test:unit --prefix handsontable --testPathPattern= # E2E tests for the area you changed -npm run test:e2e --testPathPattern= --prefix handsontable +npm run test:e2e --prefix handsontable --testPathPattern= # If you touched a wrapper, test it too npm run test --prefix wrappers/react-wrapper diff --git a/docs/README-EDITING.md b/docs/README-EDITING.md index 5c28d5af8e0..0e827007de6 100644 --- a/docs/README-EDITING.md +++ b/docs/README-EDITING.md @@ -196,6 +196,8 @@ We use the following Markdown containers: These containers are processed by the `vuepress-preprocessor.mjs` plugin in `src/plugins/`, which converts the VuePress-style syntax into Astro/Starlight-compatible output. +For `tip` / `warning` / `danger` / `note` callouts, the body is not full CommonMark. The preprocessor turns inline `` `code` ``, `**bold**`, and `[label](url)` into HTML (see `aside-inline-markdown.mjs`). + ### Adding code examples Using the `example` Markdown container, you can add code snippets that show the code's result: diff --git a/docs/content/guides/cell-features/merge-cells/merge-cells.md b/docs/content/guides/cell-features/merge-cells/merge-cells.md index 76f0440066a..e9cc6f95438 100644 --- a/docs/content/guides/cell-features/merge-cells/merge-cells.md +++ b/docs/content/guides/cell-features/merge-cells/merge-cells.md @@ -158,6 +158,15 @@ The example below uses virtualized merged cells. It's also recommended to increa ::: +## Behavior during row/column reorder and column freeze + +When a merged cell's underlying rows or columns are reordered (through [`manualColumnMove`](@/api/options.md#manualcolumnmove), [`manualRowMove`](@/api/options.md#manualrowmove), or [`manualColumnFreeze`](@/api/options.md#manualcolumnfreeze)), Handsontable follows the merge to the new visual position. Two side effects can occur: + +- **Auto-split**: if the move bisects a merge so the underlying cells are no longer contiguous in the new visual order, the merge is split into separate merges, one per contiguous run. The cross-axis span (`rowspan` for column moves, `colspan` for row moves) is preserved on every fragment. +- **Silent drop of single-cell fragments**: any resulting fragment that ends up as a single cell (`rowspan === 1 && colspan === 1`) is removed, because a single cell is no longer a merge. The [`afterMergeCells`](@/api/hooks.md#aftermergecells) hook is not fired for the dropped fragment. + +[`undo`](@/api/options.md#undo) and [`redo`](@/api/options.md#redo) restore the pre-move state, including any merges that were split or dropped by the reorder. + ## Related keyboard shortcuts | Windows | macOS | Action | Excel | Sheets | diff --git a/docs/content/guides/cell-functions/cell-function/angular/example1.ts b/docs/content/guides/cell-functions/cell-function/angular/example1.ts index d81183a82f1..3758aa52fb5 100644 --- a/docs/content/guides/cell-functions/cell-function/angular/example1.ts +++ b/docs/content/guides/cell-functions/cell-function/angular/example1.ts @@ -73,7 +73,7 @@ const stockValidator = (value: Handsontable.CellValue, callback: (valid: boolean .htStockBarTrack { flex: 1; height: 8px; - background: #e5e7eb; + background: var(--ht-background-secondary-color); border-radius: 4px; overflow: hidden; } diff --git a/docs/content/guides/cell-functions/cell-function/javascript/example1.css b/docs/content/guides/cell-functions/cell-function/javascript/example1.css index ebb5c3d65e9..2c25609056f 100644 --- a/docs/content/guides/cell-functions/cell-function/javascript/example1.css +++ b/docs/content/guides/cell-functions/cell-function/javascript/example1.css @@ -10,7 +10,7 @@ .htStockBarTrack { flex: 1; height: 8px; - background: #e5e7eb; + background: var(--ht-background-secondary-color); border-radius: 4px; overflow: hidden; } diff --git a/docs/content/guides/cell-functions/cell-function/react/example1.css b/docs/content/guides/cell-functions/cell-function/react/example1.css index ebb5c3d65e9..2c25609056f 100644 --- a/docs/content/guides/cell-functions/cell-function/react/example1.css +++ b/docs/content/guides/cell-functions/cell-function/react/example1.css @@ -10,7 +10,7 @@ .htStockBarTrack { flex: 1; height: 8px; - background: #e5e7eb; + background: var(--ht-background-secondary-color); border-radius: 4px; overflow: hidden; } diff --git a/docs/content/guides/cell-types/cell-type/angular/example1.ts b/docs/content/guides/cell-types/cell-type/angular/example1.ts index 1b0a4e58a44..bac8d1a9a2c 100644 --- a/docs/content/guides/cell-types/cell-type/angular/example1.ts +++ b/docs/content/guides/cell-types/cell-type/angular/example1.ts @@ -1,16 +1,16 @@ /* file: app.component.ts */ import { Component } from '@angular/core'; import { GridSettings, HotTableModule} from '@handsontable/angular-wrapper'; -import Handsontable from 'handsontable/base'; +import { textRenderer } from 'handsontable/renderers/textRenderer'; import { BaseRenderer } from 'handsontable/renderers'; const yellowRenderer: BaseRenderer = (instance, td, ...rest) => { - Handsontable.renderers.TextRenderer(instance, td, ...rest); + textRenderer(instance, td, ...rest); td.style.backgroundColor = 'yellow'; }; const greenRenderer: BaseRenderer = (instance, td, ...rest) => { - Handsontable.renderers.TextRenderer(instance, td, ...rest); + textRenderer(instance, td, ...rest); td.style.backgroundColor = 'green'; }; diff --git a/docs/content/guides/cell-types/cell-type/cell-type.md b/docs/content/guides/cell-types/cell-type/cell-type.md index 4f9d7478ecb..a0dbc681c12 100644 --- a/docs/content/guides/cell-types/cell-type/cell-type.md +++ b/docs/content/guides/cell-types/cell-type/cell-type.md @@ -608,6 +608,8 @@ Empty cells may be treated differently in different contexts, for example, the [ ## Related +
+ - [Autocomplete cell type](@/guides/cell-types/autocomplete-cell-type/autocomplete-cell-type.md) - [Checkbox cell type](@/guides/cell-types/checkbox-cell-type/checkbox-cell-type.md) - [Date cell type](@/guides/cell-types/date-cell-type/date-cell-type.md) @@ -619,6 +621,8 @@ Empty cells may be treated differently in different contexts, for example, the [ - [Select cell type](@/guides/cell-types/select-cell-type/select-cell-type.md) - [Time cell type](@/guides/cell-types/time-cell-type/time-cell-type.md) +
+ ## Related articles **Related guides** diff --git a/docs/content/guides/cell-types/cell-type/react/example1.jsx b/docs/content/guides/cell-types/cell-type/react/example1.jsx index 2bc49bc2bf6..06cd8923f13 100644 --- a/docs/content/guides/cell-types/cell-type/react/example1.jsx +++ b/docs/content/guides/cell-types/cell-type/react/example1.jsx @@ -1,6 +1,6 @@ import { HotTable } from '@handsontable/react-wrapper'; -import Handsontable from 'handsontable/base'; import { registerAllModules } from 'handsontable/registry'; +import { textRenderer } from 'handsontable/renderers/textRenderer'; // register Handsontable's modules registerAllModules(); @@ -8,12 +8,12 @@ registerAllModules(); const ExampleComponent = () => { const colors = ['yellow', 'red', 'orange', 'green', 'blue', 'gray', 'black', 'white']; const yellowRenderer = (instance, td, ...rest) => { - Handsontable.renderers.TextRenderer(instance, td, ...rest); + textRenderer(instance, td, ...rest); td.style.backgroundColor = 'yellow'; }; const greenRenderer = (instance, td, ...rest) => { - Handsontable.renderers.TextRenderer(instance, td, ...rest); + textRenderer(instance, td, ...rest); td.style.backgroundColor = 'green'; }; diff --git a/docs/content/guides/cell-types/cell-type/react/example1.tsx b/docs/content/guides/cell-types/cell-type/react/example1.tsx index 960773a32e9..176949d2952 100644 --- a/docs/content/guides/cell-types/cell-type/react/example1.tsx +++ b/docs/content/guides/cell-types/cell-type/react/example1.tsx @@ -1,6 +1,6 @@ import { HotTable } from '@handsontable/react-wrapper'; -import Handsontable from 'handsontable/base'; import { registerAllModules } from 'handsontable/registry'; +import { textRenderer } from 'handsontable/renderers/textRenderer'; import { BaseRenderer } from 'handsontable/renderers'; // register Handsontable's modules @@ -10,12 +10,12 @@ const ExampleComponent = () => { const colors: string[] = ['yellow', 'red', 'orange', 'green', 'blue', 'gray', 'black', 'white']; const yellowRenderer: BaseRenderer = (instance, td, ...rest) => { - Handsontable.renderers.TextRenderer(instance, td, ...rest); + textRenderer(instance, td, ...rest); td.style.backgroundColor = 'yellow'; }; const greenRenderer: BaseRenderer = (instance, td, ...rest) => { - Handsontable.renderers.TextRenderer(instance, td, ...rest); + textRenderer(instance, td, ...rest); td.style.backgroundColor = 'green'; }; diff --git a/docs/content/guides/dialog/dialog/angular/example3.ts b/docs/content/guides/dialog/dialog/angular/example3.ts index e7a91dae6e2..d65b23e8a3d 100644 --- a/docs/content/guides/dialog/dialog/angular/example3.ts +++ b/docs/content/guides/dialog/dialog/angular/example3.ts @@ -103,7 +103,7 @@ export class AppComponent implements AfterViewInit { autoWrapCol: true, autoRowSize: true, dialog: { - content: '

This dialog contains HTML content with formatting.

', + content: '

This dialog contains HTML content with formatting.

', closable: true, }, }; diff --git a/docs/content/guides/dialog/dialog/angular/example4.ts b/docs/content/guides/dialog/dialog/angular/example4.ts index 140ad89bd94..cc3db902711 100644 --- a/docs/content/guides/dialog/dialog/angular/example4.ts +++ b/docs/content/guides/dialog/dialog/angular/example4.ts @@ -1,16 +1,19 @@ /* file: app.component.ts */ -import { Component, ViewChild } from '@angular/core'; +import { Component, ViewChild, ViewEncapsulation } from '@angular/core'; import { GridSettings, HotTableComponent, HotTableModule} from '@handsontable/angular-wrapper'; @Component({ standalone: true, imports: [HotTableModule], selector: 'app-example4', + encapsulation: ViewEncapsulation.None, template: ` -
- - - +
+
+ + + +
diff --git a/docs/content/guides/dialog/dialog/angular/example8.ts b/docs/content/guides/dialog/dialog/angular/example8.ts index 2663612be40..2fcec0a8417 100644 --- a/docs/content/guides/dialog/dialog/angular/example8.ts +++ b/docs/content/guides/dialog/dialog/angular/example8.ts @@ -1,15 +1,18 @@ /* file: app.component.ts */ -import { Component, ViewChild } from '@angular/core'; +import { Component, ViewChild, ViewEncapsulation } from '@angular/core'; import { GridSettings, HotTableComponent, HotTableModule} from '@handsontable/angular-wrapper'; @Component({ standalone: true, imports: [HotTableModule], selector: 'app-example8', + encapsulation: ViewEncapsulation.None, template: ` -
- - +
+
+ + +
This dialog contains HTML content with formatting.

', + '

This dialog contains HTML content with formatting.

', closable: true, }, licenseKey: 'non-commercial-and-evaluation', @@ -97,6 +98,7 @@ const hot = new Handsontable(container, { const dialogPlugin = hot.getPlugin('dialog'); dialogPlugin.show(); + document.getElementById('example3-button').addEventListener('click', () => { dialogPlugin.hide(); }); diff --git a/docs/content/guides/dialog/dialog/javascript/example3.ts b/docs/content/guides/dialog/dialog/javascript/example3.ts index 283f63f54d3..648dd335d79 100644 --- a/docs/content/guides/dialog/dialog/javascript/example3.ts +++ b/docs/content/guides/dialog/dialog/javascript/example3.ts @@ -88,7 +88,7 @@ const hot = new Handsontable(container, { stretchH: 'all', dialog: { content: - '

This dialog contains HTML content with formatting.

', + '

This dialog contains HTML content with formatting.

', closable: true, }, licenseKey: 'non-commercial-and-evaluation', diff --git a/docs/content/guides/dialog/dialog/javascript/example4.html b/docs/content/guides/dialog/dialog/javascript/example4.html index e2921d3c9f7..62bb333fa84 100644 --- a/docs/content/guides/dialog/dialog/javascript/example4.html +++ b/docs/content/guides/dialog/dialog/javascript/example4.html @@ -1,6 +1,8 @@ -
- - - +
+
+ + + +
diff --git a/docs/content/guides/dialog/dialog/javascript/example8.html b/docs/content/guides/dialog/dialog/javascript/example8.html index df6a50284f3..b01d38c7c94 100644 --- a/docs/content/guides/dialog/dialog/javascript/example8.html +++ b/docs/content/guides/dialog/dialog/javascript/example8.html @@ -1,5 +1,7 @@ -
- - +
+
+ + +
diff --git a/docs/content/guides/dialog/dialog/react/example3.jsx b/docs/content/guides/dialog/dialog/react/example3.jsx index d53642af1df..60541d5aec5 100644 --- a/docs/content/guides/dialog/dialog/react/example3.jsx +++ b/docs/content/guides/dialog/dialog/react/example3.jsx @@ -59,7 +59,7 @@ const ExampleComponent = () => { autoRowSize={true} dialog={{ content: - '

This dialog contains HTML content with formatting.

', + '

This dialog contains HTML content with formatting.

', closable: true, }} licenseKey="non-commercial-and-evaluation" diff --git a/docs/content/guides/dialog/dialog/react/example3.tsx b/docs/content/guides/dialog/dialog/react/example3.tsx index f7b8e381a85..b82a81d4b36 100644 --- a/docs/content/guides/dialog/dialog/react/example3.tsx +++ b/docs/content/guides/dialog/dialog/react/example3.tsx @@ -60,7 +60,7 @@ const ExampleComponent = () => { autoRowSize={true} dialog={{ content: - '

This dialog contains HTML content with formatting.

', + '

This dialog contains HTML content with formatting.

', closable: true, }} licenseKey="non-commercial-and-evaluation" diff --git a/docs/content/guides/dialog/dialog/react/example4.jsx b/docs/content/guides/dialog/dialog/react/example4.jsx index 6b9d30d3702..6b3076a115f 100644 --- a/docs/content/guides/dialog/dialog/react/example4.jsx +++ b/docs/content/guides/dialog/dialog/react/example4.jsx @@ -130,10 +130,12 @@ const ExampleComponent = () => { return ( <> -
- - - +
+
+ + + +
{ return ( <> -
- - - +
+
+ + + +
{ return ( <> -
- - +
+
+ + +
{ return ( <> -
- - +
+
+ + +
- +
+
+ +
-
- +
+
+ +

This is a demonstration of how to use the Loading plugin with pagination in external container. You need to create pagination overlay manually, after that you can use the afterLoadingShow and afterLoadingHide hooks to show and hide the pagination container overlay.

diff --git a/docs/content/guides/dialog/loading/javascript/example3.html b/docs/content/guides/dialog/loading/javascript/example3.html index d4721011cd0..e0a3721ff0f 100644 --- a/docs/content/guides/dialog/loading/javascript/example3.html +++ b/docs/content/guides/dialog/loading/javascript/example3.html @@ -1,4 +1,6 @@ -
- +
+
+ +
-
\ No newline at end of file +
diff --git a/docs/content/guides/dialog/loading/javascript/example4.html b/docs/content/guides/dialog/loading/javascript/example4.html index 7fe760de487..f63d12f15ab 100644 --- a/docs/content/guides/dialog/loading/javascript/example4.html +++ b/docs/content/guides/dialog/loading/javascript/example4.html @@ -1,11 +1,13 @@
-
- +
+
+ +

This is a demonstration of how to use the Loading plugin with pagination in external container. You need to create pagination overlay manually, after that you can use the afterLoadingShow and afterLoadingHide hooks to show and hide the pagination container overlay.

-
+
-
\ No newline at end of file +
diff --git a/docs/content/guides/dialog/loading/react/example3.jsx b/docs/content/guides/dialog/loading/react/example3.jsx index ea3d9a59bf5..8c3f3923783 100644 --- a/docs/content/guides/dialog/loading/react/example3.jsx +++ b/docs/content/guides/dialog/loading/react/example3.jsx @@ -109,10 +109,12 @@ const ExampleComponent = () => { return ( <> -
- +
+
+ +
diff --git a/docs/content/guides/dialog/loading/react/example3.tsx b/docs/content/guides/dialog/loading/react/example3.tsx index eb5ac218a29..52497e74bf0 100644 --- a/docs/content/guides/dialog/loading/react/example3.tsx +++ b/docs/content/guides/dialog/loading/react/example3.tsx @@ -113,10 +113,12 @@ const ExampleComponent = () => { return ( <> -
- +
+
+ +
diff --git a/docs/content/guides/dialog/loading/react/example4.jsx b/docs/content/guides/dialog/loading/react/example4.jsx index 32c64b637c2..0bf9a906f60 100644 --- a/docs/content/guides/dialog/loading/react/example4.jsx +++ b/docs/content/guides/dialog/loading/react/example4.jsx @@ -152,10 +152,12 @@ const ExampleComponent = () => { return ( <>
-
- +
+
+ +

diff --git a/docs/content/guides/dialog/loading/react/example4.tsx b/docs/content/guides/dialog/loading/react/example4.tsx index d4b6a48ac0e..e079bbd3b00 100644 --- a/docs/content/guides/dialog/loading/react/example4.tsx +++ b/docs/content/guides/dialog/loading/react/example4.tsx @@ -152,10 +152,12 @@ const ExampleComponent = () => { return ( <>

-
- +
+
+ +

diff --git a/docs/content/guides/formulas/formula-calculation/formula-calculation.md b/docs/content/guides/formulas/formula-calculation/formula-calculation.md index de3bc734b40..7247478c9a1 100644 --- a/docs/content/guides/formulas/formula-calculation/formula-calculation.md +++ b/docs/content/guides/formulas/formula-calculation/formula-calculation.md @@ -289,7 +289,7 @@ const hyperformulaInstance = HyperFormula.buildEmpty({ // initialize it with the `'internal-use-in-handsontable'` license key licenseKey: 'internal-use-in-handsontable', }); - + const configurationOptions: GridSettings = { formulas: { engine: hyperformulaInstance @@ -722,7 +722,9 @@ For more information about named expressions, refer to the ## View the explainer video - +

+ +
## Known limitations diff --git a/docs/content/guides/getting-started/saving-data/angular/example1.ts b/docs/content/guides/getting-started/saving-data/angular/example1.ts index 03a7f68a827..e15f1d07982 100644 --- a/docs/content/guides/getting-started/saving-data/angular/example1.ts +++ b/docs/content/guides/getting-started/saving-data/angular/example1.ts @@ -68,7 +68,7 @@ export class AppComponent { return; } - fetch('https://handsontable.com/docs/scripts/json/save.json', { + fetch('/docs/scripts/json/save.json', { method: 'POST', mode: 'no-cors', headers: { @@ -99,7 +99,7 @@ export class AppComponent { loadClickCallback(event: MouseEvent): void { const hot = this.hotTable?.hotInstance; - fetch('https://handsontable.com/docs/scripts/json/load.json').then( + fetch('/docs/scripts/json/load.json').then( (response) => { response.json().then((data) => { hot?.loadData(data.data); @@ -114,7 +114,7 @@ export class AppComponent { const hot = this.hotTable?.hotInstance; // save all cell's data - fetch('https://handsontable.com/docs/scripts/json/save.json', { + fetch('/docs/scripts/json/save.json', { method: 'POST', mode: 'no-cors', headers: { diff --git a/docs/content/guides/getting-started/server-side-data/server-side-data-configuration.md b/docs/content/guides/getting-started/server-side-data/server-side-data-configuration.md index e70f861afae..e9e9d0e1074 100644 --- a/docs/content/guides/getting-started/server-side-data/server-side-data-configuration.md +++ b/docs/content/guides/getting-started/server-side-data/server-side-data-configuration.md @@ -73,7 +73,11 @@ Respect `signal` so outdated requests abort when the user sorts, filters, or cha ## More in this guide +
+ - [Server-side data](@/guides/getting-started/server-side-data/server-side-data.md) - [Migrate from client-side data](@/guides/getting-started/server-side-data/server-side-data-migration.md) - [Create, update, and remove](@/guides/getting-started/server-side-data/server-side-data-crud.md) - [Fetching, hooks, and examples](@/guides/getting-started/server-side-data/server-side-data-fetching.md) + +
diff --git a/docs/content/guides/getting-started/server-side-data/server-side-data-crud.md b/docs/content/guides/getting-started/server-side-data/server-side-data-crud.md index a940b61074f..57a87e48cdb 100644 --- a/docs/content/guides/getting-started/server-side-data/server-side-data-crud.md +++ b/docs/content/guides/getting-started/server-side-data/server-side-data-crud.md @@ -84,7 +84,11 @@ When `onRowsUpdate` is set, Handsontable skips stacking certain edit sources on ## More in this guide +
+ - [Server-side data](@/guides/getting-started/server-side-data/server-side-data.md) - [Migrate from client-side data](@/guides/getting-started/server-side-data/server-side-data-migration.md) - [Configuration and query parameters](@/guides/getting-started/server-side-data/server-side-data-configuration.md) - [Fetching, hooks, and examples](@/guides/getting-started/server-side-data/server-side-data-fetching.md) + +
diff --git a/docs/content/guides/getting-started/server-side-data/server-side-data-fetching.md b/docs/content/guides/getting-started/server-side-data/server-side-data-fetching.md index f89d06ea983..13f40b1cc1b 100644 --- a/docs/content/guides/getting-started/server-side-data/server-side-data-fetching.md +++ b/docs/content/guides/getting-started/server-side-data/server-side-data-fetching.md @@ -73,19 +73,55 @@ Each folder includes the same Express servers (`server-rest.mjs`, `server-graphq ## More in this guide +
+ - [Server-side data](@/guides/getting-started/server-side-data/server-side-data.md) - [Migrate from client-side data](@/guides/getting-started/server-side-data/server-side-data-migration.md) - [Configuration and query parameters](@/guides/getting-started/server-side-data/server-side-data-configuration.md) - [Create, update, and remove](@/guides/getting-started/server-side-data/server-side-data-crud.md) +
+ ## Related guides +
+ - [Rows pagination](@/guides/rows/rows-pagination/rows-pagination.md) - [Rows sorting](@/guides/rows/rows-sorting/rows-sorting.md) - [Column filter](@/guides/columns/column-filter/column-filter.md) +
+ ## Related API reference -- Option: [`dataProvider`](@/api/options.md#dataprovider) -- Plugin: [`DataProvider`](@/api/dataProvider.md) -- Hooks: [`beforeDataProviderFetch`](@/api/hooks.md#beforedataproviderfetch), [`afterDataProviderFetch`](@/api/hooks.md#afterdataproviderfetch), [`afterDataProviderFetchError`](@/api/hooks.md#afterdataproviderfetcherror), [`afterDataProviderFetchAbort`](@/api/hooks.md#afterdataproviderfetchabort), [`hasExternalDataSource`](@/api/hooks.md#hasexternaldatasource), [`modifyRowHeader`](@/api/hooks.md#modifyrowheader) (global row index with pagination), [`beforeRowsMutation`](@/api/hooks.md#beforerowsmutation), [`afterRowsMutation`](@/api/hooks.md#afterrowsmutation), [`afterRowsMutationError`](@/api/hooks.md#afterrowsmutationerror) +**Options** + +
+ +- [`dataProvider`](@/api/options.md#dataprovider) + +
+ +**Plugins** + +
+ +- [`DataProvider`](@/api/dataProvider.md) + +
+ +**Hooks** + +
+ +- [`beforeDataProviderFetch`](@/api/hooks.md#beforedataproviderfetch) +- [`afterDataProviderFetch`](@/api/hooks.md#afterdataproviderfetch) +- [`afterDataProviderFetchError`](@/api/hooks.md#afterdataproviderfetcherror) +- [`afterDataProviderFetchAbort`](@/api/hooks.md#afterdataproviderfetchabort) +- [`hasExternalDataSource`](@/api/hooks.md#hasexternaldatasource) +- [`modifyRowHeader`](@/api/hooks.md#modifyrowheader) (global row index with pagination) +- [`beforeRowsMutation`](@/api/hooks.md#beforerowsmutation) +- [`afterRowsMutation`](@/api/hooks.md#afterrowsmutation) +- [`afterRowsMutationError`](@/api/hooks.md#afterrowsmutationerror) + +
diff --git a/docs/content/guides/getting-started/server-side-data/server-side-data-migration.md b/docs/content/guides/getting-started/server-side-data/server-side-data-migration.md index 338473eb8fc..0827a05694b 100644 --- a/docs/content/guides/getting-started/server-side-data/server-side-data-migration.md +++ b/docs/content/guides/getting-started/server-side-data/server-side-data-migration.md @@ -36,11 +36,15 @@ Use this checklist when moving from a full in-memory `data` array and hooks such ## More in this guide +
+ - [Server-side data](@/guides/getting-started/server-side-data/server-side-data.md) — overview, quick start, and demo. - [Configuration and query parameters](@/guides/getting-started/server-side-data/server-side-data-configuration.md) - [Create, update, and remove](@/guides/getting-started/server-side-data/server-side-data-crud.md) - [Fetching, hooks, and examples](@/guides/getting-started/server-side-data/server-side-data-fetching.md) +
+ ## Result Your grid now loads and saves data server-side using the updated API. diff --git a/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.html b/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.html new file mode 100644 index 00000000000..0e3dc4668aa --- /dev/null +++ b/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.js b/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.js new file mode 100644 index 00000000000..489c383be9f --- /dev/null +++ b/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.js @@ -0,0 +1,62 @@ +import { defineComponent, h, render } from 'vue'; +import { HotTable } from '@handsontable/vue3'; +import { registerAllModules } from 'handsontable/registry'; + +// register Handsontable's modules +registerAllModules(); + +// A reusable Vue 3 component that renders a cell value as an image. +const ImageCell = defineComponent({ + props: { + src: { type: String, required: true }, + }, + render() { + return h('img', { + src: this.src, + onMousedown: (event) => event.preventDefault(), + }); + }, +}); + +// Bridge function that mounts the Vue 3 component into the cell's TD element. +// Vue's `render()` patches the existing tree on subsequent calls, so the +// component instance is reused across re-renders. +function imageComponentRenderer(instance, td, row, col, prop, value) { + const vnode = h(ImageCell, { src: value }); + + render(vnode, td); + + return td; +} + +const ExampleComponent = defineComponent({ + data() { + return { + hotSettings: { + data: [ + ['Professional JavaScript for Web Developers', + '/docs/img/examples/professional-javascript-developers-nicholas-zakas.jpg'], + ['JavaScript: The Good Parts', + '/docs/img/examples/javascript-the-good-parts.jpg'], + ], + columns: [ + {}, + { + renderer: imageComponentRenderer, + }, + ], + colHeaders: true, + rowHeights: 55, + height: 'auto', + autoWrapRow: true, + autoWrapCol: true, + licenseKey: 'non-commercial-and-evaluation', + }, + }; + }, + components: { + HotTable, + }, +}); + +export default ExampleComponent; diff --git a/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue3-custom-renderer-example.md b/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue3-custom-renderer-example.md index 771e3bb75aa..54ae999d658 100644 --- a/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue3-custom-renderer-example.md +++ b/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue3-custom-renderer-example.md @@ -3,7 +3,7 @@ type: tutorial id: uu0rzeo6 title: Custom renderer in Vue 3 metaTitle: Custom cell renderer - Vue 3 Data Grid | Handsontable -description: Create a custom cell renderer, and use it in your Vue 3 data grid by declaring it as a function. +description: Create a custom cell renderer, and use it in your Vue 3 data grid by declaring it as a function or as a Vue 3 component. permalink: /vue3-custom-renderer-example canonicalUrl: /vue3-custom-renderer-example react: @@ -36,6 +36,21 @@ The following example is an implementation of `@handsontable/vue3` with a custom ::: +## Declare a renderer as a Vue 3 component + +You can use a Vue 3 component as a custom cell renderer by mounting it into the cell's TD element from inside the renderer function. Use Vue 3's `render(vnode, container)` API to mount the component imperatively and reuse the same component instance across re-renders -- Vue patches the existing tree instead of remounting. + +The renderer function receives the same arguments as a regular function-based renderer. You build a VNode from your component with `h(Component, props)` and pass it to `render()` together with the TD element. To pass static props alongside cell data, merge them into the second argument of `h()`. + +::: example #example2 :vue3 --html 1 --js 2 + +@[code](@/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.html) +@[code](@/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.js) + +::: + +If your component needs access to a Vue application context -- for example, global components, plugins, or `provide` / `inject` -- create a dedicated app per cell with `createApp(Component, props).mount(td)` instead of `render()`. Track the returned app instances so you can call `app.unmount()` when the grid is destroyed. + ## Related articles **Related guides** @@ -94,6 +109,7 @@ The following example is an implementation of `@handsontable/vue3` with a custom - How to declare a custom renderer function in a Vue 3 application. - How to read cell values and render HTML elements -- such as images -- inside cells. - How to assign the renderer to a specific column using the `renderer` option. +- How to mount a Vue 3 component as a custom cell renderer with `render(vnode, td)`. ## Next steps diff --git a/docs/content/guides/internationalization/language/angular/example2.html b/docs/content/guides/internationalization/language/angular/example2.html deleted file mode 100644 index f6e097955e1..00000000000 --- a/docs/content/guides/internationalization/language/angular/example2.html +++ /dev/null @@ -1,3 +0,0 @@ -
- -
diff --git a/docs/content/guides/internationalization/language/angular/example2.ts b/docs/content/guides/internationalization/language/angular/example2.ts deleted file mode 100644 index 53de5674733..00000000000 --- a/docs/content/guides/internationalization/language/angular/example2.ts +++ /dev/null @@ -1,120 +0,0 @@ -/* file: app.component.ts */ -import { Component, ViewChild } from '@angular/core'; -import { GridSettings, HotTableComponent, HotTableModule} from '@handsontable/angular-wrapper'; -import { registerLanguageDictionary, getLanguagesDictionaries, - // @ts-ignore - arAR, - csCZ, - deCH, - deDE, - esMX, - frFR, - hrHR, - itIT, - jaJP, - koKR, - lvLV, - nbNO, - nlNL, - plPL, - ptBR, - ruRU, - srSP, - zhCN, - zhTW, -} from 'handsontable/i18n'; - -registerLanguageDictionary(arAR); -registerLanguageDictionary(csCZ); -registerLanguageDictionary(deCH); -registerLanguageDictionary(deDE); -registerLanguageDictionary(esMX); -registerLanguageDictionary(frFR); -registerLanguageDictionary(hrHR); -registerLanguageDictionary(itIT); -registerLanguageDictionary(jaJP); -registerLanguageDictionary(koKR); -registerLanguageDictionary(lvLV); -registerLanguageDictionary(nbNO); -registerLanguageDictionary(nlNL); -registerLanguageDictionary(plPL); -registerLanguageDictionary(ptBR); -registerLanguageDictionary(ruRU); -registerLanguageDictionary(srSP); -registerLanguageDictionary(zhCN); -registerLanguageDictionary(zhTW); - -@Component({ - selector: 'example2-language', - standalone: true, - imports: [HotTableModule], - template: `
-
- -
-
- -
-
`, -}) -export class AppComponent { - @ViewChild(HotTableComponent, { static: false }) readonly hotTable!: HotTableComponent; - - readonly data: Array> = [ - ['A1', 'B1', 'C1', 'D1', 'E1', 'F1', 'G1', 'H1', 'I1', 'J1'], - ['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2', 'H2', 'I2', 'J2'], - ['A3', 'B3', 'C3', 'D3', 'E3', 'F3', 'G3', 'H3', 'I3', 'J3'], - ['A4', 'B4', 'C4', 'D4', 'E4', 'F4', 'G4', 'H4', 'I4', 'J4'], - ['A5', 'B5', 'C5', 'D5', 'E5', 'F5', 'G5', 'H5', 'I5', 'J5'], - ]; - - readonly languageList = getLanguagesDictionaries().map( - (item) => item.languageCode - ); - - language = 'en-US'; - - readonly gridSettings: GridSettings = { - colHeaders: true, - rowHeaders: true, - contextMenu: true, - height: 'auto', - language: this.language, - autoWrapRow: true, - autoWrapCol: true - }; - - updateHotLanguage(event: any): void { - this.language = event.target.value; - this.hotTable?.hotInstance?.updateSettings({ language: this.language }); - } -} -/* end-file */ - - - -/* file: app.config.ts */ -import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; -import { registerAllModules } from 'handsontable/registry'; -import { HOT_GLOBAL_CONFIG, HotGlobalConfig, NON_COMMERCIAL_LICENSE } from '@handsontable/angular-wrapper'; - -// register Handsontable's modules -registerAllModules(); - -export const appConfig: ApplicationConfig = { - providers: [ - provideZoneChangeDetection({ eventCoalescing: true }), - { - provide: HOT_GLOBAL_CONFIG, - useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig, - }, - ], -}; -/* end-file */ diff --git a/docs/content/guides/internationalization/language/language.md b/docs/content/guides/internationalization/language/language.md index c9b4d9cc11d..6fc2279de90 100644 --- a/docs/content/guides/internationalization/language/language.md +++ b/docs/content/guides/internationalization/language/language.md @@ -217,28 +217,6 @@ Language files were loaded after loading Handsontable. ::: -::: only-for react - -::: example #example2 :react-languages --js 1 --ts 2 - -@[code](@/content/guides/internationalization/language/react/example2.jsx) -@[code](@/content/guides/internationalization/language/react/example2.tsx) - -::: - -::: - -::: only-for angular - -::: example #example2 :angular-languages --ts 1 --html 2 - -@[code](@/content/guides/internationalization/language/angular/example2.ts) -@[code](@/content/guides/internationalization/language/angular/example2.html) - -::: - -::: - ## List of translatable features Below is a list of features which can be translated: diff --git a/docs/content/guides/internationalization/language/react/example2.jsx b/docs/content/guides/internationalization/language/react/example2.jsx deleted file mode 100644 index 3eb3df83d5f..00000000000 --- a/docs/content/guides/internationalization/language/react/example2.jsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useState, useEffect } from 'react'; -import { HotTable } from '@handsontable/react-wrapper'; -import { - registerLanguageDictionary, - getLanguagesDictionaries, - arAR, - csCZ, - deCH, - deDE, - esMX, - frFR, - hrHR, - itIT, - jaJP, - koKR, - lvLV, - nbNO, - nlNL, - plPL, - ptBR, - ruRU, - srSP, - zhCN, - zhTW, -} from 'handsontable/i18n'; -import { registerAllModules } from 'handsontable/registry'; - -// register Handsontable's modules -registerAllModules(); -registerLanguageDictionary(arAR); -registerLanguageDictionary(csCZ); -registerLanguageDictionary(deCH); -registerLanguageDictionary(deDE); -registerLanguageDictionary(esMX); -registerLanguageDictionary(frFR); -registerLanguageDictionary(hrHR); -registerLanguageDictionary(itIT); -registerLanguageDictionary(jaJP); -registerLanguageDictionary(koKR); -registerLanguageDictionary(lvLV); -registerLanguageDictionary(nbNO); -registerLanguageDictionary(nlNL); -registerLanguageDictionary(plPL); -registerLanguageDictionary(ptBR); -registerLanguageDictionary(ruRU); -registerLanguageDictionary(srSP); -registerLanguageDictionary(zhCN); -registerLanguageDictionary(zhTW); - -const ExampleComponent = () => { - const [language, setLanguage] = useState('en-US'); - const [languageList, setLanguageList] = useState([]); - - useEffect(() => { - setLanguageList(getLanguagesDictionaries()); - }, []); - - const updateHotLanguage = (event) => { - setLanguage(event.target.value); - }; - - return ( -
-
- -
- - -
- ); -}; - -export default ExampleComponent; diff --git a/docs/content/guides/internationalization/language/react/example2.tsx b/docs/content/guides/internationalization/language/react/example2.tsx deleted file mode 100644 index 19c40d18aaa..00000000000 --- a/docs/content/guides/internationalization/language/react/example2.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useState, useEffect } from 'react'; -import { HotTable } from '@handsontable/react-wrapper'; -import { - registerLanguageDictionary, - getLanguagesDictionaries, - arAR, - csCZ, - deCH, - deDE, - esMX, - frFR, - hrHR, - itIT, - jaJP, - koKR, - lvLV, - nbNO, - nlNL, - plPL, - ptBR, - ruRU, - srSP, - zhCN, - zhTW, -} from 'handsontable/i18n'; -import { registerAllModules } from 'handsontable/registry'; - -// register Handsontable's modules -registerAllModules(); - -registerLanguageDictionary(arAR); -registerLanguageDictionary(csCZ); -registerLanguageDictionary(deCH); -registerLanguageDictionary(deDE); -registerLanguageDictionary(esMX); -registerLanguageDictionary(frFR); -registerLanguageDictionary(hrHR); -registerLanguageDictionary(itIT); -registerLanguageDictionary(jaJP); -registerLanguageDictionary(koKR); -registerLanguageDictionary(lvLV); -registerLanguageDictionary(nbNO); -registerLanguageDictionary(nlNL); -registerLanguageDictionary(plPL); -registerLanguageDictionary(ptBR); -registerLanguageDictionary(ruRU); -registerLanguageDictionary(srSP); -registerLanguageDictionary(zhCN); -registerLanguageDictionary(zhTW); - -const ExampleComponent = () => { - const [language, setLanguage] = useState('en-US'); - const [languageList, setLanguageList] = useState([]); - - useEffect(() => { - setLanguageList(getLanguagesDictionaries()); - }, []); - - const updateHotLanguage = (event: React.ChangeEvent) => { - setLanguage(event.target.value); - }; - - return ( -
-
- -
- - -
- ); -}; - -export default ExampleComponent; diff --git a/docs/content/guides/navigation/focus-scopes/angular/example1.ts b/docs/content/guides/navigation/focus-scopes/angular/example1.ts index fd958315fef..f42d4e4b160 100644 --- a/docs/content/guides/navigation/focus-scopes/angular/example1.ts +++ b/docs/content/guides/navigation/focus-scopes/angular/example1.ts @@ -62,12 +62,22 @@ import { HotTableComponent } from '@handsontable/angular-wrapper'; styles: ` .placeholder-input { max-width: 20rem; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; + padding: 0.4rem 0.625rem; + font-size: var(--sl-text-sm); line-height: 1.25rem; - color: black; - border: 1px solid #e4e4e7; - border-radius: 6px; + color: var(--sl-color-text); + background: none; + border: 1px solid var(--sl-color-gray-5); + border-radius: 0; + outline: none; + } + + .placeholder-input::placeholder { + color: var(--sl-color-gray-3); + } + + .placeholder-input:focus { + border-color: var(--sl-color-accent); } .example-container { @@ -75,10 +85,6 @@ import { HotTableComponent } from '@handsontable/angular-wrapper'; display: flex; flex-direction: column; } - - .debug-table td { - padding: 10px !important; - } `, }) export class AppComponent implements AfterViewInit, OnDestroy { diff --git a/docs/content/guides/navigation/focus-scopes/angular/example2.ts b/docs/content/guides/navigation/focus-scopes/angular/example2.ts index 23532b3018b..f67ef7a4764 100644 --- a/docs/content/guides/navigation/focus-scopes/angular/example2.ts +++ b/docs/content/guides/navigation/focus-scopes/angular/example2.ts @@ -62,12 +62,22 @@ import { HotTableComponent } from '@handsontable/angular-wrapper'; styles: ` .placeholder-input { max-width: 20rem; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; + padding: 0.4rem 0.625rem; + font-size: var(--sl-text-sm); line-height: 1.25rem; - color: black; - border: 1px solid #e4e4e7; - border-radius: 6px; + color: var(--sl-color-text); + background: none; + border: 1px solid var(--sl-color-gray-5); + border-radius: 0; + outline: none; + } + + .placeholder-input::placeholder { + color: var(--sl-color-gray-3); + } + + .placeholder-input:focus { + border-color: var(--sl-color-accent); } .example-container { @@ -75,10 +85,6 @@ import { HotTableComponent } from '@handsontable/angular-wrapper'; display: flex; flex-direction: column; } - - .debug-table td { - padding: 10px !important; - } `, }) export class AppComponent implements AfterViewInit, OnDestroy { diff --git a/docs/content/guides/navigation/focus-scopes/javascript/example1.css b/docs/content/guides/navigation/focus-scopes/javascript/example1.css index 7821a226282..74d0f355700 100644 --- a/docs/content/guides/navigation/focus-scopes/javascript/example1.css +++ b/docs/content/guides/navigation/focus-scopes/javascript/example1.css @@ -23,30 +23,3 @@ display: flex; flex-direction: column; } - -.example-container > strong { - font-size: 0.875rem; - font-weight: 600; - color: var(--sl-color-white); - margin: 0.5rem 0 0; -} - -.example-container .debug-table { - font-size: var(--sl-text-sm); - border-collapse: collapse; - width: 100%; -} - -.example-container .debug-table td { - padding: 0.4rem 0.75rem !important; - border-bottom: 1px solid var(--sl-color-gray-5); - color: var(--sl-color-gray-2); -} - -.example-container .debug-table td:last-child { - font-family: var(--sl-font-mono, ui-monospace, monospace); -} - -.example-container .debug-table td code { - font-size: var(--sl-text-xs); -} diff --git a/docs/content/guides/navigation/focus-scopes/javascript/example2.css b/docs/content/guides/navigation/focus-scopes/javascript/example2.css index 7821a226282..74d0f355700 100644 --- a/docs/content/guides/navigation/focus-scopes/javascript/example2.css +++ b/docs/content/guides/navigation/focus-scopes/javascript/example2.css @@ -23,30 +23,3 @@ display: flex; flex-direction: column; } - -.example-container > strong { - font-size: 0.875rem; - font-weight: 600; - color: var(--sl-color-white); - margin: 0.5rem 0 0; -} - -.example-container .debug-table { - font-size: var(--sl-text-sm); - border-collapse: collapse; - width: 100%; -} - -.example-container .debug-table td { - padding: 0.4rem 0.75rem !important; - border-bottom: 1px solid var(--sl-color-gray-5); - color: var(--sl-color-gray-2); -} - -.example-container .debug-table td:last-child { - font-family: var(--sl-font-mono, ui-monospace, monospace); -} - -.example-container .debug-table td code { - font-size: var(--sl-text-xs); -} diff --git a/docs/content/guides/navigation/focus-scopes/react/example1.css b/docs/content/guides/navigation/focus-scopes/react/example1.css index 7821a226282..74d0f355700 100644 --- a/docs/content/guides/navigation/focus-scopes/react/example1.css +++ b/docs/content/guides/navigation/focus-scopes/react/example1.css @@ -23,30 +23,3 @@ display: flex; flex-direction: column; } - -.example-container > strong { - font-size: 0.875rem; - font-weight: 600; - color: var(--sl-color-white); - margin: 0.5rem 0 0; -} - -.example-container .debug-table { - font-size: var(--sl-text-sm); - border-collapse: collapse; - width: 100%; -} - -.example-container .debug-table td { - padding: 0.4rem 0.75rem !important; - border-bottom: 1px solid var(--sl-color-gray-5); - color: var(--sl-color-gray-2); -} - -.example-container .debug-table td:last-child { - font-family: var(--sl-font-mono, ui-monospace, monospace); -} - -.example-container .debug-table td code { - font-size: var(--sl-text-xs); -} diff --git a/docs/content/guides/navigation/focus-scopes/react/example2.css b/docs/content/guides/navigation/focus-scopes/react/example2.css index 7821a226282..74d0f355700 100644 --- a/docs/content/guides/navigation/focus-scopes/react/example2.css +++ b/docs/content/guides/navigation/focus-scopes/react/example2.css @@ -23,30 +23,3 @@ display: flex; flex-direction: column; } - -.example-container > strong { - font-size: 0.875rem; - font-weight: 600; - color: var(--sl-color-white); - margin: 0.5rem 0 0; -} - -.example-container .debug-table { - font-size: var(--sl-text-sm); - border-collapse: collapse; - width: 100%; -} - -.example-container .debug-table td { - padding: 0.4rem 0.75rem !important; - border-bottom: 1px solid var(--sl-color-gray-5); - color: var(--sl-color-gray-2); -} - -.example-container .debug-table td:last-child { - font-family: var(--sl-font-mono, ui-monospace, monospace); -} - -.example-container .debug-table td code { - font-size: var(--sl-text-xs); -} diff --git a/docs/content/guides/rows/row-prepopulating/angular/example1.ts b/docs/content/guides/rows/row-prepopulating/angular/example1.ts index 594efb8f3b4..6bdceffb04d 100644 --- a/docs/content/guides/rows/row-prepopulating/angular/example1.ts +++ b/docs/content/guides/rows/row-prepopulating/angular/example1.ts @@ -1,137 +1,37 @@ /* file: app.component.ts */ -import { AfterViewInit, Component, ViewChild } from '@angular/core'; -import {GridSettings, HotTableComponent, HotTableModule} from '@handsontable/angular-wrapper'; -import Handsontable from 'handsontable/base'; -import { textRenderer } from 'handsontable/renderers/textRenderer'; +import { Component } from '@angular/core'; +import { GridSettings, HotTableModule } from '@handsontable/angular-wrapper'; @Component({ selector: 'app-example1', template: ` - - + `, standalone: true, imports: [HotTableModule], }) -export class AppComponent implements AfterViewInit { - @ViewChild(HotTableComponent, {static: false}) hotTable!: HotTableComponent; - - hotData = [ - +export class AppComponent { + readonly hotData = [ + ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], + ['2017', 10, 11, 12, 13], + ['2018', 20, 11, 14, 13], + ['2019', 30, 15, 12, 13], ]; - hotSettings: GridSettings = { - + readonly gridSettings: GridSettings = { + minSpareRows: 1, + height: 'auto', + autoWrapRow: true, + autoWrapCol: true, }; - - ngAfterViewInit() { - const templateValues = ['one', 'two', 'three']; - const data = [ - ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], - ['2017', 10, 11, 12, 13], - ['2018', 20, 11, 14, 13], - ['2019', 30, 15, 12, 13], - ]; - - function isEmptyRow(instance: Handsontable, row: number) { - const rowData = instance.getDataAtRow(row); - - for (let i = 0, ilen = rowData.length; i < ilen; i++) { - if (rowData[i] !== null) { - return false; - } - } - - return true; - } - - const defaultValueRenderer = ( - instance: Handsontable, - td: HTMLTableCellElement, - row: number, - col: number, - prop: string | number, - value: Handsontable.CellValue, - cellProperties: Handsontable.CellProperties - ) => { - if (value === null && isEmptyRow(instance, row)) { - value = templateValues[col]; - td.style.color = '#999'; - } else { - td.style.color = ''; - } - - textRenderer( - instance, - td, - row, - col, - prop, - value, - cellProperties - ); - }; - - const hot = this.hotTable.hotInstance!; - - this.hotSettings = { - startRows: 8, - startCols: 5, - minSpareRows: 1, - contextMenu: true, - height: 'auto', - licenseKey: 'non-commercial-and-evaluation', - cells() { - return { renderer: defaultValueRenderer }; - }, - beforeChange: function (changes) { - const instance = hot; - const columns = instance.countCols(); - const rowColumnSeen: Record = {}; - const rowsToFill: Record = {}; - const ch = changes === null ? [] : changes!; - - for (let i = 0; i < ch.length; i++) { - // if oldVal is empty - if (ch[i]![2] === null && ch[i]![3] !== null) { - if (isEmptyRow(instance, ch[i]![0])) { - // add this row/col combination to the cache so it will not be overwritten by the template - rowColumnSeen[`${ch[i]![0]}/${ch[i]![1]}`] = true; - rowsToFill[String(ch[i]![0])] = true; - } - } - } - - for (const r in rowsToFill) { - if (rowsToFill.hasOwnProperty(r)) { - for (let c = 0; c < columns; c++) { - // if it is not provided by user in this change set, take the value from the template - if (!rowColumnSeen[`${r}/${c}`]) { - ch.push([Number(r), c, null, templateValues[c]]); - } - } - } - } - }, - autoWrapRow: true, - autoWrapCol: true, - } - - this.hotTable.hotInstance!.loadData(data); - } - } /* end-file */ - - /* file: app.config.ts */ import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; import { registerAllModules } from 'handsontable/registry'; import { HOT_GLOBAL_CONFIG, HotGlobalConfig, NON_COMMERCIAL_LICENSE } from '@handsontable/angular-wrapper'; -// register Handsontable's modules registerAllModules(); export const appConfig: ApplicationConfig = { diff --git a/docs/content/guides/rows/row-prepopulating/angular/example2.html b/docs/content/guides/rows/row-prepopulating/angular/example2.html new file mode 100644 index 00000000000..7de9666d333 --- /dev/null +++ b/docs/content/guides/rows/row-prepopulating/angular/example2.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/docs/content/guides/rows/row-prepopulating/angular/example2.ts b/docs/content/guides/rows/row-prepopulating/angular/example2.ts new file mode 100644 index 00000000000..a890a1eee06 --- /dev/null +++ b/docs/content/guides/rows/row-prepopulating/angular/example2.ts @@ -0,0 +1,84 @@ +/* file: app.component.ts */ +import { Component } from '@angular/core'; +import { GridSettings, HotTableModule } from '@handsontable/angular-wrapper'; +import Handsontable from 'handsontable/base'; +import { textRenderer } from 'handsontable/renderers/textRenderer'; + +const templateValues = ['one', 'two', 'three']; + +function isEmptyRow(instance: Handsontable, row: number) { + const rowData = instance.getDataAtRow(row); + + for (let i = 0, ilen = rowData.length; i < ilen; i++) { + if (rowData[i] !== null) { + return false; + } + } + + return true; +} + +const defaultValueRenderer = ( + instance: Handsontable, + td: HTMLTableCellElement, + row: number, + col: number, + prop: string | number, + value: Handsontable.CellValue, + cellProperties: Handsontable.CellProperties +) => { + if (value === null && isEmptyRow(instance, row)) { + value = templateValues[col]; + td.style.color = '#999'; + } else { + td.style.color = ''; + } + + textRenderer(instance, td, row, col, prop, value, cellProperties); +}; + +@Component({ + selector: 'app-example2', + template: ` + + `, + standalone: true, + imports: [HotTableModule], +}) +export class AppComponent { + readonly hotData = [ + ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], + ['2017', 10, 11, 12, 13], + ['2018', 20, 11, 14, 13], + ['2019', 30, 15, 12, 13], + ]; + + readonly gridSettings: GridSettings = { + minSpareRows: 1, + height: 'auto', + autoWrapRow: true, + autoWrapCol: true, + cells() { + return { renderer: defaultValueRenderer }; + }, + }; +} +/* end-file */ + +/* file: app.config.ts */ +import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import { registerAllModules } from 'handsontable/registry'; +import { HOT_GLOBAL_CONFIG, HotGlobalConfig, NON_COMMERCIAL_LICENSE } from '@handsontable/angular-wrapper'; + +registerAllModules(); + +export const appConfig: ApplicationConfig = { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + { + provide: HOT_GLOBAL_CONFIG, + useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig, + }, + ], +}; +/* end-file */ diff --git a/docs/content/guides/rows/row-prepopulating/angular/example3.html b/docs/content/guides/rows/row-prepopulating/angular/example3.html new file mode 100644 index 00000000000..e3db8213fab --- /dev/null +++ b/docs/content/guides/rows/row-prepopulating/angular/example3.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/docs/content/guides/rows/row-prepopulating/angular/example3.ts b/docs/content/guides/rows/row-prepopulating/angular/example3.ts new file mode 100644 index 00000000000..307cbe304f5 --- /dev/null +++ b/docs/content/guides/rows/row-prepopulating/angular/example3.ts @@ -0,0 +1,138 @@ +/* file: app.component.ts */ +import { AfterViewInit, Component, ViewChild } from '@angular/core'; +import {GridSettings, HotTableComponent, HotTableModule} from '@handsontable/angular-wrapper'; +import Handsontable from 'handsontable/base'; +import { textRenderer } from 'handsontable/renderers/textRenderer'; + +@Component({ + selector: 'app-example3', + template: ` + + + `, + standalone: true, + imports: [HotTableModule], +}) +export class AppComponent implements AfterViewInit { + @ViewChild(HotTableComponent, {static: false}) hotTable!: HotTableComponent; + + hotData = [ + ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], + ['2017', 10, 11, 12, 13], + ['2018', 20, 11, 14, 13], + ['2019', 30, 15, 12, 13], + ]; + + hotSettings: GridSettings = {}; + + ngAfterViewInit() { + const templateValues = ['one', 'two', 'three']; + + function isEmptyRow(instance: Handsontable, row: number) { + const rowData = instance.getDataAtRow(row); + + for (let i = 0, ilen = rowData.length; i < ilen; i++) { + if (rowData[i] !== null) { + return false; + } + } + + return true; + } + + const defaultValueRenderer = ( + instance: Handsontable, + td: HTMLTableCellElement, + row: number, + col: number, + prop: string | number, + value: Handsontable.CellValue, + cellProperties: Handsontable.CellProperties + ) => { + if (value === null && isEmptyRow(instance, row)) { + value = templateValues[col]; + td.style.color = '#999'; + } else { + td.style.color = ''; + } + + textRenderer( + instance, + td, + row, + col, + prop, + value, + cellProperties + ); + }; + + this.hotSettings = { + startRows: 8, + startCols: 5, + minSpareRows: 1, + contextMenu: true, + height: 'auto', + licenseKey: 'non-commercial-and-evaluation', + cells() { + return { renderer: defaultValueRenderer }; + }, + beforeChange: (changes) => { + const instance = this.hotTable.hotInstance!; + const columns = instance.countCols(); + const rowColumnSeen: Record = {}; + const rowsToFill: Record = {}; + const ch = changes === null ? [] : changes!; + + for (let i = 0; i < ch.length; i++) { + // if oldVal is empty + if (ch[i]![2] === null && ch[i]![3] !== null) { + if (isEmptyRow(instance, ch[i]![0])) { + // add this row/col combination to the cache so it will not be overwritten by the template + rowColumnSeen[`${ch[i]![0]}/${ch[i]![1]}`] = true; + rowsToFill[String(ch[i]![0])] = true; + } + } + } + + for (const r in rowsToFill) { + if (rowsToFill.hasOwnProperty(r)) { + for (let c = 0; c < columns; c++) { + // if it is not provided by user in this change set, take the value from the template + if (!rowColumnSeen[`${r}/${c}`]) { + ch.push([Number(r), c, null, templateValues[c]]); + } + } + } + } + }, + autoWrapRow: true, + autoWrapCol: true, + } + + } + +} +/* end-file */ + + + +/* file: app.config.ts */ +import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import { registerAllModules } from 'handsontable/registry'; +import { HOT_GLOBAL_CONFIG, HotGlobalConfig, NON_COMMERCIAL_LICENSE } from '@handsontable/angular-wrapper'; + +// register Handsontable's modules +registerAllModules(); + +export const appConfig: ApplicationConfig = { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + { + provide: HOT_GLOBAL_CONFIG, + useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig, + }, + ], +}; +/* end-file */ diff --git a/docs/content/guides/rows/row-prepopulating/javascript/example1.js b/docs/content/guides/rows/row-prepopulating/javascript/example1.js index fdd87cfe470..70eec123b2b 100644 --- a/docs/content/guides/rows/row-prepopulating/javascript/example1.js +++ b/docs/content/guides/rows/row-prepopulating/javascript/example1.js @@ -1,84 +1,18 @@ import Handsontable from 'handsontable/base'; import { registerAllModules } from 'handsontable/registry'; -import { textRenderer } from 'handsontable/renderers/textRenderer'; - -// Register all Handsontable's modules. registerAllModules(); - -const templateValues = ['one', 'two', 'three']; const data = [ - ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], - ['2017', 10, 11, 12, 13], - ['2018', 20, 11, 14, 13], - ['2019', 30, 15, 12, 13], + ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], + ['2017', 10, 11, 12, 13], + ['2018', 20, 11, 14, 13], + ['2019', 30, 15, 12, 13], ]; - -function isEmptyRow(instance, row) { - const rowData = instance.getDataAtRow(row); - - for (let i = 0, ilen = rowData.length; i < ilen; i++) { - if (rowData[i] !== null) { - return false; - } - } - - return true; -} - -const defaultValueRenderer = (instance, td, row, col, prop, value, cellProperties) => { - if (value === null && isEmptyRow(instance, row)) { - value = templateValues[col]; - td.style.color = '#999'; - } else { - td.style.color = ''; - } - - textRenderer(instance, td, row, col, prop, value, cellProperties); -}; - const container = document.querySelector('#example1'); const hot = new Handsontable(container, { - startRows: 8, - startCols: 5, - minSpareRows: 1, - contextMenu: true, - height: 'auto', - licenseKey: 'non-commercial-and-evaluation', - cells() { - return { renderer: defaultValueRenderer }; - }, - beforeChange(changes) { - const instance = hot; - const columns = instance.countCols(); - const rowColumnSeen = {}; - const rowsToFill = {}; - const ch = changes === null ? [] : changes; - - for (let i = 0; i < changes.length; i++) { - // if oldVal is empty - if (ch[i][2] === null && ch[i][3] !== null) { - if (isEmptyRow(instance, ch[i][0])) { - // add this row/col combination to the cache so it will not be overwritten by the template - rowColumnSeen[`${ch[i][0]}/${ch[i][1]}`] = true; - rowsToFill[ch[i][0]] = true; - } - } - } - - for (const r in rowsToFill) { - if (rowsToFill.hasOwnProperty(r)) { - for (let c = 0; c < columns; c++) { - // if it is not provided by user in this change set, take the value from the template - if (!rowColumnSeen[`${r}/${c}`]) { - changes.push([Number(r), c, null, templateValues[c]]); - } - } - } - } - }, - autoWrapRow: true, - autoWrapCol: true, + data, + minSpareRows: 1, + height: 'auto', + licenseKey: 'non-commercial-and-evaluation', + autoWrapRow: true, + autoWrapCol: true, }); - -// or, use `updateData()` to replace `data` without resetting states -hot.loadData(data); diff --git a/docs/content/guides/rows/row-prepopulating/javascript/example1.ts b/docs/content/guides/rows/row-prepopulating/javascript/example1.ts index 8ba8cc638ca..3af7c3b217f 100644 --- a/docs/content/guides/rows/row-prepopulating/javascript/example1.ts +++ b/docs/content/guides/rows/row-prepopulating/javascript/example1.ts @@ -1,86 +1,22 @@ import Handsontable from 'handsontable/base'; import { registerAllModules } from 'handsontable/registry'; -import { BaseRenderer } from 'handsontable/renderers'; -import { textRenderer } from 'handsontable/renderers/textRenderer'; -// Register all Handsontable's modules. registerAllModules(); -const templateValues: string[] = ['one', 'two', 'three']; -const data: (string | number)[][] = [ +const data = [ ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], ['2017', 10, 11, 12, 13], ['2018', 20, 11, 14, 13], ['2019', 30, 15, 12, 13], ]; -function isEmptyRow(instance: Handsontable, row: number) { - const rowData = instance.getDataAtRow(row); - - for (let i = 0, ilen = rowData.length; i < ilen; i++) { - if (rowData[i] !== null) { - return false; - } - } - - return true; -} - -const defaultValueRenderer: BaseRenderer = (instance, td, row, col, prop, value, cellProperties) => { - if (value === null && isEmptyRow(instance, row)) { - value = templateValues[col]; - td.style.color = '#999'; - } else { - td.style.color = ''; - } - - textRenderer(instance, td, row, col, prop, value, cellProperties); -}; - const container = document.querySelector('#example1')!; const hot = new Handsontable(container, { - startRows: 8, - startCols: 5, + data, minSpareRows: 1, - contextMenu: true, height: 'auto', licenseKey: 'non-commercial-and-evaluation', - cells() { - return { renderer: defaultValueRenderer }; - }, - beforeChange(changes) { - const instance = hot; - const columns = instance.countCols(); - const rowColumnSeen = {}; - const rowsToFill = {}; - const ch = changes === null ? [] : (changes as Handsontable.CellChange); - - for (let i = 0; i < changes.length; i++) { - // if oldVal is empty - if (ch[i][2] === null && ch[i][3] !== null) { - if (isEmptyRow(instance, ch[i][0])) { - // add this row/col combination to the cache so it will not be overwritten by the template - rowColumnSeen[`${ch[i][0]}/${ch[i][1]}`] = true; - rowsToFill[ch[i][0]] = true; - } - } - } - - for (const r in rowsToFill) { - if (rowsToFill.hasOwnProperty(r)) { - for (let c = 0; c < columns; c++) { - // if it is not provided by user in this change set, take the value from the template - if (!rowColumnSeen[`${r}/${c}`]) { - changes.push([Number(r), c, null, templateValues[c]]); - } - } - } - } - }, autoWrapRow: true, autoWrapCol: true, }); - -// or, use `updateData()` to replace `data` without resetting states -hot.loadData(data); diff --git a/docs/content/guides/rows/row-prepopulating/javascript/example2.js b/docs/content/guides/rows/row-prepopulating/javascript/example2.js new file mode 100644 index 00000000000..f86517a6c93 --- /dev/null +++ b/docs/content/guides/rows/row-prepopulating/javascript/example2.js @@ -0,0 +1,42 @@ +import Handsontable from 'handsontable/base'; +import { registerAllModules } from 'handsontable/registry'; +import { textRenderer } from 'handsontable/renderers/textRenderer'; +registerAllModules(); +const templateValues = ['one', 'two', 'three']; +const data = [ + ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], + ['2017', 10, 11, 12, 13], + ['2018', 20, 11, 14, 13], + ['2019', 30, 15, 12, 13], +]; +function isEmptyRow(instance, row) { + const rowData = instance.getDataAtRow(row); + for (let i = 0, ilen = rowData.length; i < ilen; i++) { + if (rowData[i] !== null) { + return false; + } + } + return true; +} +const defaultValueRenderer = (instance, td, row, col, prop, value, cellProperties) => { + if (value === null && isEmptyRow(instance, row)) { + value = templateValues[col]; + td.style.color = '#999'; + } + else { + td.style.color = ''; + } + textRenderer(instance, td, row, col, prop, value, cellProperties); +}; +const container = document.querySelector('#example2'); +const hot = new Handsontable(container, { + data, + minSpareRows: 1, + height: 'auto', + licenseKey: 'non-commercial-and-evaluation', + cells() { + return { renderer: defaultValueRenderer }; + }, + autoWrapRow: true, + autoWrapCol: true, +}); diff --git a/docs/content/guides/rows/row-prepopulating/javascript/example2.ts b/docs/content/guides/rows/row-prepopulating/javascript/example2.ts new file mode 100644 index 00000000000..c35b4fd604e --- /dev/null +++ b/docs/content/guides/rows/row-prepopulating/javascript/example2.ts @@ -0,0 +1,51 @@ +import Handsontable from 'handsontable/base'; +import { registerAllModules } from 'handsontable/registry'; +import { BaseRenderer } from 'handsontable/renderers'; +import { textRenderer } from 'handsontable/renderers/textRenderer'; + +registerAllModules(); + +const templateValues = ['one', 'two', 'three']; +const data = [ + ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], + ['2017', 10, 11, 12, 13], + ['2018', 20, 11, 14, 13], + ['2019', 30, 15, 12, 13], +]; + +function isEmptyRow(instance: Handsontable, row: number) { + const rowData = instance.getDataAtRow(row); + + for (let i = 0, ilen = rowData.length; i < ilen; i++) { + if (rowData[i] !== null) { + return false; + } + } + + return true; +} + +const defaultValueRenderer: BaseRenderer = (instance, td, row, col, prop, value, cellProperties) => { + if (value === null && isEmptyRow(instance, row)) { + value = templateValues[col]; + td.style.color = '#999'; + } else { + td.style.color = ''; + } + + textRenderer(instance, td, row, col, prop, value, cellProperties); +}; + +const container = document.querySelector('#example2')!; + +const hot = new Handsontable(container, { + data, + minSpareRows: 1, + height: 'auto', + licenseKey: 'non-commercial-and-evaluation', + cells() { + return { renderer: defaultValueRenderer }; + }, + autoWrapRow: true, + autoWrapCol: true, +}); diff --git a/docs/content/guides/rows/row-prepopulating/javascript/example3.js b/docs/content/guides/rows/row-prepopulating/javascript/example3.js new file mode 100644 index 00000000000..f03d7cf3526 --- /dev/null +++ b/docs/content/guides/rows/row-prepopulating/javascript/example3.js @@ -0,0 +1,84 @@ +import Handsontable from 'handsontable/base'; +import { registerAllModules } from 'handsontable/registry'; +import { textRenderer } from 'handsontable/renderers/textRenderer'; + +// Register all Handsontable's modules. +registerAllModules(); + +const templateValues = ['one', 'two', 'three']; +const data = [ + ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], + ['2017', 10, 11, 12, 13], + ['2018', 20, 11, 14, 13], + ['2019', 30, 15, 12, 13], +]; + +function isEmptyRow(instance, row) { + const rowData = instance.getDataAtRow(row); + + for (let i = 0, ilen = rowData.length; i < ilen; i++) { + if (rowData[i] !== null) { + return false; + } + } + + return true; +} + +const defaultValueRenderer = (instance, td, row, col, prop, value, cellProperties) => { + if (value === null && isEmptyRow(instance, row)) { + value = templateValues[col]; + td.style.color = '#999'; + } else { + td.style.color = ''; + } + + textRenderer(instance, td, row, col, prop, value, cellProperties); +}; + +const container = document.querySelector('#example3'); +const hot = new Handsontable(container, { + startRows: 8, + startCols: 5, + minSpareRows: 1, + contextMenu: true, + height: 'auto', + licenseKey: 'non-commercial-and-evaluation', + cells() { + return { renderer: defaultValueRenderer }; + }, + beforeChange(changes) { + const instance = hot; + const columns = instance.countCols(); + const rowColumnSeen = {}; + const rowsToFill = {}; + const ch = changes === null ? [] : changes; + + for (let i = 0; i < changes.length; i++) { + // if oldVal is empty + if (ch[i][2] === null && ch[i][3] !== null) { + if (isEmptyRow(instance, ch[i][0])) { + // add this row/col combination to the cache so it will not be overwritten by the template + rowColumnSeen[`${ch[i][0]}/${ch[i][1]}`] = true; + rowsToFill[ch[i][0]] = true; + } + } + } + + for (const r in rowsToFill) { + if (rowsToFill.hasOwnProperty(r)) { + for (let c = 0; c < columns; c++) { + // if it is not provided by user in this change set, take the value from the template + if (!rowColumnSeen[`${r}/${c}`]) { + changes.push([Number(r), c, null, templateValues[c]]); + } + } + } + } + }, + autoWrapRow: true, + autoWrapCol: true, +}); + +// or, use `updateData()` to replace `data` without resetting states +hot.loadData(data); diff --git a/docs/content/guides/rows/row-prepopulating/javascript/example3.ts b/docs/content/guides/rows/row-prepopulating/javascript/example3.ts new file mode 100644 index 00000000000..04cd05ee69f --- /dev/null +++ b/docs/content/guides/rows/row-prepopulating/javascript/example3.ts @@ -0,0 +1,86 @@ +import Handsontable from 'handsontable/base'; +import { registerAllModules } from 'handsontable/registry'; +import { BaseRenderer } from 'handsontable/renderers'; +import { textRenderer } from 'handsontable/renderers/textRenderer'; + +// Register all Handsontable's modules. +registerAllModules(); + +const templateValues: string[] = ['one', 'two', 'three']; +const data: (string | number)[][] = [ + ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], + ['2017', 10, 11, 12, 13], + ['2018', 20, 11, 14, 13], + ['2019', 30, 15, 12, 13], +]; + +function isEmptyRow(instance: Handsontable, row: number) { + const rowData = instance.getDataAtRow(row); + + for (let i = 0, ilen = rowData.length; i < ilen; i++) { + if (rowData[i] !== null) { + return false; + } + } + + return true; +} + +const defaultValueRenderer: BaseRenderer = (instance, td, row, col, prop, value, cellProperties) => { + if (value === null && isEmptyRow(instance, row)) { + value = templateValues[col]; + td.style.color = '#999'; + } else { + td.style.color = ''; + } + + textRenderer(instance, td, row, col, prop, value, cellProperties); +}; + +const container = document.querySelector('#example3')!; + +const hot = new Handsontable(container, { + startRows: 8, + startCols: 5, + minSpareRows: 1, + contextMenu: true, + height: 'auto', + licenseKey: 'non-commercial-and-evaluation', + cells() { + return { renderer: defaultValueRenderer }; + }, + beforeChange(changes) { + const instance = hot; + const columns = instance.countCols(); + const rowColumnSeen = {}; + const rowsToFill = {}; + const ch = changes === null ? [] : (changes as Handsontable.CellChange); + + for (let i = 0; i < changes.length; i++) { + // if oldVal is empty + if (ch[i][2] === null && ch[i][3] !== null) { + if (isEmptyRow(instance, ch[i][0])) { + // add this row/col combination to the cache so it will not be overwritten by the template + rowColumnSeen[`${ch[i][0]}/${ch[i][1]}`] = true; + rowsToFill[ch[i][0]] = true; + } + } + } + + for (const r in rowsToFill) { + if (rowsToFill.hasOwnProperty(r)) { + for (let c = 0; c < columns; c++) { + // if it is not provided by user in this change set, take the value from the template + if (!rowColumnSeen[`${r}/${c}`]) { + changes.push([Number(r), c, null, templateValues[c]]); + } + } + } + } + }, + autoWrapRow: true, + autoWrapCol: true, +}); + +// or, use `updateData()` to replace `data` without resetting states +hot.loadData(data); diff --git a/docs/content/guides/rows/row-prepopulating/react/example1.jsx b/docs/content/guides/rows/row-prepopulating/react/example1.jsx index 0a83677f90d..5e6d0f5244b 100644 --- a/docs/content/guides/rows/row-prepopulating/react/example1.jsx +++ b/docs/content/guides/rows/row-prepopulating/react/example1.jsx @@ -1,98 +1,25 @@ -import { useRef } from 'react'; import { HotTable } from '@handsontable/react-wrapper'; import { registerAllModules } from 'handsontable/registry'; -import { textRenderer } from 'handsontable/renderers/textRenderer'; -// register Handsontable's modules registerAllModules(); -const ExampleComponent = () => { - const hotRef = useRef(null); - const templateValues = ['one', 'two', 'three']; - const data = [ - ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], - ['2017', 10, 11, 12, 13], - ['2018', 20, 11, 14, 13], - ['2019', 30, 15, 12, 13], - ]; - - function isEmptyRow(instance, row) { - const rowData = instance.getDataAtRow(row); - - for (let i = 0, ilen = rowData.length; i < ilen; i++) { - if (rowData[i] !== null) { - return false; - } - } - - return true; - } - - function defaultValueRenderer(instance, td, row, col) { - const args = arguments; - - if (args[5] === null && isEmptyRow(instance, row)) { - args[5] = templateValues[col]; - td.style.color = '#999'; - } else { - td.style.color = ''; - } - - textRenderer.apply(this, args); - } +const data = [ + ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], + ['2017', 10, 11, 12, 13], + ['2018', 20, 11, 14, 13], + ['2019', 30, 15, 12, 13], +]; +const ExampleComponent = () => { return ( - <> - - + ); }; diff --git a/docs/content/guides/rows/row-prepopulating/react/example1.tsx b/docs/content/guides/rows/row-prepopulating/react/example1.tsx index 75b2105d2ae..5e6d0f5244b 100644 --- a/docs/content/guides/rows/row-prepopulating/react/example1.tsx +++ b/docs/content/guides/rows/row-prepopulating/react/example1.tsx @@ -1,107 +1,25 @@ -import { useRef } from 'react'; -import { HotTable, HotTableRef } from '@handsontable/react-wrapper'; +import { HotTable } from '@handsontable/react-wrapper'; import { registerAllModules } from 'handsontable/registry'; -import { textRenderer } from 'handsontable/renderers/textRenderer'; -import Handsontable from 'handsontable/base'; -import { CellChange } from 'handsontable/common'; -// register Handsontable's modules registerAllModules(); -const ExampleComponent = () => { - const hotRef = useRef(null); - - const templateValues = ['one', 'two', 'three']; - const data = [ - ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], - ['2017', 10, 11, 12, 13], - ['2018', 20, 11, 14, 13], - ['2019', 30, 15, 12, 13], - ]; - - function isEmptyRow(instance: Handsontable, row: number) { - const rowData = instance.getDataAtRow(row); - - for (let i = 0, ilen = rowData.length; i < ilen; i++) { - if (rowData[i] !== null) { - return false; - } - } - - return true; - } - - function defaultValueRenderer( - this: Handsontable, - instance: Handsontable, - td: HTMLTableCellElement, - row: number, - col: number - ) { - const args = arguments; - - if (args[5] === null && isEmptyRow(instance, row)) { - args[5] = templateValues[col]; - td.style.color = '#999'; - } else { - td.style.color = ''; - } - - textRenderer.apply(this, args as any); - } +const data = [ + ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], + ['2017', 10, 11, 12, 13], + ['2018', 20, 11, 14, 13], + ['2019', 30, 15, 12, 13], +]; +const ExampleComponent = () => { return ( - <> - - + ); }; diff --git a/docs/content/guides/rows/row-prepopulating/react/example2.jsx b/docs/content/guides/rows/row-prepopulating/react/example2.jsx new file mode 100644 index 00000000000..e78cc261295 --- /dev/null +++ b/docs/content/guides/rows/row-prepopulating/react/example2.jsx @@ -0,0 +1,54 @@ +import { HotTable } from '@handsontable/react-wrapper'; +import { registerAllModules } from 'handsontable/registry'; +import { textRenderer } from 'handsontable/renderers/textRenderer'; + +registerAllModules(); + +const templateValues = ['one', 'two', 'three']; +const data = [ + ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], + ['2017', 10, 11, 12, 13], + ['2018', 20, 11, 14, 13], + ['2019', 30, 15, 12, 13], +]; + +const ExampleComponent = () => { + function isEmptyRow(instance, row) { + const rowData = instance.getDataAtRow(row); + + for (let i = 0, ilen = rowData.length; i < ilen; i++) { + if (rowData[i] !== null) { + return false; + } + } + + return true; + } + + function defaultValueRenderer(instance, td, row, col) { + const args = arguments; + + if (args[5] === null && isEmptyRow(instance, row)) { + args[5] = templateValues[col]; + td.style.color = '#999'; + } else { + td.style.color = ''; + } + + textRenderer.apply(this, args); + } + + return ( + ({ renderer: defaultValueRenderer })} + /> + ); +}; + +export default ExampleComponent; diff --git a/docs/content/guides/rows/row-prepopulating/react/example2.tsx b/docs/content/guides/rows/row-prepopulating/react/example2.tsx new file mode 100644 index 00000000000..304ce1e4cca --- /dev/null +++ b/docs/content/guides/rows/row-prepopulating/react/example2.tsx @@ -0,0 +1,61 @@ +import { HotTable } from '@handsontable/react-wrapper'; +import { registerAllModules } from 'handsontable/registry'; +import { textRenderer } from 'handsontable/renderers/textRenderer'; +import Handsontable from 'handsontable/base'; + +registerAllModules(); + +const templateValues = ['one', 'two', 'three']; +const data = [ + ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], + ['2017', 10, 11, 12, 13], + ['2018', 20, 11, 14, 13], + ['2019', 30, 15, 12, 13], +]; + +const ExampleComponent = () => { + function isEmptyRow(instance: Handsontable, row: number) { + const rowData = instance.getDataAtRow(row); + + for (let i = 0, ilen = rowData.length; i < ilen; i++) { + if (rowData[i] !== null) { + return false; + } + } + + return true; + } + + function defaultValueRenderer( + this: Handsontable, + instance: Handsontable, + td: HTMLTableCellElement, + row: number, + col: number + ) { + const args = arguments; + + if (args[5] === null && isEmptyRow(instance, row)) { + args[5] = templateValues[col]; + td.style.color = '#999'; + } else { + td.style.color = ''; + } + + textRenderer.apply(this, args as any); + } + + return ( + ({ renderer: defaultValueRenderer })} + /> + ); +}; + +export default ExampleComponent; diff --git a/docs/content/guides/rows/row-prepopulating/react/example3.jsx b/docs/content/guides/rows/row-prepopulating/react/example3.jsx new file mode 100644 index 00000000000..0a83677f90d --- /dev/null +++ b/docs/content/guides/rows/row-prepopulating/react/example3.jsx @@ -0,0 +1,99 @@ +import { useRef } from 'react'; +import { HotTable } from '@handsontable/react-wrapper'; +import { registerAllModules } from 'handsontable/registry'; +import { textRenderer } from 'handsontable/renderers/textRenderer'; + +// register Handsontable's modules +registerAllModules(); + +const ExampleComponent = () => { + const hotRef = useRef(null); + const templateValues = ['one', 'two', 'three']; + const data = [ + ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], + ['2017', 10, 11, 12, 13], + ['2018', 20, 11, 14, 13], + ['2019', 30, 15, 12, 13], + ]; + + function isEmptyRow(instance, row) { + const rowData = instance.getDataAtRow(row); + + for (let i = 0, ilen = rowData.length; i < ilen; i++) { + if (rowData[i] !== null) { + return false; + } + } + + return true; + } + + function defaultValueRenderer(instance, td, row, col) { + const args = arguments; + + if (args[5] === null && isEmptyRow(instance, row)) { + args[5] = templateValues[col]; + td.style.color = '#999'; + } else { + td.style.color = ''; + } + + textRenderer.apply(this, args); + } + + return ( + <> + + + ); +}; + +export default ExampleComponent; diff --git a/docs/content/guides/rows/row-prepopulating/react/example3.tsx b/docs/content/guides/rows/row-prepopulating/react/example3.tsx new file mode 100644 index 00000000000..75b2105d2ae --- /dev/null +++ b/docs/content/guides/rows/row-prepopulating/react/example3.tsx @@ -0,0 +1,108 @@ +import { useRef } from 'react'; +import { HotTable, HotTableRef } from '@handsontable/react-wrapper'; +import { registerAllModules } from 'handsontable/registry'; +import { textRenderer } from 'handsontable/renderers/textRenderer'; +import Handsontable from 'handsontable/base'; +import { CellChange } from 'handsontable/common'; + +// register Handsontable's modules +registerAllModules(); + +const ExampleComponent = () => { + const hotRef = useRef(null); + + const templateValues = ['one', 'two', 'three']; + const data = [ + ['', 'Tesla', 'Nissan', 'Toyota', 'Honda'], + ['2017', 10, 11, 12, 13], + ['2018', 20, 11, 14, 13], + ['2019', 30, 15, 12, 13], + ]; + + function isEmptyRow(instance: Handsontable, row: number) { + const rowData = instance.getDataAtRow(row); + + for (let i = 0, ilen = rowData.length; i < ilen; i++) { + if (rowData[i] !== null) { + return false; + } + } + + return true; + } + + function defaultValueRenderer( + this: Handsontable, + instance: Handsontable, + td: HTMLTableCellElement, + row: number, + col: number + ) { + const args = arguments; + + if (args[5] === null && isEmptyRow(instance, row)) { + args[5] = templateValues[col]; + td.style.color = '#999'; + } else { + td.style.color = ''; + } + + textRenderer.apply(this, args as any); + } + + return ( + <> + + + ); +}; + +export default ExampleComponent; diff --git a/docs/content/guides/rows/row-prepopulating/row-prepopulating.md b/docs/content/guides/rows/row-prepopulating/row-prepopulating.md index 1e0ffaafeee..f1156c3c5dd 100644 --- a/docs/content/guides/rows/row-prepopulating/row-prepopulating.md +++ b/docs/content/guides/rows/row-prepopulating/row-prepopulating.md @@ -3,7 +3,7 @@ type: how-to id: 42px61id title: Row pre-populating metaTitle: Row pre-populating - JavaScript Data Grid | Handsontable -description: Populate newly-added rows with predefined template values, using cell renderers. +description: Pre-populate spare rows with default values using minSpareRows, custom placeholder renderers, or auto-filling template values. permalink: /row-prepopulating canonicalUrl: /row-prepopulating tags: @@ -20,13 +20,13 @@ angular: searchCategory: Guides category: Rows --- -Pre-populate new rows with default values when users add rows to the grid. Use the `afterCreateRow` hook to set initial cell values. +Pre-populate new rows with default values when users add rows to the grid. [[toc]] -## Example +## Basic spare rows -The example below shows how cell renderers can be used to populate the template values in empty rows. When a cell in the empty row is edited, the [`beforeChange`](@/api/hooks.md#beforechange) callback fills the row with the template values. +To keep one empty row at the bottom of the grid, set [`minSpareRows`](@/api/options.md#minsparerows) to `1`. ::: only-for javascript @@ -61,6 +61,80 @@ The example below shows how cell renderers can be used to populate the template ::: +## Spare rows with placeholder styling + +To hint what to enter in the spare row, add a custom cell renderer that displays greyed-out placeholder text in empty cells. The renderer checks whether the whole row is empty, then shows a template value in a lighter color. + +::: only-for javascript + +::: example #example2 --js 1 --ts 2 + +@[code](@/content/guides/rows/row-prepopulating/javascript/example2.js) +@[code](@/content/guides/rows/row-prepopulating/javascript/example2.ts) + +::: + +::: + +::: only-for react + +::: example #example2 :react --js 1 --ts 2 + +@[code](@/content/guides/rows/row-prepopulating/react/example2.jsx) +@[code](@/content/guides/rows/row-prepopulating/react/example2.tsx) + +::: + +::: + +::: only-for angular + +::: example #example2 :angular --ts 1 --html 2 + +@[code](@/content/guides/rows/row-prepopulating/angular/example2.ts) +@[code](@/content/guides/rows/row-prepopulating/angular/example2.html) + +::: + +::: + +## Auto-populating with template values + +For full pre-population, use the [`beforeChange`](@/api/hooks.md#beforechange) hook to fill all cells in a spare row with template values the moment the user starts editing. The `isEmptyRow()` helper detects whether the row is untouched, and the hook pushes changes for every column except the one the user is editing. + +::: only-for javascript + +::: example #example3 --js 1 --ts 2 + +@[code](@/content/guides/rows/row-prepopulating/javascript/example3.js) +@[code](@/content/guides/rows/row-prepopulating/javascript/example3.ts) + +::: + +::: + +::: only-for react + +::: example #example3 :react --js 1 --ts 2 + +@[code](@/content/guides/rows/row-prepopulating/react/example3.jsx) +@[code](@/content/guides/rows/row-prepopulating/react/example3.tsx) + +::: + +::: + +::: only-for angular + +::: example #example3 :angular --ts 1 --html 2 + +@[code](@/content/guides/rows/row-prepopulating/angular/example3.ts) +@[code](@/content/guides/rows/row-prepopulating/angular/example3.html) + +::: + +::: + ## Pre-populate from an adjacent row You can copy values from the row above when the user inserts a new row. Use the [`afterCreateRow`](@/api/hooks.md#aftercreaterow) hook to read the source row's data and write it to the newly created row. @@ -106,7 +180,7 @@ const hot = new Handsontable(container, { ## Result -After completing this guide, your grid fills new rows with template values automatically. You can pre-populate rows from static defaults, adjacent row values, or server-fetched data. +Your grid keeps one or more empty rows at the bottom. Depending on the approach, spare rows show greyed-out placeholder text or auto-fill all cells with template values when the user starts editing. ## Related API reference diff --git a/docs/content/recipes/filtering-and-search/external-search-box/angular/example1.ts b/docs/content/recipes/filtering-and-search/external-search-box/angular/example1.ts index 23e21d8b283..aacdd47237d 100644 --- a/docs/content/recipes/filtering-and-search/external-search-box/angular/example1.ts +++ b/docs/content/recipes/filtering-and-search/external-search-box/angular/example1.ts @@ -24,6 +24,7 @@ const data = [ id="external-search-input" type="search" placeholder="Type to highlight matching cells..." + style="min-width: 20rem" (input)="onSearch($event)" />
diff --git a/docs/content/recipes/filtering-and-search/external-search-box/external-search-box.md b/docs/content/recipes/filtering-and-search/external-search-box/external-search-box.md index e2ac03e10de..26fe3a245e4 100644 --- a/docs/content/recipes/filtering-and-search/external-search-box/external-search-box.md +++ b/docs/content/recipes/filtering-and-search/external-search-box/external-search-box.md @@ -26,10 +26,11 @@ In this tutorial, you will add a search input outside Handsontable that highligh ::: only-for javascript -::: example #example1 :hot-recipe --js 1 --ts 2 +::: example #example1 :hot-recipe --js 1 --ts 2 --css 3 @[code](@/content/recipes/filtering-and-search/external-search-box/javascript/example1.js) @[code](@/content/recipes/filtering-and-search/external-search-box/javascript/example1.ts) +@[code](@/content/recipes/filtering-and-search/external-search-box/javascript/example1.css) ::: @@ -37,8 +38,9 @@ In this tutorial, you will add a search input outside Handsontable that highligh ::: only-for react -::: example #example1 :react-advanced --js 1 --ts 2 +::: example #example1 :react-advanced --css 1 --js 2 --ts 3 +@[code](@/content/recipes/filtering-and-search/external-search-box/react/example1.css) @[code](@/content/recipes/filtering-and-search/external-search-box/react/example1.jsx) @[code](@/content/recipes/filtering-and-search/external-search-box/react/example1.tsx) ::: diff --git a/docs/content/recipes/filtering-and-search/external-search-box/javascript/example1.css b/docs/content/recipes/filtering-and-search/external-search-box/javascript/example1.css new file mode 100644 index 00000000000..74123b6d3c8 --- /dev/null +++ b/docs/content/recipes/filtering-and-search/external-search-box/javascript/example1.css @@ -0,0 +1,3 @@ +#external-search-input { + min-width: 20rem; +} diff --git a/docs/content/recipes/filtering-and-search/external-search-box/react/example1.css b/docs/content/recipes/filtering-and-search/external-search-box/react/example1.css new file mode 100644 index 00000000000..74123b6d3c8 --- /dev/null +++ b/docs/content/recipes/filtering-and-search/external-search-box/react/example1.css @@ -0,0 +1,3 @@ +#external-search-input { + min-width: 20rem; +} diff --git a/docs/content/recipes/import-export/import-csv-excel/angular/example1.css b/docs/content/recipes/import-export/import-csv-excel/angular/example1.css index 51ff63673c9..f7ab699f27e 100644 --- a/docs/content/recipes/import-export/import-csv-excel/angular/example1.css +++ b/docs/content/recipes/import-export/import-csv-excel/angular/example1.css @@ -1,25 +1,118 @@ +.import-csv-excel-wrap { + display: flex; + flex-direction: column; + margin: 0 -1rem; +} + .import-dropzone { - border: 2px dashed var(--ht-border-color, #ccc); - border-radius: 8px; - padding: 16px; + padding: 1.5rem 1rem; text-align: center; - margin-bottom: 12px; + border: 0; + border-bottom: 1px solid var(--sl-color-gray-5); + border-radius: 0; + background: transparent; + color: var(--sl-color-gray-2); + font-size: var(--sl-text-xs); + transition: background 0.15s ease; cursor: pointer; } -.import-dropzone--active { - border-color: var(--ht-accent-color, #1a42e8); - background: rgba(26, 66, 232, 0.05); +.import-dropzone p { + margin: 0 0 0.75rem; +} + +.import-dropzone.import-dropzone--active { + background: var(--sl-color-gray-6); } -.import-preview { - border: 1px solid var(--ht-border-color, #e0e0e0); - border-radius: 4px; - padding: 12px; - margin-bottom: 12px; +.import-actions { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.import-file-label { + display: inline-block; + cursor: pointer; +} + +.import-file-label span, +.import-sample-btn { + display: inline-flex; + align-items: center; + padding: 0.375rem 0.75rem; + border: 1px solid var(--sl-color-gray-5); + border-radius: 0; + background: var(--sl-color-gray-6); + color: var(--sl-color-gray-2); + font-family: var(--sl-font); + font-size: var(--sl-text-xs); + font-weight: 500; + line-height: 1.4; + cursor: pointer; + transition: background-color 0.15s, color 0.15s, border-color 0.15s; +} + +.import-file-label:hover span, +.import-sample-btn:hover { + background: var(--sl-color-gray-5); + color: var(--sl-color-white); +} + +.import-file-label input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.import-msg { + margin: 0; + padding: 0.75rem 1rem; + border-radius: 0; + font-size: var(--sl-text-xs); } .import-msg--error { - color: var(--ht-cell-error-color, #c62828); - padding: 8px 0; + border-bottom: 1px solid var(--sl-color-red, #ef4444); + background: var(--sl-color-red-low, rgba(239, 68, 68, 0.12)); + color: var(--sl-color-red, #ef4444); +} + +.import-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2.5rem 1rem; + min-height: 200px; + color: var(--sl-color-gray-2); + text-align: center; +} + +.import-empty-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + margin-bottom: 0.25rem; + border: 1px solid var(--sl-color-gray-5); + color: var(--sl-color-gray-3); +} + +.import-empty-title { + margin: 0; + color: var(--sl-color-white); + font-size: var(--sl-text-sm); + font-weight: 600; +} + +.import-empty-text { + margin: 0; + max-width: 38ch; + font-size: var(--sl-text-xs); + line-height: 1.5; } diff --git a/docs/content/recipes/import-export/import-csv-excel/angular/example1.ts b/docs/content/recipes/import-export/import-csv-excel/angular/example1.ts index 34bbd868d8c..14a9604b92c 100644 --- a/docs/content/recipes/import-export/import-csv-excel/angular/example1.ts +++ b/docs/content/recipes/import-export/import-csv-excel/angular/example1.ts @@ -147,29 +147,18 @@ async function parseFile(file: File): Promise { (dragleave)="onDragLeave()" (drop)="onDrop($event)" > -

Drop a .csv or .xlsx file here, or use the file picker.

- -
- -
- - -
- +

Drop a .csv or .xlsx file here, or pick a source.

+
+ +
@@ -177,19 +166,22 @@ async function parseFile(file: File): Promise {
{{ errorMessage }}
} - @if (showPreview) { -
-

Detected column headers (not loaded yet):

-
    - @for (h of pending?.headers ?? []; track h) { -
  • {{ h }}
  • - } -
- + @if (gridData.length === 0) { +
+ +

No data loaded yet

+

+ Drop a CSV or Excel file above, choose a file, or load the sample data to populate the table. +

+ } @else { + } - -
`, }) @@ -198,11 +190,9 @@ export class AppComponent { isDragOver = false; errorMessage = ''; - showPreview = false; - pending: ParsedPayload | null = null; gridData: Record[] = []; - sampleCsv = `Product,Category,In stock,Price + private readonly SAMPLE_CSV = `Product,Category,In stock,Price Widget A,Hardware,true,19.99 Widget B,Hardware,false,24.5 Service Pack,Services,true,0`; @@ -242,34 +232,16 @@ Service Pack,Services,true,0`; input.value = ''; } - async parseSampleCsv(): Promise { + async loadSampleData(): Promise { this.errorMessage = ''; try { - const payload = parseCsvText(this.sampleCsv); - this.setPending(payload); + const payload = parseCsvText(this.SAMPLE_CSV); + this.loadIntoGrid(payload); } catch (e) { - this.clearPendingPreview(); this.errorMessage = e instanceof Error ? e.message : String(e); } } - applyToGrid(): void { - this.errorMessage = ''; - if (!this.pending) { - this.errorMessage = 'Nothing to load. Import a file first.'; - return; - } - const { headers, rows } = this.pending; - this.gridSettings = { - ...this.gridSettings, - colHeaders: headers, - columns: this.columnsFromHeaders(headers), - }; - this.gridData = [...rows]; - this.showPreview = false; - this.pending = null; - } - private async handleFile(file: File): Promise { this.errorMessage = ''; if (file.size === 0) { @@ -278,30 +250,25 @@ Service Pack,Services,true,0`; } try { const payload = await parseFile(file); - this.setPending(payload); + this.loadIntoGrid(payload); } catch (e) { - this.clearPendingPreview(); this.errorMessage = e instanceof Error ? e.message : String(e); } } - private setPending(payload: ParsedPayload): void { - this.pending = payload; - this.errorMessage = ''; - this.showPreview = true; - } - - private clearPendingPreview(): void { - this.pending = null; - this.showPreview = false; + private loadIntoGrid(payload: ParsedPayload): void { + const { headers, rows } = payload; + this.gridSettings = { + ...this.gridSettings, + colHeaders: headers, + columns: this.columnsFromHeaders(headers, rows), + }; + this.gridData = [...rows]; } - private columnsFromHeaders(headers: string[]): GridSettings['columns'] { - if (!this.pending) { - return headers.map((data) => ({ data, type: 'text' })); - } + private columnsFromHeaders(headers: string[], rows: Record[]): GridSettings['columns'] { return headers.map((data) => { - const values = (this.pending?.rows ?? []) + const values = rows .map((row) => row[data]) .filter((v) => v !== null); if (values.length > 0 && values.every((v) => typeof v === 'number')) { diff --git a/docs/content/recipes/import-export/import-csv-excel/import-csv-excel.md b/docs/content/recipes/import-export/import-csv-excel/import-csv-excel.md index d1bcd7d0236..47be1c736d7 100644 --- a/docs/content/recipes/import-export/import-csv-excel/import-csv-excel.md +++ b/docs/content/recipes/import-export/import-csv-excel/import-csv-excel.md @@ -95,18 +95,22 @@ Use `accept` on the file input and check `file.name` to route `.csv` and `.xlsx` ```html
-

Drop a .csv or .xlsx file here, or use the file picker.

- +

Drop a .csv or .xlsx file here, or pick a source.

+
+ + +
``` **What's happening:** - The `accept` attribute restricts the system file picker to `.csv` and `.xlsx`. This is a hint to the browser only -- you must validate the extension in JavaScript as well. - The real `` is visually hidden (positioned off-screen with `opacity: 0`) and activated via the wrapping `
- -
- - -
- +

Drop a .csv or .xlsx file here, or pick a source.

+
+ +
- diff --git a/docs/content/recipes/import-export/import-csv-excel/javascript/example1.js b/docs/content/recipes/import-export/import-csv-excel/javascript/example1.js index f7a2449e658..66df0b52c5d 100644 --- a/docs/content/recipes/import-export/import-csv-excel/javascript/example1.js +++ b/docs/content/recipes/import-export/import-csv-excel/javascript/example1.js @@ -193,21 +193,13 @@ async function parseFile(file) { } throw new Error('Unsupported file type. Use a .csv or .xlsx file.'); } -let pending = null; -function renderHeaderPreview(listEl, headers) { - listEl.innerHTML = ''; - for (const h of headers) { - const li = document.createElement('li'); - li.textContent = h; - listEl.appendChild(li); - } -} -function columnsFromHeaders(headers) { - if (!pending) { - return headers.map((data) => ({ data, type: 'text' })); - } +const SAMPLE_CSV = `Product,Category,In stock,Price +Widget A,Hardware,true,19.99 +Widget B,Hardware,false,24.5 +Service Pack,Services,true,0`; +function columnsFromHeaders(headers, rows) { return headers.map((data) => { - const values = pending?.rows + const values = rows .map((row) => row[data]) .filter((v) => v !== null); if (values.length > 0 && values.every((v) => typeof v === 'number')) { @@ -220,36 +212,34 @@ function columnsFromHeaders(headers) { }); } const gridContainer = document.querySelector('#example1'); +const emptyEl = document.querySelector('#import-empty'); const errEl = document.querySelector('#import-error'); -const previewEl = document.querySelector('#import-preview'); -const headerListEl = document.querySelector('#import-header-list'); -const applyBtn = document.querySelector('#import-apply'); const fileInput = document.querySelector('#import-file'); const dropzone = document.querySelector('#import-dropzone'); -const sampleTa = document.querySelector('#import-sample-csv'); -const sampleBtn = document.querySelector('#import-parse-sample'); -const initialSettings = { - data: [], - columns: [], - colHeaders: [], - rowHeaders: true, - height: 'auto', - width: '100%', - licenseKey: 'non-commercial-and-evaluation', -}; -const hot = new Handsontable(gridContainer, initialSettings); -function clearPendingPreview() { - pending = null; - if (previewEl) { - previewEl.hidden = true; +const sampleBtn = document.querySelector('#import-load-sample'); +let hot = null; +function loadIntoGrid({ headers, rows }) { + const columns = columnsFromHeaders(headers, rows); + if (!hot) { + if (emptyEl) { + emptyEl.hidden = true; + } + if (gridContainer) { + gridContainer.hidden = false; + } + hot = new Handsontable(gridContainer, { + data: rows, + columns, + colHeaders: headers, + rowHeaders: true, + height: 'auto', + width: '100%', + licenseKey: 'non-commercial-and-evaluation', + }); } -} -function setPending(payload) { - pending = payload; - clearError(errEl); - if (headerListEl && previewEl) { - renderHeaderPreview(headerListEl, payload.headers); - previewEl.hidden = false; + else { + hot.updateSettings({ colHeaders: headers, columns }); + hot.loadData(rows); } } async function handleFile(file) { @@ -263,30 +253,12 @@ async function handleFile(file) { } try { const payload = await parseFile(file); - setPending(payload); + loadIntoGrid(payload); } catch (e) { - clearPendingPreview(); showError(errEl, e instanceof Error ? e.message : String(e)); } } -applyBtn?.addEventListener('click', () => { - clearError(errEl); - if (!pending) { - showError(errEl, 'Nothing to load. Import a file first.'); - return; - } - const { headers, rows } = pending; - hot.updateSettings({ - colHeaders: headers, - columns: columnsFromHeaders(headers), - }); - hot.loadData(rows); - if (previewEl) { - previewEl.hidden = true; - } - pending = null; -}); fileInput?.addEventListener('change', () => { const f = fileInput.files?.[0]; handleFile(f); @@ -309,12 +281,10 @@ sampleBtn?.addEventListener('click', async () => { clearError(errEl); try { const PapaRef = await ensurePapa(); - const text = sampleTa?.value ?? ''; - const payload = parseCsvText(text, PapaRef); - setPending(payload); + const payload = parseCsvText(SAMPLE_CSV, PapaRef); + loadIntoGrid(payload); } catch (e) { - clearPendingPreview(); showError(errEl, e instanceof Error ? e.message : String(e)); } }); diff --git a/docs/content/recipes/import-export/import-csv-excel/javascript/example1.ts b/docs/content/recipes/import-export/import-csv-excel/javascript/example1.ts index c1a8bc0e525..6600f63e720 100644 --- a/docs/content/recipes/import-export/import-csv-excel/javascript/example1.ts +++ b/docs/content/recipes/import-export/import-csv-excel/javascript/example1.ts @@ -282,25 +282,14 @@ async function parseFile(file: File): Promise { throw new Error('Unsupported file type. Use a .csv or .xlsx file.'); } -let pending: ParsedPayload | null = null; - -function renderHeaderPreview(listEl: HTMLElement, headers: string[]): void { - listEl.innerHTML = ''; - for (const h of headers) { - const li = document.createElement('li'); - - li.textContent = h; - listEl.appendChild(li); - } -} - -function columnsFromHeaders(headers: string[]): GridSettings['columns'] { - if (!pending) { - return headers.map((data) => ({ data, type: 'text' as const })); - } +const SAMPLE_CSV = `Product,Category,In stock,Price +Widget A,Hardware,true,19.99 +Widget B,Hardware,false,24.5 +Service Pack,Services,true,0`; +function columnsFromHeaders(headers: string[], rows: ParsedRow[]): GridSettings['columns'] { return headers.map((data) => { - const values = pending?.rows + const values = rows .map((row) => row[data]) .filter((v): v is string | number | boolean => v !== null); @@ -317,42 +306,34 @@ function columnsFromHeaders(headers: string[]): GridSettings['columns'] { } const gridContainer = document.querySelector('#example1')!; +const emptyEl = document.querySelector('#import-empty'); const errEl = document.querySelector('#import-error'); -const previewEl = document.querySelector('#import-preview'); -const headerListEl = document.querySelector('#import-header-list'); -const applyBtn = document.querySelector('#import-apply'); const fileInput = document.querySelector('#import-file'); const dropzone = document.querySelector('#import-dropzone'); -const sampleTa = document.querySelector('#import-sample-csv'); -const sampleBtn = document.querySelector('#import-parse-sample'); - -const initialSettings: GridSettings = { - data: [], - columns: [], - colHeaders: [], - rowHeaders: true, - height: 'auto', - width: '100%', - licenseKey: 'non-commercial-and-evaluation', -}; +const sampleBtn = document.querySelector('#import-load-sample'); -const hot = new Handsontable(gridContainer, initialSettings); +let hot: Handsontable | null = null; -function clearPendingPreview(): void { - pending = null; +function loadIntoGrid({ headers, rows }: ParsedPayload): void { + const columns = columnsFromHeaders(headers, rows); - if (previewEl) { - previewEl.hidden = true; - } -} - -function setPending(payload: ParsedPayload): void { - pending = payload; - clearError(errEl); - - if (headerListEl && previewEl) { - renderHeaderPreview(headerListEl, payload.headers); - previewEl.hidden = false; + if (!hot) { + if (emptyEl) { + emptyEl.hidden = true; + } + gridContainer.hidden = false; + hot = new Handsontable(gridContainer, { + data: rows, + columns, + colHeaders: headers, + rowHeaders: true, + height: 'auto', + width: '100%', + licenseKey: 'non-commercial-and-evaluation', + }); + } else { + hot.updateSettings({ colHeaders: headers, columns }); + hot.loadData(rows); } } @@ -372,38 +353,12 @@ async function handleFile(file: File | null | undefined): Promise { try { const payload = await parseFile(file); - setPending(payload); + loadIntoGrid(payload); } catch (e) { - clearPendingPreview(); - showError(errEl, e instanceof Error ? e.message : String(e)); } } -applyBtn?.addEventListener('click', () => { - clearError(errEl); - - if (!pending) { - showError(errEl, 'Nothing to load. Import a file first.'); - - return; - } - - const { headers, rows } = pending; - - hot.updateSettings({ - colHeaders: headers, - columns: columnsFromHeaders(headers), - }); - hot.loadData(rows); - - if (previewEl) { - previewEl.hidden = true; - } - - pending = null; -}); - fileInput?.addEventListener('change', () => { const f = fileInput.files?.[0]; @@ -433,12 +388,10 @@ sampleBtn?.addEventListener('click', async () => { try { const PapaRef = await ensurePapa(); - const text = sampleTa?.value ?? ''; - const payload = parseCsvText(text, PapaRef); + const payload = parseCsvText(SAMPLE_CSV, PapaRef); - setPending(payload); + loadIntoGrid(payload); } catch (e) { - clearPendingPreview(); showError(errEl, e instanceof Error ? e.message : String(e)); } }); diff --git a/docs/content/recipes/import-export/import-csv-excel/react/example1.css b/docs/content/recipes/import-export/import-csv-excel/react/example1.css index 286a715eec8..37cedaaae88 100644 --- a/docs/content/recipes/import-export/import-csv-excel/react/example1.css +++ b/docs/content/recipes/import-export/import-csv-excel/react/example1.css @@ -1,37 +1,61 @@ .import-csv-excel-wrap { display: flex; flex-direction: column; - gap: 12px; - margin-bottom: 12px; + margin: 0 -1rem; } .import-dropzone { - border: 2px dashed var(--sl-color-gray-4, #ccc); - border-radius: 8px; - padding: 16px; + padding: 1.5rem 1rem; text-align: center; - background: var(--sl-color-bg-inline-code, rgba(0, 0, 0, 0.04)); - transition: border-color 0.15s ease, background 0.15s ease; + border: 0; + border-bottom: 1px solid var(--sl-color-gray-5); + border-radius: 0; + background: transparent; + color: var(--sl-color-gray-2); + font-size: var(--sl-text-xs); + transition: background 0.15s ease; +} + +.import-dropzone p { + margin: 0 0 0.75rem; } .import-dropzone.import-dropzone--active { - border-color: var(--sl-color-accent, #3b82f6); - background: var(--sl-color-accent-low, rgba(59, 130, 246, 0.08)); + background: var(--sl-color-gray-6); +} + +.import-actions { + display: inline-flex; + align-items: center; + gap: 0.5rem; } .import-file-label { display: inline-block; - margin-top: 8px; cursor: pointer; } -.import-file-label span { - display: inline-block; - padding: 6px 14px; - border-radius: 6px; - background: var(--sl-color-accent, #3b82f6); - color: var(--sl-color-black, #fff); - font-size: 0.875rem; +.import-file-label span, +.import-sample-btn { + display: inline-flex; + align-items: center; + padding: 0.375rem 0.75rem; + border: 1px solid var(--sl-color-gray-5); + border-radius: 0; + background: var(--sl-color-gray-6); + color: var(--sl-color-gray-2); + font-family: var(--sl-font); + font-size: var(--sl-text-xs); + font-weight: 500; + line-height: 1.4; + cursor: pointer; + transition: background-color 0.15s, color 0.15s, border-color 0.15s; +} + +.import-file-label:hover span, +.import-sample-btn:hover { + background: var(--sl-color-gray-5); + color: var(--sl-color-white); } .import-file-label input { @@ -42,70 +66,52 @@ pointer-events: none; } -.import-sample-block label { - display: block; - font-size: 0.875rem; - margin-bottom: 6px; -} - -.import-sample-block textarea { - width: 100%; - box-sizing: border-box; - font-family: ui-monospace, monospace; - font-size: 0.8rem; - padding: 8px; - border-radius: 6px; - border: 1px solid var(--sl-color-gray-4, #ccc); - resize: vertical; -} - -.import-sample-actions { - margin-top: 8px; -} - -.import-sample-actions button, -.import-apply-btn { - padding: 6px 14px; - border-radius: 6px; - border: 1px solid var(--sl-color-gray-4, #ccc); - background: var(--sl-color-bg, #fff); - cursor: pointer; - font-size: 0.875rem; -} - -.import-apply-btn { - margin-top: 8px; - border-color: var(--sl-color-accent, #3b82f6); - color: var(--sl-color-accent, #3b82f6); -} - .import-msg { - padding: 10px 12px; - border-radius: 6px; - font-size: 0.875rem; + margin: 0; + padding: 0.75rem 1rem; + border-radius: 0; + font-size: var(--sl-text-xs); } .import-msg--error { + border-bottom: 1px solid var(--sl-color-red, #ef4444); background: var(--sl-color-red-low, rgba(239, 68, 68, 0.12)); - border: 1px solid var(--sl-color-red, #ef4444); - color: var(--sl-color-red-high, #b91c1c); + color: var(--sl-color-red, #ef4444); } -.import-preview { - padding: 12px; - border-radius: 8px; - border: 1px solid var(--sl-color-gray-4, #ccc); - background: var(--sl-color-bg-inline-code, rgba(0, 0, 0, 0.03)); +.import-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2.5rem 1rem; + min-height: 200px; + color: var(--sl-color-gray-2); + text-align: center; } -.import-preview-title { - margin: 0 0 8px; - font-size: 0.875rem; +.import-empty-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + margin-bottom: 0.25rem; + border: 1px solid var(--sl-color-gray-5); + color: var(--sl-color-gray-3); +} + +.import-empty-title { + margin: 0; + color: var(--sl-color-white); + font-size: var(--sl-text-sm); font-weight: 600; } -.import-header-list { +.import-empty-text { margin: 0; - padding-left: 1.25rem; - font-size: 0.875rem; + max-width: 38ch; + font-size: var(--sl-text-xs); + line-height: 1.5; } diff --git a/docs/content/recipes/import-export/import-csv-excel/react/example1.jsx b/docs/content/recipes/import-export/import-csv-excel/react/example1.jsx index 830e6d2dc4d..d4e7a23157d 100644 --- a/docs/content/recipes/import-export/import-csv-excel/react/example1.jsx +++ b/docs/content/recipes/import-export/import-csv-excel/react/example1.jsx @@ -243,13 +243,9 @@ async function parseFile(file) { throw new Error('Unsupported file type. Use a .csv or .xlsx file.'); } -function columnsFromHeaders(headers, pendingRows) { - if (!pendingRows) { - return headers.map((data) => ({ data, type: 'text' })); - } - +function columnsFromHeaders(headers, rows) { return headers.map((data) => { - const values = pendingRows + const values = rows .map((row) => row[data]) .filter((v) => v !== null); @@ -269,33 +265,24 @@ function columnsFromHeaders(headers, pendingRows) { const SAMPLE_CSV = `Product,Category,In stock,Price Widget A,Hardware,true,19.99 Widget B,Hardware,false,24.5 -Service Pack,Services,true,0 -`; +Service Pack,Services,true,0`; /* end:skip-in-preview */ const ExampleComponent = () => { - const [pending, setPendingState] = useState(null); const [errorMessage, setErrorMessage] = useState(''); - const [showPreview, setShowPreview] = useState(false); - const [sampleCsv, setSampleCsv] = useState(SAMPLE_CSV); const [dropzoneActive, setDropzoneActive] = useState(false); const [gridData, setGridData] = useState([]); const [gridColHeaders, setGridColHeaders] = useState([]); const [gridColumns, setGridColumns] = useState([]); - const clearPendingPreview = () => { - setPendingState(null); - setShowPreview(false); - }; - - const handleParsed = (payload) => { + const loadIntoGrid = ({ headers, rows }) => { setErrorMessage(''); - setPendingState(payload); - setShowPreview(true); + setGridColHeaders(headers); + setGridColumns(columnsFromHeaders(headers, rows)); + setGridData(rows); }; const handleError = (e) => { - clearPendingPreview(); setErrorMessage(e instanceof Error ? e.message : String(e)); }; @@ -315,7 +302,7 @@ const ExampleComponent = () => { try { const payload = await parseFile(file); - handleParsed(payload); + loadIntoGrid(payload); } catch (e) { handleError(e); } @@ -345,37 +332,19 @@ const ExampleComponent = () => { handleFile(f); }; - const handleParseSample = async () => { + const handleLoadSample = async () => { setErrorMessage(''); try { const PapaRef = await ensurePapa(); - const payload = parseCsvText(sampleCsv, PapaRef); + const payload = parseCsvText(SAMPLE_CSV, PapaRef); - handleParsed(payload); + loadIntoGrid(payload); } catch (e) { handleError(e); } }; - const handleApply = () => { - setErrorMessage(''); - - if (!pending) { - setErrorMessage('Nothing to load. Import a file first.'); - - return; - } - - const { headers, rows } = pending; - - setGridColHeaders(headers); - setGridColumns(columnsFromHeaders(headers, rows)); - setGridData(rows); - setPendingState(null); - setShowPreview(false); - }; - return (
{ onDrop={handleDrop} >

- Drop a .csv or .xlsx file here, or use the file picker. + Drop a .csv or .xlsx file here, or pick a source.

- -
- -
- -