Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
93e99d3
feat: add opt-in GridStack renderer with shared contracts
daniil-filippov-dev Oct 23, 2026
e850b91
fix: bound nested widget autoheight to its container
daniil-filippov-dev Oct 23, 2026
63c2624
fix: restore opt-in isolation of widget renderer updates
daniil-filippov-dev Oct 23, 2026
7fe8b64
fix: refresh autoheight bounds after controlled container changes
daniil-filippov-dev Oct 23, 2026
4637c0e
test: pace legacy scale continuation across browser frames
daniil-filippov-dev Oct 23, 2026
a3dac6d
fix: preserve controlled GridStack revisions
daniil-filippov-dev Oct 23, 2026
46bbbc2
test: merge shared flat contracts into both adapters
daniil-filippov-dev Oct 23, 2026
beb49ee
chore: align feature with final preparation head
daniil-filippov-dev Oct 23, 2026
4954edc
fix: keep nested nowrap budgets in local grid units
daniil-filippov-dev Oct 23, 2026
baab99b
fix: derive nowrap constraints from container ownership
daniil-filippov-dev Oct 23, 2026
a307ad3
fix: bind plugin drag handles after portal rendering
daniil-filippov-dev Oct 23, 2026
0f34947
fix: preserve resize portals when binding custom drag handles
daniil-filippov-dev Oct 23, 2026
8bc5410
fix(GridStack): measure nested grids using local content width
daniil-filippov-dev Oct 23, 2026
33ee843
fix(GridStack): preserve fractional geometry during non-resizing edits
daniil-filippov-dev Oct 23, 2026
259688e
fix: retain saved widths during nowrap moves
daniil-filippov-dev Oct 23, 2026
6c00f26
fix: keep root styling out of nested drop zones
daniil-filippov-dev Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,33 @@ jobs:
- name: Unit Tests
run: npm run test

native_tests:
name: Native browser contracts (Chromium)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install Packages
run: npm ci
- name: Install Chromium
run: npx playwright install --with-deps chromium
- name: Native browser contracts
run: npm run test:browser -- --project=chromium
- name: Upload failure artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: native-browser-failures
path: |
playwright-report/
test-results/
retention-days: 7

# Validate the lower edge of the React peer range. Runs only on
# release-please PRs so regular PR CI stays fast; failure here blocks
# the release before it can be tagged and published.
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ node_modules
# Generated content
build
storybook-static

# Playwright
playwright-report/
test-results/
7 changes: 7 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ const config: StorybookConfig = {
name: '@storybook/react-webpack5',
options: {fastRefresh: true, legacyRootApi: false},
},
babel: async (options) => ({
...options,
plugins: [
['@babel/plugin-transform-typescript', {allowDeclareFields: true}],
...(options.plugins || []),
],
}),
stories: ['../src/**/*.stories.@(ts|tsx)'],
docs: {
autodocs: false,
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

A dashboard grid rendering library.

The separate [`DashKitGridStack` entry](docs/gridstack.md) provides GridStack layout with the same plugin and parameter APIs, including optional nested containers. Existing root imports keep the legacy layout.

## Installation

```bash
Expand Down Expand Up @@ -77,7 +79,7 @@ interface DashKitProps {
- **onItemRender**: Called when item render complete
- **defaultGlobalParams**, **globalParams**: [Parameters](#Params) that affect all widgets. In DataLens, `defaultGlobalParams` are global parameters set in the dashboard settings. `globalParams` are globals parameters that can be set in the url.
- **itemsStateAndParams**: [itemsStateAndParams](#itemsStateAndParams).
- **settings**: DashKit settings.
- **settings**: DashKit settings. Optional `isolateItemUpdates: true` suppresses renderer updates caused only by other widgets' layout changes. Own data, state, params, geometry, settings, context and callbacks still update; the default keeps full-layout renderer updates.
- **context**: Object that will be propped up on all widgets.
- **overlayControls**: Object that overrides widget controls at the time of editing. If not transmitted, basic controls will be displayed. If `null` passed only close button or custom menu will be displayed.
- **overlayMenuItems**: Custom dropdown menu items
Expand Down
98 changes: 98 additions & 0 deletions docs/gridstack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# GridStack layout

`DashKitGridStack` uses GridStack 12.6.0 for layout and native dragging and resizing. It shares DashKit's plugin registry, parameter processing, controls, events and readiness callbacks. The existing `DashKit` entry continues to use the legacy layout.

```tsx
import {DashKitGridStack} from '@gravity-ui/dashkit/gridstack';
import {DashKitDnDWrapper, pluginText} from '@gravity-ui/dashkit';

DashKitGridStack.registerPlugins(pluginText);

<DashKitDnDWrapper>
<DashKitGridStack config={config} editMode={editing} onChange={handleChange} />
</DashKitDnDWrapper>;
```

The separate entry includes its required styles. Keep importing shared components, built-in plugins and public types from the root, and helpers from `@gravity-ui/dashkit/helpers`. Registration and `setSettings` are shared across both components in one module instance. Do not register the same type twice; `reloadPlugins` replaces a registration.

## Existing API

The component accepts the existing DashKit props, including groups, parameters, item state, plugin context, overlays, focus callbacks, copy callbacks and drag/resize callbacks. Its ref supports `on('change', handler)` (returns an unsubscribe function), `getItemsMeta()` (an array of promises) and `reloadItems({targetIds, force})`. Existing registration, settings and item helpers remain available as statics. `reflowLayout` additionally accepts `containers` to resolve child-grid settings and treat sibling containers as fixed collision footprints. It returns widget layout only and leaves descriptors unchanged. New-item priority is independent of canonical saved order. `setItem` rejects insertion into a full horizontal-nowrap row. Direct `reflowLayout` retains the legacy transformation behavior for existing flat inputs, including packing/wrapping an explicitly over-budget layout; interactive admission still enforces the nowrap row budget.

`config` remains controlled. Replace it to apply undo, redo or an external document; this does not emit a change event. Geometry is reconciled by widget ID, preserving a widget's React instance and ref when it changes zone or container. Reordering `config.items` changes desktop stacking within a grid; mobile `orderId` retains its existing meaning.

The synchronous `change` event precedes `onChange`. Calling `event.preventDefault()` suppresses `onChange` and still advances the internal complete geometry baseline. A later parameter, copy or delete operation therefore uses the latest layout. Change events can include optional `containers` and `previousContainers`; an existing widget's membership patch can include `containerId`.

`onItemMountChange` and `onItemRender` describe plugin readiness. A layout event does not mean asynchronous content has finished rendering. Automatic widget height remains a visual adjustment; the original saved height is retained until an explicit resize or reset follows the existing DashKit contract. GridStack does not add lazy mount suppression to React plugins.

## Grid settings

`groups` remain runtime zones, with the existing `render` and `gridProperties` functions. The implementation supports vertical, horizontal, horizontal-nowrap and disabled compaction, overlap, collision prevention, size constraints, bounds, resize directions and custom resize handles. The legacy `verticalCompact: false` disables compaction.

Static `setItem` and `reflowLayout` resolve the same effective grid settings as the mounted layout. A `gridProperties` callback replaces the supplied settings; omitted geometry uses the legacy renderer defaults of 12 columns, row height 150 and margin/padding 10. Return `{...defaults, ...overrides}` to retain the supplied settings instead.

Widths, margins and container padding use the existing DashKit grid units. For a grid of width `W`, `C` columns, horizontal padding `P` and gap `G`, the column step is `(W - 2P + G) / C`; a widget of width `w` occupies `w * step - G` CSS pixels. Vertical step is `rowHeight + gapY`.

For pixel reports, use `cols` equal to the unscaled canvas width, `rowHeight: 1`, `margin: [0, 0]`, `compactType: null` and `allowOverlap: true`. Set `maxRows` for the vertical upper bound. A fixed canvas also needs a definite containing height and `autoSize: false`; `maxRows` alone does not make an auto-sized zone tall. Use `transformScale` or the existing mutable `transformScaleRef` for scaled canvases. All configured resize corners operate in unscaled coordinates.

Ordinary auto-sized zones derive height from their content, gap and padding, including empty zones. They do not gain a minimum number of rows. A fluid grid's inline size is contained so the native grid's trailing gap cannot enlarge an ancestor's intrinsic width.

## Nested document format

Flat configurations continue to work without migration. Nesting adds fields without changing widget IDs, data or existing layout coordinates:

```ts
const nested = {
...config,
layoutVersion: 2,
containers: [
{
id: 'panel',
parent: 'slide',
x: 20,
y: 20,
w: 400,
h: 300,
padding: [12, 20, 24, 16], // top, right, bottom, left; CSS pixels
gap: [8, 6], // horizontal and vertical CSS pixels
grid: {
cols: 12,
rowHeight: 18,
compactType: null,
allowOverlap: false,
maxRows: 10,
},
},
],
layout: config.layout.map((item) =>
item.i === 'chart'
? {...item, parent: 'slide', containerId: 'panel', x: 0, y: 0, w: 6, h: 4}
: item,
),
};
```

`parent` always identifies the root runtime zone. `containerId` identifies the immediate containing grid; x/y/w/h are local to that grid. A container can itself have `containerId`. Container padding has four independent sides and is separate from the child gap. Child grids inherit interaction flags, scale and resize handles from their root zone; their persisted grid settings determine sizing, compaction, overlap and optional row bounds. Zone callbacks are not copied onto every nested grid. Native child admission is bounded by the container’s usable height after padding and gap conversion; an optional `maxRows` can tighten that bound. The inferred bound is never saved, and valid fractional geometry is retained. Invalid native proposals are restored before a document transaction is emitted. Plugin autoheight is visual: nested height and its height constraints are capped to the remaining usable rows without changing the saved height or growing the parent; resetting restores the saved height.

The component validates duplicate/reserved IDs, geometry, padding, gaps, missing membership, cycles and inconsistent root zones before applying a document. Invalid input throws an explanatory error. The measured geometry and snapshot projection also reject nonpositive usable content width/height or children exceeding their container bounds; padding does not silently enlarge a saved container. Remove or move a container's children explicitly before deleting it; removing a nonempty container does not silently delete its subtree. Pass a nested configuration to `DashKitGridStack`; the legacy component explicitly rejects it.

Moving between grids preserves the widget instance and converts its visible unscaled size and constraints into the destination's units, with destination-grid quantization. A container subtree can move between grids with its descendants and local coordinates intact; a destination that cannot fit its projected size rejects the drop. Releasing a cross-grid drag outside an accepted destination restores the previous document. Resizing a container respects the extents of its children. Mobile mode traverses the hierarchy and renders each widget once using the existing mobile presentation.

For snapshots, `getGridStackLayoutRects(config, rootGrids)` returns widget rectangles in unscaled CSS pixels, accumulating container origins and padding. Supply each root zone's width and effective `gridProperties`; use `__default` for the default zone. It does not mount a grid, mutate a configuration or wait for plugin content.

## Packaging and local checks

The entry has ESM, CommonJS and TypeScript declarations. GridStack's published native modules use extensionless ESM imports; the CommonJS build includes a transpiled copy of the pinned runtime and its license. Run the library build when changing the runtime or dependency version. Both module formats import CSS and require an application bundler or an appropriate CSS loader for Node rendering.

The React peer range is unchanged. The new layout uses portals and the existing lifecycle shell; it does not require `createRoot` in consumer applications. Browser support follows modern GridStack requirements, including CSS variables and inline-size containment. ResizeObserver uses the existing polyfill when unavailable. A JavaScript build does not polyfill CSS features.

The public stories include report, nested, cross-zone, dashboard, preview/overlap and legacy comparison fixtures. Native tests use Storybook's normal bundler and server; Playwright starts it automatically:

```sh
npx playwright install chromium
npm run test:browser -- --project=chromium
# All installed engines (Chromium, Firefox, WebKit):
npm run test:browser
```

Playwright writes traces and failure screenshots to `test-results/` and its HTML report to `playwright-report/` (both ignored). `npm test` runs engine-independent helpers once, the same mounted flat contract for legacy and GridStack, and separate engine-specific suites. Native specs retain gesture, scale, nested containment, cancellation, bounds, overlap and StrictMode checks, plus a common flat gesture contract for both renderers. Only the native CDP touch check is Chromium-specific.
56 changes: 49 additions & 7 deletions gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,36 @@ task('compile-to-cjs', () => {
return compileTs();
});

// GridStack publishes extensionless ESM. Transpile its native modules for our CJS entry.
task('gridstack-cjs', (done) => {
const fs = require('fs');
const ts = require('typescript');
const source = path.dirname(require.resolve('gridstack'));
const target = path.join(BUILD_DIR, 'cjs', 'gridstack-runtime');
fs.mkdirSync(target, {recursive: true});
for (const name of fs.readdirSync(source)) {
if (!name.endsWith('.js') || name.includes('-all')) {
continue;
}
const result = ts.transpileModule(fs.readFileSync(path.join(source, name), 'utf8'), {
compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020},
});
fs.writeFileSync(path.join(target, name), result.outputText);
}
fs.copyFileSync(path.join(source, '..', 'LICENSE'), path.join(target, 'LICENSE'));
const entry = path.join(BUILD_DIR, 'cjs', 'components', 'GridStack', 'runtime.js');
fs.writeFileSync(
entry,
fs
.readFileSync(entry, 'utf8')
.replace(
/require\(["']gridstack["']\)/g,
'require("../../gridstack-runtime/gridstack")',
),
);
done();
});

task('copy-js-declarations', () => {
return src(['src/**/*.d.ts', '!src/**/__stories__/**/*.d.ts'])
.pipe(dest(path.resolve(BUILD_DIR, 'esm')))
Expand All @@ -98,7 +128,15 @@ task('copy-docs', (done) => {
buildDocs({
rootDir: __dirname,
outDir: path.join(__dirname, 'build', 'docs'),
sources: [],
sources: [
{
title: 'Guides',
kind: 'markdown',
baseDir: 'docs',
outPrefix: 'guides',
nameFromTitle: true,
},
],
});
done();
});
Expand All @@ -110,6 +148,7 @@ task(
parallel(['compile-to-esm', 'compile-to-cjs']),
parallel(['copy-js-declarations', 'copy-i18n']),
'styles-components',
'gridstack-cjs',
'copy-docs',
]),
);
Expand All @@ -118,12 +157,15 @@ task('default', series(['build']));

task(
'rebuild',
parallel([
'compile-to-esm',
'compile-to-cjs',
'copy-js-declarations',
'copy-i18n',
'styles-components',
series([
parallel([
'compile-to-esm',
'compile-to-cjs',
'copy-js-declarations',
'copy-i18n',
'styles-components',
]),
'gridstack-cjs',
]),
);

Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module.exports = {
transform: {
'^.+\\.(t|j)sx?$': ['ts-jest', {tsconfig: './tsconfig.json'}],
},
transformIgnorePatterns: ['node_modules/(?!(@gravity-ui)/)'],
transformIgnorePatterns: ['node_modules/(?!(@gravity-ui|gridstack)/)'],
moduleNameMapper: {
'\\.(css|scss)$': '<rootDir>/jest/styleMock.js',
},
Expand Down
82 changes: 82 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading