feat: complete shared platform UI contracts
This commit is contained in:
@@ -16,6 +16,9 @@ Core owns:
|
||||
- kernel APIs for platform metadata, module lifecycle, health, and development diagnostics
|
||||
- `@govoplan/core-webui`, including login, CSRF/API helpers, shell layout, generic UI components, IconRail, DataGrid, access boundaries, and module route/nav contracts
|
||||
|
||||
The shared DataGrid sizing and resize invariants are specified in
|
||||
[`docs/DATAGRID_SIZING_CONTRACT.md`](docs/DATAGRID_SIZING_CONTRACT.md).
|
||||
|
||||
Platform and feature modules own their backend routers, models, migrations,
|
||||
permissions, frontend packages, nav items, and route contributions. Access,
|
||||
tenancy, policy, audit, and admin behavior live in their owning platform
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# DataGrid Sizing Contract
|
||||
|
||||
`DataGrid` turns every declared track into a deterministic pixel layout after
|
||||
its container has a measurable width. The same contract is used on initial
|
||||
layout, container resize, persisted-layout restore, and pointer resize.
|
||||
|
||||
## Column Declarations
|
||||
|
||||
- `width: number` or `Npx` is the preferred pixel width.
|
||||
- `width: N%` is a preferred share of the measured container.
|
||||
- `width: Nfr` shares residual width by fraction weight.
|
||||
- `width: minmax(Npx, preferred)` combines a hard lower bound with any
|
||||
supported preferred width.
|
||||
- An omitted width and the legacy `fill` flag are one-fraction flexible tracks.
|
||||
- `minWidth` is a hard floor. Header sort, filter, and resize controls may raise
|
||||
the effective accessible floor.
|
||||
- `maxWidth` bounds direct user growth and free/constrained compensation. In a
|
||||
cover layout it is a preferred maximum: passive tracks may exceed it when
|
||||
that is necessary to keep the table flush with its container.
|
||||
|
||||
## Layout Modes
|
||||
|
||||
| `initialFit` | `resizeBehavior` | Initial layout | Pointer resize |
|
||||
| --- | --- | --- | --- |
|
||||
| `container` | `cover` | Real columns fill the container. Hard-minimum excess scrolls horizontally. | The active change is compensated by ordinary peer columns. Passive maxima are soft; no blank filler column is created. |
|
||||
| `content` | `cover` | After measurement, the same cover invariant applies. | Same as cover above. |
|
||||
| `container` | `constrained` | Real columns fill the container. | Peer compensation respects every hard min/max and stops the active resize when capacity is exhausted. |
|
||||
| `content` | `free` | Declared content widths are retained. | Only the active column changes, so underflow or horizontal overflow is allowed. |
|
||||
| `container` | `free` | The initial layout fills the container. | Later user resizing is free. A right-sticky column promotes this mode to cover so its edge remains stable. |
|
||||
|
||||
Sticky columns do not absorb ordinary cover residuals and are not resize
|
||||
compensation targets. A last resizable column compensates against columns to
|
||||
its left, so it follows the same right-edge invariant as earlier columns.
|
||||
|
||||
## Persistence
|
||||
|
||||
Only the pixel layout resulting from an explicit user resize is persisted.
|
||||
Persisted widths are keyed by a signature containing column IDs, declared
|
||||
widths and bounds, resize affordances, sticky placement, initial fit, and resize
|
||||
behavior. A changed signature discards the old override and recomputes the
|
||||
declared layout.
|
||||
|
||||
Container reconciliation is suspended while a pointer drag is active. On
|
||||
release, the already-rendered pixel layout becomes the persisted preference;
|
||||
there is no second fit pass and therefore no drag-end snap.
|
||||
|
||||
## Regression Matrix
|
||||
|
||||
`webui/tests/data-grid-sizing.test.ts` covers:
|
||||
|
||||
- pixel, percentage, fraction, `minmax`, omitted, and legacy-fill tracks;
|
||||
- preferred max exhaustion without a synthetic filler column;
|
||||
- hard-minimum horizontal overflow;
|
||||
- fixed-only cover grids;
|
||||
- persisted overrides under growth and viewport pressure;
|
||||
- stale layout signatures;
|
||||
- first, middle, and last-column peer compensation;
|
||||
- free, cover, and constrained resizing;
|
||||
- cover-expanded tracks that already exceed preferred maxima; and
|
||||
- preservation of the pointer layout across the commit fit.
|
||||
|
||||
`webui/tests/data-grid-actions.test.tsx` also verifies the rendered fixed-cover
|
||||
shape and guards against reintroducing a synthetic buffer cell.
|
||||
@@ -222,11 +222,13 @@ DocumentationSourceKind = Literal[
|
||||
"route",
|
||||
"capability",
|
||||
"policy",
|
||||
"release_catalog",
|
||||
"configuration_package",
|
||||
"wiki",
|
||||
"repository",
|
||||
]
|
||||
DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
|
||||
CapabilityStability = Literal["experimental", "stable", "deprecated"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -336,12 +338,25 @@ class DocumentationSourceDefinition:
|
||||
documentation_types: tuple[DocumentationType, ...] = ("admin",)
|
||||
condition: DocumentationCondition = field(default_factory=DocumentationCondition)
|
||||
state: DocumentationSourceState = "configured"
|
||||
state_reason: str | None = None
|
||||
provenance: Mapping[str, Any] = field(default_factory=dict)
|
||||
inspection: Mapping[str, Any] = field(default_factory=dict)
|
||||
link: DocumentationLink | None = None
|
||||
configuration_key: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CapabilityDocumentation:
|
||||
"""Provider-owned capability metadata safe for generic platform consumers."""
|
||||
|
||||
label: str
|
||||
summary: str
|
||||
contract_version: str | None = None
|
||||
stability: CapabilityStability = "stable"
|
||||
documentation_types: tuple[DocumentationType, ...] = ("admin",)
|
||||
audience: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class ResourceAclProvider(Protocol):
|
||||
resource_type: str
|
||||
|
||||
@@ -412,6 +427,7 @@ class ModuleManifest:
|
||||
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
|
||||
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
|
||||
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
|
||||
capability_documentation: Mapping[str, CapabilityDocumentation] = field(default_factory=dict)
|
||||
search_providers: tuple["SearchProviderRegistration", ...] = ()
|
||||
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
|
||||
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
|
||||
|
||||
@@ -614,6 +614,28 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
|
||||
|
||||
def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
|
||||
for capability, metadata in manifest.capability_documentation.items():
|
||||
if capability not in manifest.capability_factories:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} documents capability {capability!r} "
|
||||
"but does not provide it"
|
||||
)
|
||||
if not metadata.label.strip():
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} capability {capability!r} "
|
||||
"documentation must have a label"
|
||||
)
|
||||
if not metadata.summary.strip():
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} capability {capability!r} "
|
||||
"documentation must have a summary"
|
||||
)
|
||||
if metadata.contract_version is not None and not metadata.contract_version.strip():
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} capability {capability!r} "
|
||||
"documentation contract version must not be empty"
|
||||
)
|
||||
|
||||
provider_keys: set[str] = set()
|
||||
for registration in manifest.documentation_configuration_providers:
|
||||
if not registration.keys:
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationConfigurationDecision,
|
||||
DocumentationConfigurationProviderRegistration,
|
||||
DocumentationCondition,
|
||||
@@ -116,6 +117,39 @@ class DocumentationTopicContractTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(RegistryError, "duplicate documentation configuration key"):
|
||||
duplicate.validate()
|
||||
|
||||
def test_capability_documentation_is_typed_and_must_match_a_provider(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
capability_factories={"example.lookup": lambda _context: object()},
|
||||
capability_documentation={
|
||||
"example.lookup": CapabilityDocumentation(
|
||||
label="Example lookup",
|
||||
summary="Resolves example records without exposing provider internals.",
|
||||
contract_version="2",
|
||||
audience=("module_admin",),
|
||||
),
|
||||
},
|
||||
))
|
||||
registry.validate()
|
||||
|
||||
missing_provider = PlatformRegistry()
|
||||
missing_provider.register(ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
capability_documentation={
|
||||
"example.lookup": CapabilityDocumentation(
|
||||
label="Example lookup",
|
||||
summary="Resolves example records.",
|
||||
),
|
||||
},
|
||||
))
|
||||
with self.assertRaisesRegex(RegistryError, "does not provide it"):
|
||||
missing_provider.validate()
|
||||
|
||||
|
||||
def registry_for(*topics: DocumentationTopic) -> PlatformRegistry:
|
||||
registry = PlatformRegistry()
|
||||
|
||||
Generated
+20
@@ -29,6 +29,7 @@
|
||||
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
|
||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
||||
"@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui",
|
||||
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
@@ -435,6 +436,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-tenancy/webui": {
|
||||
"name": "@govoplan/tenancy-webui",
|
||||
"version": "0.1.8",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-views/webui": {
|
||||
"name": "@govoplan/views-webui",
|
||||
"version": "0.1.0",
|
||||
@@ -1305,6 +1321,10 @@
|
||||
"resolved": "../../govoplan-search/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/tenancy-webui": {
|
||||
"resolved": "../../govoplan-tenancy/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/views-webui": {
|
||||
"resolved": "../../govoplan-views/webui",
|
||||
"link": true
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
|
||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
||||
"@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui",
|
||||
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
|
||||
@@ -23,6 +23,7 @@ const packageByModule = {
|
||||
risk_compliance: "@govoplan/risk-compliance-webui",
|
||||
scheduling: "@govoplan/scheduling-webui",
|
||||
search: "@govoplan/search-webui",
|
||||
tenancy: "@govoplan/tenancy-webui",
|
||||
views: "@govoplan/views-webui",
|
||||
workflow: "@govoplan/workflow-webui"
|
||||
};
|
||||
@@ -30,6 +31,8 @@ const packageByModule = {
|
||||
const cases = [
|
||||
{ name: "core-only", modules: [] },
|
||||
{ name: "access-only", modules: ["access"] },
|
||||
{ name: "tenancy-only", modules: ["tenancy"] },
|
||||
{ name: "access-with-tenancy", modules: ["access", "tenancy"] },
|
||||
{ name: "admin-only", modules: ["admin"] },
|
||||
{ name: "addresses-only", modules: ["addresses"] },
|
||||
{ name: "access-with-admin", modules: ["access", "admin"] },
|
||||
@@ -58,7 +61,7 @@ const cases = [
|
||||
{ name: "search-only", modules: ["search"] },
|
||||
{ name: "risk-compliance-only", modules: ["risk_compliance"] },
|
||||
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
||||
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox", "risk_compliance", "search"] }
|
||||
{ name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox", "risk_compliance", "search"] }
|
||||
];
|
||||
|
||||
const npmExec = process.env.npm_execpath;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Link } from "react-router";
|
||||
|
||||
import type { AuthInfo, WizardDirectoryEntry } from "../types";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import { hasAnyScope, hasScope } from "../utils/permissions";
|
||||
import Card from "./Card";
|
||||
|
||||
export type WizardDirectoryProps = {
|
||||
entries: WizardDirectoryEntry[];
|
||||
auth: AuthInfo;
|
||||
emptyText?: string;
|
||||
};
|
||||
|
||||
function canOpenWizard(auth: AuthInfo, entry: WizardDirectoryEntry): boolean {
|
||||
if (entry.available === false) return false;
|
||||
if (entry.allOf?.some((scope) => !hasScope(auth, scope))) return false;
|
||||
if (entry.anyOf?.length && !hasAnyScope(auth, entry.anyOf)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function WizardDirectory({
|
||||
entries,
|
||||
auth,
|
||||
emptyText = "No guided workflows are available."
|
||||
}: WizardDirectoryProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const visibleEntries = entries.filter(
|
||||
(entry) =>
|
||||
!entry.anyOf?.length
|
||||
|| hasAnyScope(auth, entry.anyOf)
|
||||
|| entry.available === false
|
||||
);
|
||||
|
||||
if (!visibleEntries.length) {
|
||||
return <p className="muted">{translateText(emptyText)}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wizard-directory-grid">
|
||||
{visibleEntries.map((entry) => {
|
||||
const available = canOpenWizard(auth, entry);
|
||||
return (
|
||||
<Card
|
||||
key={`${entry.moduleId}:${entry.id}`}
|
||||
title={entry.label}
|
||||
actions={
|
||||
available
|
||||
? (
|
||||
<Link className="btn btn-primary" to={entry.to}>
|
||||
{translateText(entry.actionLabel ?? "Open")}
|
||||
</Link>
|
||||
)
|
||||
: null
|
||||
}
|
||||
>
|
||||
{entry.description && <p>{translateText(entry.description)}</p>}
|
||||
{!available && entry.unavailableReason && (
|
||||
<p className="muted small-note">
|
||||
{translateText(entry.unavailableReason)}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,20 +5,17 @@ import StatusBadge from "../StatusBadge";
|
||||
import TableActionGroup from "./TableActionGroup";
|
||||
import { usePlatformLanguage, i18nMessage } from "../../i18n/LanguageContext";
|
||||
import {
|
||||
DATA_GRID_MAX_TRACK_WIDTH as BUFFER_MAX_WIDTH,
|
||||
DATA_GRID_MAX_TRACK_WIDTH,
|
||||
dataGridColumnPixelWidth as columnPixelWidth,
|
||||
dataGridColumnResizeWeight,
|
||||
dataGridColumnTrackWithMinimum as columnTrackWithMinimum,
|
||||
dataGridLayoutSignature,
|
||||
dataGridWidthsForLayout,
|
||||
distributeDataGridResizeAmount as distributeResizeAmount,
|
||||
effectiveDataGridColumnMaxWidth as effectiveColumnMaxWidth,
|
||||
effectiveDataGridColumnMinWidth as effectiveColumnMinWidth,
|
||||
fitDataGridColumns,
|
||||
isFlexibleDataGridWidth as isFlexibleWidth,
|
||||
roundDataGridWidths as roundWidthRecord,
|
||||
totalDataGridColumnWidth as totalColumnWidth,
|
||||
type DataGridResizeTarget as ResizeTarget
|
||||
resizeDataGridColumn,
|
||||
roundDataGridWidths as roundWidthRecord
|
||||
} from "./dataGridSizing";
|
||||
|
||||
export type DataGridSortDirection = "asc" | "desc";
|
||||
@@ -89,7 +86,10 @@ export type DataGridColumn<T> = {
|
||||
width?: number | string;
|
||||
/** Hard lower bound. Header controls may require a larger accessible minimum. */
|
||||
minWidth?: number;
|
||||
/** Hard upper bound. Space left after all maxima is assigned to the neutral buffer track. */
|
||||
/**
|
||||
* Upper bound for direct resizing. A cover layout may exceed preferred
|
||||
* maxima only when that is required to consume the complete container.
|
||||
*/
|
||||
maxWidth?: number;
|
||||
resizable?: boolean;
|
||||
/** @deprecated Kept for source compatibility. Resize space is now distributed across resizable columns. */
|
||||
@@ -119,8 +119,6 @@ type DataGridState = {
|
||||
layoutSignature?: string;
|
||||
/** Legacy layout state; removed when persisted state is sanitized. */
|
||||
fillColumnId?: string | null;
|
||||
/** Width of the synthetic, handle-less coverage track. */
|
||||
bufferWidth?: number;
|
||||
};
|
||||
|
||||
type DataGridBaseProps<T> = {
|
||||
@@ -136,7 +134,11 @@ type DataGridBaseProps<T> = {
|
||||
initialFit?: DataGridInitialFit;
|
||||
/** @deprecated Use initialFit instead. */
|
||||
fit?: DataGridInitialFit;
|
||||
/** Defaults to cover. Free is automatically promoted to cover when a right-sticky column exists. */
|
||||
/**
|
||||
* Cover keeps the right edge fixed by redistributing every resize across
|
||||
* real columns. Free permits underflow and horizontal growth. Free is
|
||||
* automatically promoted to cover when a right-sticky column exists.
|
||||
*/
|
||||
resizeBehavior?: DataGridResizeBehavior;
|
||||
className?: string;
|
||||
rowClassName?: (row: T, index: number) => string | undefined;
|
||||
@@ -190,28 +192,14 @@ type FilterPosition = {
|
||||
type ColumnResizeState = {
|
||||
columnId: string;
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
minWidth: number;
|
||||
maxWidth: number;
|
||||
baseWidths: Record<string, number>;
|
||||
rightResizeTargets: ResizeTarget[];
|
||||
bufferTarget?: ResizeTarget;
|
||||
bufferStartWidth: number;
|
||||
shrinkRoomWithoutScroll: number;
|
||||
/**
|
||||
* The last resizable column may consume overflow on either side of the
|
||||
* current viewport. As its track shrinks, the browser clamps scrollLeft and
|
||||
* moves the viewport left, while the grid still covers the complete surface.
|
||||
*/
|
||||
lastResizableShrinkRoom: number;
|
||||
isLastResizableColumn: boolean;
|
||||
uncompensatedShrinkRoom: number;
|
||||
behavior: DataGridResizeBehavior;
|
||||
};
|
||||
|
||||
const STORAGE_PREFIX = "govoplan.datagrid.";
|
||||
const FILTER_POPOVER_WIDTH = 320;
|
||||
const FILTER_POPOVER_MARGIN = 12;
|
||||
const BUFFER_COLUMN_ID = "__data_grid_resize_buffer__";
|
||||
|
||||
export default function DataGrid<T>({
|
||||
id,
|
||||
@@ -241,8 +229,6 @@ export default function DataGrid<T>({
|
||||
"cover" :
|
||||
resizeBehavior;
|
||||
const layoutSignature = dataGridLayoutSignature(columns, resolvedInitialFit, effectiveResizeBehavior);
|
||||
const bufferInsertIndex = findBufferInsertIndex(columns);
|
||||
const containerResizeColumns = columns.filter(isContainerResizeColumn);
|
||||
|
||||
const localStorageKey = storageKey ?? `${STORAGE_PREFIX}${id}`;
|
||||
const initialFiltersKey = JSON.stringify(initialFilters);
|
||||
@@ -368,31 +354,33 @@ export default function DataGrid<T>({
|
||||
const userWidths = signatureMatches ? current.userWidths ?? {} : {};
|
||||
const mustFit = effectiveResizeBehavior !== "free" || resolvedInitialFit === "container";
|
||||
if (!mustFit) {
|
||||
if (signatureMatches && current.widths === undefined && current.bufferWidth === undefined) return current;
|
||||
if (signatureMatches && current.widths === undefined) return current;
|
||||
return {
|
||||
...current,
|
||||
widths: undefined,
|
||||
userWidths,
|
||||
layoutSignature,
|
||||
fillColumnId: undefined,
|
||||
bufferWidth: undefined
|
||||
fillColumnId: undefined
|
||||
};
|
||||
}
|
||||
|
||||
const layout = fitDataGridColumns(columns, nextContainerWidth, measuredWidths, userWidths);
|
||||
const nextBufferWidth = normalizeBufferWidth(layout.bufferWidth, containerResizeColumns.length === 0);
|
||||
const layout = fitDataGridColumns(
|
||||
columns,
|
||||
nextContainerWidth,
|
||||
measuredWidths,
|
||||
userWidths,
|
||||
effectiveResizeBehavior
|
||||
);
|
||||
if (
|
||||
signatureMatches
|
||||
&& shallowEqualNumberRecords(current.widths ?? {}, layout.widths)
|
||||
&& nextBufferWidth === current.bufferWidth
|
||||
) return current;
|
||||
return {
|
||||
...current,
|
||||
widths: layout.widths,
|
||||
userWidths,
|
||||
layoutSignature,
|
||||
fillColumnId: undefined,
|
||||
bufferWidth: nextBufferWidth
|
||||
fillColumnId: undefined
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -421,57 +409,25 @@ export default function DataGrid<T>({
|
||||
|
||||
function onMove(event: MouseEvent) {
|
||||
const rawDelta = event.clientX - activeResize.startX;
|
||||
const activeDelta = Math.min(
|
||||
activeResize.maxWidth - activeResize.startWidth,
|
||||
Math.max(activeResize.minWidth - activeResize.startWidth, rawDelta)
|
||||
const resized = resizeDataGridColumn(
|
||||
columns,
|
||||
activeResize.baseWidths,
|
||||
activeResize.columnId,
|
||||
rawDelta,
|
||||
activeResize.behavior,
|
||||
activeResize.uncompensatedShrinkRoom
|
||||
);
|
||||
const nextWidths = { ...activeResize.baseWidths };
|
||||
let nextBufferWidth = activeResize.bufferStartWidth;
|
||||
let appliedActiveDelta = activeDelta;
|
||||
|
||||
if (activeResize.behavior === "cover" && activeDelta < 0) {
|
||||
const requestedShrink = -activeDelta;
|
||||
const availableUncompensatedShrink = activeResize.isLastResizableColumn ?
|
||||
activeResize.lastResizableShrinkRoom :
|
||||
activeResize.shrinkRoomWithoutScroll;
|
||||
const freeShrink = Math.min(requestedShrink, availableUncompensatedShrink);
|
||||
const compensationNeeded = requestedShrink - freeShrink;
|
||||
const distribution = distributeWithFallback(
|
||||
compensationNeeded,
|
||||
activeResize.rightResizeTargets,
|
||||
activeResize.bufferTarget
|
||||
);
|
||||
appliedActiveDelta = -(freeShrink + distribution.applied);
|
||||
const applied = applyResizeAmounts(nextWidths, nextBufferWidth, distribution.amounts);
|
||||
nextBufferWidth = applied.bufferWidth;
|
||||
} else if (activeResize.behavior === "cover" && activeDelta > 0 && activeResize.bufferTarget) {
|
||||
// The buffer is residual cover space, not user-sized content. Growing a
|
||||
// real column consumes that track before the table itself gets wider.
|
||||
const distribution = distributeResizeAmount(-activeDelta, [activeResize.bufferTarget]);
|
||||
const applied = applyResizeAmounts(nextWidths, nextBufferWidth, distribution.amounts);
|
||||
nextBufferWidth = applied.bufferWidth;
|
||||
} else if (activeResize.behavior === "constrained") {
|
||||
const distribution = distributeWithFallback(
|
||||
-activeDelta,
|
||||
activeResize.rightResizeTargets,
|
||||
activeResize.bufferTarget
|
||||
);
|
||||
appliedActiveDelta = -distribution.applied;
|
||||
const applied = applyResizeAmounts(nextWidths, nextBufferWidth, distribution.amounts);
|
||||
nextBufferWidth = applied.bufferWidth;
|
||||
}
|
||||
|
||||
nextWidths[activeResize.columnId] = activeResize.startWidth + appliedActiveDelta;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
widths: roundWidthRecord(nextWidths),
|
||||
userWidths: {
|
||||
widths: resized.widths,
|
||||
userWidths: activeResize.behavior === "free"
|
||||
? {
|
||||
...(current.layoutSignature === layoutSignature ? current.userWidths ?? {} : {}),
|
||||
[activeResize.columnId]: Math.round(nextWidths[activeResize.columnId] * 100) / 100
|
||||
},
|
||||
[activeResize.columnId]: resized.widths[activeResize.columnId]
|
||||
}
|
||||
: resized.widths,
|
||||
layoutSignature,
|
||||
fillColumnId: undefined,
|
||||
bufferWidth: normalizeBufferWidth(nextBufferWidth, containerResizeColumns.length === 0)
|
||||
fillColumnId: undefined
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -491,7 +447,7 @@ export default function DataGrid<T>({
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [resizeState, columns, effectiveResizeBehavior, containerResizeColumns.length, layoutSignature]);
|
||||
}, [resizeState, columns, effectiveResizeBehavior, layoutSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openFilterColumnId) return undefined;
|
||||
@@ -594,13 +550,8 @@ export default function DataGrid<T>({
|
||||
if (pagination && pagination.page !== paginationPage) pagination.onPageChange(paginationPage);
|
||||
}, [pagination, paginationPage]);
|
||||
|
||||
const bufferWidth = Math.max(0, state.bufferWidth ?? 0);
|
||||
const shouldRenderBuffer = effectiveResizeBehavior !== "free" && (
|
||||
containerResizeColumns.length === 0 || bufferWidth > 0.01);
|
||||
const actualTracks = columns.map((column) => widthForColumn(column, state.widths?.[column.id]));
|
||||
const templateTracks = [...actualTracks];
|
||||
if (shouldRenderBuffer) templateTracks.splice(bufferInsertIndex, 0, `${bufferWidth}px`);
|
||||
const templateColumns = templateTracks.join(" ");
|
||||
const templateColumns = actualTracks.join(" ");
|
||||
const hasFlexibleColumns = columns.some((column) => !state.widths?.[column.id] && isFlexibleColumn(column));
|
||||
const stickyOffsets = useMemo(() => computeStickyOffsets(columns, state.widths, measuredWidths), [columns, state.widths, measuredWidths]);
|
||||
const gridClassName = [
|
||||
@@ -655,17 +606,6 @@ export default function DataGrid<T>({
|
||||
});
|
||||
}
|
||||
|
||||
function bufferCell(key: string, classNames: string, role: "columnheader" | "cell") {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role={role}
|
||||
aria-hidden="true"
|
||||
className={`data-grid-cell data-grid-buffer-cell ${classNames}`.trim()} />);
|
||||
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`data-grid-shell data-grid-${resolvedInitialFit} data-grid-shell-resize-${effectiveResizeBehavior} ${className}`.trim()}
|
||||
@@ -674,14 +614,10 @@ export default function DataGrid<T>({
|
||||
|
||||
<div className="data-grid-scroll-region" ref={scrollRegionRef}>
|
||||
<div className={gridClassName} role="table" aria-label={id} style={{ gridTemplateColumns: templateColumns }}>
|
||||
{columns.flatMap((column, columnIndex) => {
|
||||
{columns.map((column, columnIndex) => {
|
||||
const sorted = state.sort?.columnId === column.id ? state.sort.direction : undefined;
|
||||
const hasFilter = Boolean((state.filters?.[column.id] ?? "").trim());
|
||||
const cells: ReactNode[] = [];
|
||||
if (shouldRenderBuffer && bufferInsertIndex === columnIndex) {
|
||||
cells.push(bufferCell("header-buffer", "data-grid-header-cell", "columnheader"));
|
||||
}
|
||||
cells.push(
|
||||
return (
|
||||
<div
|
||||
key={`header-${column.id}`}
|
||||
role="columnheader"
|
||||
@@ -721,57 +657,28 @@ export default function DataGrid<T>({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const baseWidths = measuredColumnWidths(columns, headerCellRefs.current, state.widths, measuredWidths);
|
||||
const currentWidth = baseWidths[column.id] ?? columnPixelWidth(column, state.widths?.[column.id], measuredWidths[column.id]);
|
||||
const activeColumnIndex = columns.findIndex((candidate) => candidate.id === column.id);
|
||||
const rightResizeTargets = columns.
|
||||
slice(activeColumnIndex + 1).
|
||||
filter(isResizeCompensationColumn).
|
||||
map((candidate) => resizeTargetForColumn(candidate, baseWidths));
|
||||
const isLastResizableColumn = !columns.
|
||||
slice(activeColumnIndex + 1).
|
||||
some(isResizeCompensationColumn);
|
||||
const canUseBuffer = effectiveResizeBehavior !== "free" && activeColumnIndex < bufferInsertIndex;
|
||||
const storedBufferWidth = Math.max(0, state.bufferWidth ?? 0);
|
||||
const scrollElement = scrollRegionRef.current;
|
||||
const untrackedCoverWidth = scrollElement && canUseBuffer ?
|
||||
Math.max(0, scrollElement.clientWidth - totalColumnWidth(columns, baseWidths) - storedBufferWidth) :
|
||||
0;
|
||||
const activeBufferWidth = storedBufferWidth + untrackedCoverWidth;
|
||||
const totalHorizontalOverflow = scrollElement ?
|
||||
Math.max(0, scrollElement.scrollWidth - scrollElement.clientWidth) :
|
||||
0;
|
||||
const shrinkRoomWithoutScroll = scrollElement ?
|
||||
const shrinkRoomWithoutScroll = scrollElement && !isLastResizableColumn ?
|
||||
Math.max(0, totalHorizontalOverflow - scrollElement.scrollLeft) :
|
||||
0;
|
||||
const lastResizableShrinkRoom = Math.min(
|
||||
totalHorizontalOverflow,
|
||||
Math.max(0, currentWidth - effectiveColumnMinWidth(column))
|
||||
);
|
||||
totalHorizontalOverflow;
|
||||
|
||||
setState((current) => ({
|
||||
...current,
|
||||
widths: roundWidthRecord(baseWidths),
|
||||
fillColumnId: undefined,
|
||||
bufferWidth: normalizeBufferWidth(activeBufferWidth, containerResizeColumns.length === 0)
|
||||
fillColumnId: undefined
|
||||
}));
|
||||
setResizeState({
|
||||
columnId: column.id,
|
||||
startX: event.clientX,
|
||||
startWidth: currentWidth,
|
||||
minWidth: effectiveColumnMinWidth(column),
|
||||
maxWidth: effectiveColumnMaxWidth(column),
|
||||
baseWidths,
|
||||
rightResizeTargets,
|
||||
bufferTarget: canUseBuffer ? {
|
||||
columnId: BUFFER_COLUMN_ID,
|
||||
startWidth: activeBufferWidth,
|
||||
minWidth: 0,
|
||||
maxWidth: BUFFER_MAX_WIDTH
|
||||
} : undefined,
|
||||
bufferStartWidth: activeBufferWidth,
|
||||
shrinkRoomWithoutScroll,
|
||||
lastResizableShrinkRoom,
|
||||
isLastResizableColumn,
|
||||
uncompensatedShrinkRoom: shrinkRoomWithoutScroll,
|
||||
behavior: effectiveResizeBehavior
|
||||
});
|
||||
}}>
|
||||
@@ -781,30 +688,25 @@ export default function DataGrid<T>({
|
||||
}
|
||||
</div>
|
||||
);
|
||||
return cells;
|
||||
})}
|
||||
{shouldRenderBuffer && bufferInsertIndex === columns.length ?
|
||||
bufferCell("header-buffer-end", "data-grid-header-cell", "columnheader") :
|
||||
null}
|
||||
{renderedRows.length === 0 ? (() => {
|
||||
const filteredEmpty = rows.length > 0 && hasActiveFilters;
|
||||
const actionColumnIndex = columns.findIndex((column) => column.id === emptyActionColumnId);
|
||||
const actionColumn = actionColumnIndex >= 0 ? columns[actionColumnIndex] : undefined;
|
||||
const actionLayoutIndex = actionColumnIndex + (shouldRenderBuffer && bufferInsertIndex <= actionColumnIndex ? 1 : 0);
|
||||
if (!filteredEmpty && emptyAction && actionColumn && actionLayoutIndex > 0) {
|
||||
if (!filteredEmpty && emptyAction && actionColumn && actionColumnIndex > 0) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="data-grid-cell data-grid-body-cell data-grid-empty-message data-grid-row-even is-last-row"
|
||||
role="cell"
|
||||
style={{ gridColumn: `1 / ${actionLayoutIndex + 1}` }}>
|
||||
style={{ gridColumn: `1 / ${actionColumnIndex + 1}` }}>
|
||||
|
||||
{translatedEmptyText}
|
||||
</div>
|
||||
<div
|
||||
className={`data-grid-cell data-grid-body-cell data-grid-empty-action-cell data-grid-row-even is-last-row ${stickyClass(actionColumn)}`.trim()}
|
||||
role="cell"
|
||||
style={{ ...stickyStyle(actionColumn, stickyOffsets[actionColumnIndex]), gridColumn: `${actionLayoutIndex + 1} / ${actionLayoutIndex + 2}` }}>
|
||||
style={{ ...stickyStyle(actionColumn, stickyOffsets[actionColumnIndex]), gridColumn: `${actionColumnIndex + 1} / ${actionColumnIndex + 2}` }}>
|
||||
|
||||
{emptyAction}
|
||||
</div>
|
||||
@@ -824,9 +726,6 @@ export default function DataGrid<T>({
|
||||
const lastRowClass = visibleIndex === renderedRows.length - 1 ? "is-last-row" : "";
|
||||
const cells: ReactNode[] = [];
|
||||
columns.forEach((column, columnIndex) => {
|
||||
if (shouldRenderBuffer && bufferInsertIndex === columnIndex) {
|
||||
cells.push(bufferCell(`${rowKey}-buffer`, `data-grid-body-cell ${parityClass} ${lastRowClass}`, "cell"));
|
||||
}
|
||||
cells.push(
|
||||
<div
|
||||
key={`${rowKey}-${column.id}`}
|
||||
@@ -838,9 +737,6 @@ export default function DataGrid<T>({
|
||||
</div>
|
||||
);
|
||||
});
|
||||
if (shouldRenderBuffer && bufferInsertIndex === columns.length) {
|
||||
cells.push(bufferCell(`${rowKey}-buffer-end`, `data-grid-body-cell ${parityClass} ${lastRowClass}`, "cell"));
|
||||
}
|
||||
return cells;
|
||||
})}
|
||||
</div>
|
||||
@@ -1369,7 +1265,9 @@ layoutSignature: string)
|
||||
const column = columnMap.get(columnId);
|
||||
if (!column || !Number.isFinite(width)) return [];
|
||||
const minimum = effectiveColumnMinWidth(column);
|
||||
const maximum = effectiveColumnMaxWidth(column, minimum);
|
||||
const maximum = resizeBehavior === "free"
|
||||
? effectiveColumnMaxWidth(column, minimum)
|
||||
: DATA_GRID_MAX_TRACK_WIDTH;
|
||||
return [[columnId, Math.min(maximum, Math.max(minimum, width))]];
|
||||
})
|
||||
);
|
||||
@@ -1377,103 +1275,33 @@ layoutSignature: string)
|
||||
const nextUserWidths = signatureMatches ? sanitizeWidths(state.userWidths) : {};
|
||||
const normalizedWidths = Object.keys(nextWidths).length > 0 ? roundWidthRecord(nextWidths) : undefined;
|
||||
const normalizedUserWidths = Object.keys(nextUserWidths).length > 0 ? roundWidthRecord(nextUserWidths) : undefined;
|
||||
const hasContainerResizeColumn = columns.some(isContainerResizeColumn);
|
||||
const normalizedBufferWidth = resizeBehavior === "free" || !signatureMatches ?
|
||||
undefined :
|
||||
normalizeBufferWidth(state.bufferWidth ?? 0, !hasContainerResizeColumn);
|
||||
const widthsChanged = !shallowEqualNumberRecords(state.widths ?? {}, normalizedWidths ?? {});
|
||||
const userWidthsChanged = !shallowEqualNumberRecords(state.userWidths ?? {}, normalizedUserWidths ?? {});
|
||||
const bufferChanged = normalizedBufferWidth !== state.bufferWidth;
|
||||
const fillChanged = state.fillColumnId !== undefined;
|
||||
const signatureChanged = state.layoutSignature !== layoutSignature;
|
||||
if (!widthsChanged && !userWidthsChanged && !bufferChanged && !fillChanged && !signatureChanged) return state;
|
||||
if (!widthsChanged && !userWidthsChanged && !fillChanged && !signatureChanged) return state;
|
||||
return {
|
||||
...state,
|
||||
widths: normalizedWidths,
|
||||
userWidths: normalizedUserWidths,
|
||||
layoutSignature,
|
||||
fillColumnId: undefined,
|
||||
bufferWidth: normalizedBufferWidth
|
||||
fillColumnId: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function widthForColumn<T>(column: DataGridColumn<T>, savedWidth?: number): string {
|
||||
const minimum = effectiveColumnMinWidth(column);
|
||||
const maximum = effectiveColumnMaxWidth(column, minimum);
|
||||
if (savedWidth !== undefined) return `${Math.min(maximum, Math.max(minimum, savedWidth))}px`;
|
||||
if (savedWidth !== undefined) return `${Math.min(DATA_GRID_MAX_TRACK_WIDTH, Math.max(minimum, savedWidth))}px`;
|
||||
if (typeof column.width === "number") return `${Math.min(maximum, Math.max(minimum, column.width))}px`;
|
||||
if (column.width) return columnTrackWithMinimum(column.width, minimum);
|
||||
return `minmax(${minimum}px, 1fr)`;
|
||||
}
|
||||
|
||||
function findBufferInsertIndex<T>(columns: DataGridColumn<T>[]): number {
|
||||
let index = columns.length;
|
||||
while (index > 0 && columns[index - 1].sticky === "end") index -= 1;
|
||||
return index;
|
||||
}
|
||||
|
||||
function isContainerResizeColumn<T>(column: DataGridColumn<T>): boolean {
|
||||
return isFlexibleWidth(column.width, column.fill);
|
||||
}
|
||||
|
||||
function isResizeCompensationColumn<T>(column: DataGridColumn<T>): boolean {
|
||||
return Boolean(column.resizable && !column.sticky);
|
||||
}
|
||||
|
||||
function resizeTargetForColumn<T>(column: DataGridColumn<T>, baseWidths: Record<string, number>): ResizeTarget {
|
||||
const minimum = effectiveColumnMinWidth(column);
|
||||
return {
|
||||
columnId: column.id,
|
||||
startWidth: baseWidths[column.id] ?? columnPixelWidth(column),
|
||||
minWidth: minimum,
|
||||
maxWidth: effectiveColumnMaxWidth(column, minimum),
|
||||
weight: dataGridColumnResizeWeight(column)
|
||||
};
|
||||
}
|
||||
|
||||
function distributeWithFallback(
|
||||
requestedAmount: number,
|
||||
targets: ResizeTarget[],
|
||||
fallback?: ResizeTarget)
|
||||
: {applied: number;amounts: Record<string, number>;} {
|
||||
if (!fallback) return distributeResizeAmount(requestedAmount, targets);
|
||||
|
||||
// The synthetic buffer is residual coverage, not a peer of the data
|
||||
// columns. Positive space is offered to real resizable columns first and
|
||||
// reaches the buffer only after they hit maxWidth. Negative space consumes
|
||||
// the buffer first; only the remaining deficit is distributed across real
|
||||
// columns. This keeps a buffer created on a wide viewport from surviving a
|
||||
// later viewport shrink while ordinary columns are reduced around it.
|
||||
const bufferFirst = requestedAmount < 0 && fallback.startWidth > fallback.minWidth + 0.01;
|
||||
const primaryTargets = bufferFirst ? [fallback] : targets;
|
||||
const secondaryTargets = bufferFirst ? targets : [fallback];
|
||||
const primary = distributeResizeAmount(requestedAmount, primaryTargets);
|
||||
const remaining = requestedAmount - primary.applied;
|
||||
if (Math.abs(remaining) <= 0.01 || secondaryTargets.length === 0) return primary;
|
||||
const secondary = distributeResizeAmount(remaining, secondaryTargets);
|
||||
return {
|
||||
applied: primary.applied + secondary.applied,
|
||||
amounts: { ...primary.amounts, ...secondary.amounts }
|
||||
};
|
||||
}
|
||||
|
||||
function applyResizeAmounts(
|
||||
widths: Record<string, number>,
|
||||
bufferWidth: number,
|
||||
amounts: Record<string, number>)
|
||||
: {bufferWidth: number;} {
|
||||
for (const [columnId, amount] of Object.entries(amounts)) {
|
||||
if (columnId === BUFFER_COLUMN_ID) bufferWidth += amount;else
|
||||
widths[columnId] = (widths[columnId] ?? 0) + amount;
|
||||
}
|
||||
return { bufferWidth: Math.max(0, bufferWidth) };
|
||||
}
|
||||
|
||||
function normalizeBufferWidth(width: number, preserveZero: boolean): number | undefined {
|
||||
const normalized = Math.round(Math.max(0, width) * 100) / 100;
|
||||
return normalized > 0.01 || preserveZero ? normalized : undefined;
|
||||
}
|
||||
|
||||
function measuredColumnWidths<T>(
|
||||
columns: DataGridColumn<T>[],
|
||||
elements: Record<string, HTMLDivElement | null>,
|
||||
@@ -1484,9 +1312,12 @@ measuredWidths?: Record<string, number>)
|
||||
for (const column of columns) {
|
||||
const element = elements[column.id];
|
||||
const measured = element ? Math.round(element.getBoundingClientRect().width) : undefined;
|
||||
result[column.id] = columnPixelWidth(
|
||||
const savedWidth = savedWidths?.[column.id];
|
||||
result[column.id] = savedWidth !== undefined
|
||||
? Math.min(DATA_GRID_MAX_TRACK_WIDTH, Math.max(effectiveColumnMinWidth(column), savedWidth))
|
||||
: columnPixelWidth(
|
||||
column,
|
||||
savedWidths?.[column.id],
|
||||
undefined,
|
||||
measured && measured > 0 ? measured : measuredWidths?.[column.id]
|
||||
);
|
||||
}
|
||||
@@ -1504,7 +1335,7 @@ function computeStickyOffsets<T>(columns: DataGridColumn<T>[], savedWidths?: Rec
|
||||
columns.forEach((column, index) => {
|
||||
if (column.sticky === "start") {
|
||||
offsets[index] = left;
|
||||
left += columnPixelWidth(column, savedWidths?.[column.id], measuredWidths?.[column.id]);
|
||||
left += renderedColumnPixelWidth(column, savedWidths?.[column.id], measuredWidths?.[column.id]);
|
||||
}
|
||||
});
|
||||
let right = 0;
|
||||
@@ -1512,12 +1343,22 @@ function computeStickyOffsets<T>(columns: DataGridColumn<T>[], savedWidths?: Rec
|
||||
const index = columns.length - 1 - reverseIndex;
|
||||
if (column.sticky === "end") {
|
||||
offsets[index] = right;
|
||||
right += columnPixelWidth(column, savedWidths?.[column.id], measuredWidths?.[column.id]);
|
||||
right += renderedColumnPixelWidth(column, savedWidths?.[column.id], measuredWidths?.[column.id]);
|
||||
}
|
||||
});
|
||||
return offsets;
|
||||
}
|
||||
|
||||
function renderedColumnPixelWidth<T>(
|
||||
column: DataGridColumn<T>,
|
||||
savedWidth?: number,
|
||||
measuredWidth?: number
|
||||
): number {
|
||||
return savedWidth !== undefined
|
||||
? Math.min(DATA_GRID_MAX_TRACK_WIDTH, Math.max(effectiveColumnMinWidth(column), savedWidth))
|
||||
: columnPixelWidth(column, undefined, measuredWidth);
|
||||
}
|
||||
|
||||
function stickyClass<T>(column: DataGridColumn<T>): string {
|
||||
if (column.sticky === "start") return "is-sticky-start";
|
||||
if (column.sticky === "end") return "is-sticky-end";
|
||||
|
||||
@@ -19,12 +19,19 @@ export type DataGridResizeTarget = {
|
||||
weight?: number;
|
||||
};
|
||||
|
||||
export type DataGridFitMode = "free" | "cover" | "constrained";
|
||||
|
||||
export type DataGridColumnLayout = {
|
||||
widths: Record<string, number>;
|
||||
bufferWidth: number;
|
||||
underflowWidth: number;
|
||||
overflowWidth: number;
|
||||
};
|
||||
|
||||
export type DataGridColumnResize = {
|
||||
widths: Record<string, number>;
|
||||
appliedDelta: number;
|
||||
};
|
||||
|
||||
const MIN_HEADER_LABEL_WIDTH = 72;
|
||||
const SORT_CONTROL_RESERVE = 24;
|
||||
const FILTER_CONTROL_RESERVE = 32;
|
||||
@@ -125,7 +132,8 @@ export function fitDataGridColumns(
|
||||
columns: DataGridSizingColumn[],
|
||||
containerWidth: number,
|
||||
measuredWidths: Record<string, number> = {},
|
||||
userWidths: Record<string, number> = {}
|
||||
userWidths: Record<string, number> = {},
|
||||
fitMode: DataGridFitMode = "cover"
|
||||
): DataGridColumnLayout {
|
||||
const safeContainerWidth = Math.max(0, containerWidth);
|
||||
const widths: Record<string, number> = {};
|
||||
@@ -135,37 +143,160 @@ export function fitDataGridColumns(
|
||||
const maximum = effectiveDataGridColumnMaxWidth(column, minimum);
|
||||
const override = userWidths[column.id];
|
||||
widths[column.id] = override !== undefined && Number.isFinite(override)
|
||||
? clampWidth(override, minimum, maximum)
|
||||
? clampWidth(
|
||||
override,
|
||||
minimum,
|
||||
fitMode === "cover" ? DATA_GRID_MAX_TRACK_WIDTH : maximum
|
||||
)
|
||||
: preferredDataGridColumnWidth(column, safeContainerWidth, measuredWidths[column.id]);
|
||||
}
|
||||
|
||||
const preferredTotal = totalDataGridColumnWidth(columns, widths);
|
||||
const requestedAmount = safeContainerWidth - preferredTotal;
|
||||
const targets = columns
|
||||
.filter((column) => userWidths[column.id] === undefined)
|
||||
.filter((column) => requestedAmount >= 0
|
||||
let remaining = safeContainerWidth - preferredTotal;
|
||||
const ordinaryColumns = columns.filter((column) => !column.sticky);
|
||||
const coverageColumns = ordinaryColumns.length > 0 ? ordinaryColumns : columns;
|
||||
const automaticColumns = coverageColumns.filter((column) => userWidths[column.id] === undefined);
|
||||
const responsiveColumns = automaticColumns.filter((column) =>
|
||||
isFlexibleDataGridWidth(column.width, column.fill)
|
||||
);
|
||||
|
||||
if (fitMode === "free") {
|
||||
const targets = automaticColumns
|
||||
.filter((column) => remaining >= 0
|
||||
? dataGridColumnGrowthWeight(column) > 0
|
||||
: isFlexibleDataGridWidth(column.width, column.fill) || Boolean(column.resizable))
|
||||
.map((column): DataGridResizeTarget => ({
|
||||
columnId: column.id,
|
||||
startWidth: widths[column.id],
|
||||
minWidth: effectiveDataGridColumnMinWidth(column),
|
||||
maxWidth: effectiveDataGridColumnMaxWidth(column),
|
||||
weight: requestedAmount >= 0
|
||||
? dataGridColumnGrowthWeight(column)
|
||||
: dataGridColumnResizeWeight(column)
|
||||
}));
|
||||
const distribution = distributeDataGridResizeAmount(requestedAmount, targets);
|
||||
|
||||
for (const [columnId, amount] of Object.entries(distribution.amounts)) {
|
||||
widths[columnId] += amount;
|
||||
.map((column) => resizeTargetForLayout(
|
||||
column,
|
||||
widths,
|
||||
remaining >= 0 ? dataGridColumnGrowthWeight(column) : dataGridColumnResizeWeight(column)
|
||||
));
|
||||
remaining = applyDataGridDistribution(widths, remaining, targets);
|
||||
} else if (remaining > 0.01) {
|
||||
remaining = applyDataGridDistribution(
|
||||
widths,
|
||||
remaining,
|
||||
responsiveColumns.map((column) =>
|
||||
resizeTargetForLayout(column, widths, dataGridColumnGrowthWeight(column))
|
||||
)
|
||||
);
|
||||
remaining = applyDataGridDistribution(
|
||||
widths,
|
||||
remaining,
|
||||
automaticColumns.map((column) =>
|
||||
resizeTargetForLayout(column, widths, widths[column.id])
|
||||
)
|
||||
);
|
||||
// Cover is a stronger invariant than maxWidth. Once preferred maxima have
|
||||
// been exhausted, ordinary data columns absorb the final residual space.
|
||||
remaining = applyDataGridDistribution(
|
||||
widths,
|
||||
remaining,
|
||||
coverageColumns.map((column) =>
|
||||
resizeTargetForLayout(column, widths, widths[column.id], DATA_GRID_MAX_TRACK_WIDTH)
|
||||
)
|
||||
);
|
||||
} else if (remaining < -0.01) {
|
||||
remaining = applyDataGridDistribution(
|
||||
widths,
|
||||
remaining,
|
||||
responsiveColumns
|
||||
.filter((column) => column.resizable || isFlexibleDataGridWidth(column.width, column.fill))
|
||||
.map((column) => resizeTargetForLayout(column, widths, widths[column.id]))
|
||||
);
|
||||
remaining = applyDataGridDistribution(
|
||||
widths,
|
||||
remaining,
|
||||
automaticColumns.map((column) =>
|
||||
resizeTargetForLayout(column, widths, widths[column.id])
|
||||
)
|
||||
);
|
||||
// A persisted user layout is preferred, not a reason to overflow. Under
|
||||
// viewport pressure every ordinary track may shrink down to its hard floor.
|
||||
remaining = applyDataGridDistribution(
|
||||
widths,
|
||||
remaining,
|
||||
coverageColumns.map((column) =>
|
||||
resizeTargetForLayout(column, widths, widths[column.id])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const remaining = requestedAmount - distribution.applied;
|
||||
const roundedWidths = roundDataGridWidths(widths);
|
||||
closeCoveredRoundingGap(columns, roundedWidths, safeContainerWidth, fitMode, remaining);
|
||||
const renderedTotal = totalDataGridColumnWidth(columns, roundedWidths);
|
||||
const renderedDifference = safeContainerWidth - renderedTotal;
|
||||
return {
|
||||
widths: roundedWidths,
|
||||
underflowWidth: roundWidth(Math.max(0, renderedDifference)),
|
||||
overflowWidth: roundWidth(Math.max(0, -renderedDifference))
|
||||
};
|
||||
}
|
||||
|
||||
export function resizeDataGridColumn(
|
||||
columns: DataGridSizingColumn[],
|
||||
baseWidths: Record<string, number>,
|
||||
columnId: string,
|
||||
requestedDelta: number,
|
||||
fitMode: DataGridFitMode,
|
||||
uncompensatedShrinkRoom = 0
|
||||
): DataGridColumnResize {
|
||||
const activeIndex = columns.findIndex((column) => column.id === columnId);
|
||||
const activeColumn = columns[activeIndex];
|
||||
if (!activeColumn) return { widths: roundDataGridWidths(baseWidths), appliedDelta: 0 };
|
||||
|
||||
const startWidth = baseWidths[columnId]
|
||||
?? dataGridColumnPixelWidth(activeColumn);
|
||||
const minimum = effectiveDataGridColumnMinWidth(activeColumn);
|
||||
// Cover fitting may have expanded this track beyond its preferred maximum.
|
||||
// A pointer move must never snap such a track backwards before applying the
|
||||
// user's requested direction.
|
||||
const maximum = Math.max(
|
||||
startWidth,
|
||||
effectiveDataGridColumnMaxWidth(activeColumn, minimum)
|
||||
);
|
||||
const boundedDelta = Math.min(
|
||||
maximum - startWidth,
|
||||
Math.max(minimum - startWidth, requestedDelta)
|
||||
);
|
||||
const widths = { ...baseWidths };
|
||||
|
||||
if (fitMode === "free") {
|
||||
widths[columnId] = startWidth + boundedDelta;
|
||||
return {
|
||||
widths: roundDataGridWidths(widths),
|
||||
bufferWidth: Math.round(Math.max(0, remaining) * 100) / 100,
|
||||
overflowWidth: Math.round(Math.max(0, -remaining) * 100) / 100
|
||||
appliedDelta: roundWidth(boundedDelta)
|
||||
};
|
||||
}
|
||||
|
||||
const freeShrink = boundedDelta < 0
|
||||
? Math.min(-boundedDelta, Math.max(0, uncompensatedShrinkRoom))
|
||||
: 0;
|
||||
const uncompensatedDelta = -freeShrink;
|
||||
const compensationRequest = -(boundedDelta - uncompensatedDelta);
|
||||
let remainingCompensation = compensationRequest;
|
||||
let appliedCompensation = 0;
|
||||
|
||||
for (const group of resizeCompensationGroups(columns, activeIndex)) {
|
||||
if (Math.abs(remainingCompensation) <= 0.01) break;
|
||||
const targets = group.map((column) =>
|
||||
resizeTargetForLayout(
|
||||
column,
|
||||
widths,
|
||||
dataGridColumnResizeWeight(column),
|
||||
fitMode === "cover" ? DATA_GRID_MAX_TRACK_WIDTH : undefined
|
||||
)
|
||||
);
|
||||
const distribution = distributeDataGridResizeAmount(remainingCompensation, targets);
|
||||
applyDataGridAmounts(widths, distribution.amounts);
|
||||
appliedCompensation += distribution.applied;
|
||||
remainingCompensation -= distribution.applied;
|
||||
}
|
||||
|
||||
const appliedDelta = uncompensatedDelta - appliedCompensation;
|
||||
widths[columnId] = startWidth + appliedDelta;
|
||||
return {
|
||||
widths: roundDataGridWidths(widths),
|
||||
appliedDelta: roundWidth(appliedDelta)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -261,7 +392,74 @@ function dataGridColumnGrowthWeight(column: DataGridSizingColumn): number {
|
||||
if (column.fill || column.width === undefined) return 1;
|
||||
if (typeof column.width !== "string") return 0;
|
||||
const preferred = minmaxParts(column.width)?.preferred ?? column.width;
|
||||
return parseFractionTrack(preferred) ?? 0;
|
||||
return parseFractionTrack(preferred) ?? parsePercentageTrack(preferred) ?? 0;
|
||||
}
|
||||
|
||||
function resizeTargetForLayout(
|
||||
column: DataGridSizingColumn,
|
||||
widths: Record<string, number>,
|
||||
weight: number,
|
||||
maximum?: number
|
||||
): DataGridResizeTarget {
|
||||
const minimum = effectiveDataGridColumnMinWidth(column);
|
||||
return {
|
||||
columnId: column.id,
|
||||
startWidth: widths[column.id] ?? dataGridColumnPixelWidth(column),
|
||||
minWidth: minimum,
|
||||
maxWidth: maximum ?? effectiveDataGridColumnMaxWidth(column, minimum),
|
||||
weight
|
||||
};
|
||||
}
|
||||
|
||||
function applyDataGridDistribution(
|
||||
widths: Record<string, number>,
|
||||
requestedAmount: number,
|
||||
targets: DataGridResizeTarget[]
|
||||
): number {
|
||||
if (Math.abs(requestedAmount) <= 0.01 || targets.length === 0) return requestedAmount;
|
||||
const distribution = distributeDataGridResizeAmount(requestedAmount, targets);
|
||||
applyDataGridAmounts(widths, distribution.amounts);
|
||||
return requestedAmount - distribution.applied;
|
||||
}
|
||||
|
||||
function applyDataGridAmounts(
|
||||
widths: Record<string, number>,
|
||||
amounts: Record<string, number>
|
||||
): void {
|
||||
for (const [columnId, amount] of Object.entries(amounts)) {
|
||||
widths[columnId] = (widths[columnId] ?? 0) + amount;
|
||||
}
|
||||
}
|
||||
|
||||
function resizeCompensationGroups(
|
||||
columns: DataGridSizingColumn[],
|
||||
activeIndex: number
|
||||
): DataGridSizingColumn[][] {
|
||||
const right = columns.slice(activeIndex + 1).filter((column) => !column.sticky);
|
||||
const left = columns.slice(0, activeIndex).reverse().filter((column) => !column.sticky);
|
||||
return [
|
||||
right.filter((column) => column.resizable),
|
||||
right.filter((column) => !column.resizable),
|
||||
left.filter((column) => column.resizable),
|
||||
left.filter((column) => !column.resizable)
|
||||
].filter((group) => group.length > 0);
|
||||
}
|
||||
|
||||
function closeCoveredRoundingGap(
|
||||
columns: DataGridSizingColumn[],
|
||||
widths: Record<string, number>,
|
||||
containerWidth: number,
|
||||
fitMode: DataGridFitMode,
|
||||
unallocatedAmount: number
|
||||
): void {
|
||||
if (fitMode !== "cover" || Math.abs(unallocatedAmount) > 0.01) return;
|
||||
const roundedTotal = totalDataGridColumnWidth(columns, widths);
|
||||
const correction = roundWidth(containerWidth - roundedTotal);
|
||||
if (Math.abs(correction) <= 0.001) return;
|
||||
const ordinaryColumns = columns.filter((column) => !column.sticky);
|
||||
const target = ordinaryColumns[ordinaryColumns.length - 1] ?? columns[columns.length - 1];
|
||||
if (!target) return;
|
||||
widths[target.id] = roundWidth((widths[target.id] ?? 0) + correction);
|
||||
}
|
||||
|
||||
function parsePixelTrack(track?: string): number | null {
|
||||
@@ -301,3 +499,7 @@ function normalizedWeight(weight?: number): number {
|
||||
function clampWidth(width: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, width));
|
||||
}
|
||||
|
||||
function roundWidth(width: number): number {
|
||||
return Math.round(width * 100) / 100;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export * from "./platform/ModuleContext";
|
||||
export * from "./platform/moduleEvents";
|
||||
export * from "./platform/ViewContext";
|
||||
export * from "./platform/views";
|
||||
export * from "./platform/wizards";
|
||||
|
||||
export * from "./utils/permissions";
|
||||
export * from "./utils/fieldHelp";
|
||||
@@ -85,6 +86,8 @@ export { default as DisabledActionTooltip } from "./components/DisabledActionToo
|
||||
export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip";
|
||||
export { default as DismissibleAlert } from "./components/DismissibleAlert";
|
||||
export { default as ViewSurfaceRouteBoundary } from "./components/ViewSurfaceRouteBoundary";
|
||||
export { default as WizardDirectory } from "./components/WizardDirectory";
|
||||
export type { WizardDirectoryProps } from "./components/WizardDirectory";
|
||||
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
|
||||
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
|
||||
export { default as FileDropZone } from "./components/FileDropZone";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
WizardDirectoriesUiCapability,
|
||||
WizardDirectoryContext,
|
||||
WizardDirectoryEntry
|
||||
} from "../types";
|
||||
|
||||
export function wizardEntriesForContext(
|
||||
capabilities: WizardDirectoriesUiCapability[],
|
||||
context: WizardDirectoryContext
|
||||
): WizardDirectoryEntry[] {
|
||||
const entries = capabilities
|
||||
.flatMap((capability) => capability.directories)
|
||||
.filter((directory) => directory.contextId === context.contextId)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(left.order ?? 100) - (right.order ?? 100)
|
||||
|| left.id.localeCompare(right.id)
|
||||
)
|
||||
.flatMap((directory) => directory.entries(context));
|
||||
|
||||
const unique = new Map<string, WizardDirectoryEntry>();
|
||||
for (const entry of entries) {
|
||||
const key = `${entry.moduleId}:${entry.id}`;
|
||||
if (!unique.has(key)) unique.set(key, entry);
|
||||
}
|
||||
return [...unique.values()].sort(
|
||||
(left, right) =>
|
||||
(left.order ?? 100) - (right.order ?? 100)
|
||||
|| left.label.localeCompare(right.label)
|
||||
);
|
||||
}
|
||||
@@ -165,6 +165,21 @@
|
||||
.wizard-heading h1 { margin: 0; }
|
||||
.save-state { color: var(--green); font-size: 13px; font-weight: 700; }
|
||||
.wizard-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
|
||||
.wizard-directory-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.wizard-directory-grid > .card {
|
||||
min-height: 164px;
|
||||
}
|
||||
.wizard-directory-grid > .card .card-body {
|
||||
color: var(--muted);
|
||||
}
|
||||
.wizard-directory-grid > .card .card-body p {
|
||||
margin: 0;
|
||||
}
|
||||
.stepper { list-style: none; padding: 22px 0; margin: 0; background: var(--panel-soft); border-right: var(--border-line); }
|
||||
.step button { width: 100%; min-height: 72px; border: 0; background: transparent; display: flex; gap: 14px; text-align: left; padding: 12px 20px; color: var(--muted); cursor: pointer; }
|
||||
.step.active button { background: var(--surface); color: var(--text-strong); }
|
||||
|
||||
@@ -226,15 +226,6 @@
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.data-grid-buffer-cell {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
border-right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.data-grid-container .data-grid {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
@@ -266,6 +266,38 @@ export type PlatformRouteContext = {
|
||||
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
};
|
||||
|
||||
export type WizardDirectoryContext = PlatformRouteContext & {
|
||||
contextId: string;
|
||||
resourceId?: string;
|
||||
};
|
||||
|
||||
export type WizardDirectoryEntry = {
|
||||
id: string;
|
||||
moduleId: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
to: string;
|
||||
actionLabel?: string;
|
||||
category?: string;
|
||||
order?: number;
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
available?: boolean;
|
||||
unavailableReason?: string;
|
||||
};
|
||||
|
||||
export type WizardDirectoryContribution = {
|
||||
id: string;
|
||||
moduleId: string;
|
||||
contextId: string;
|
||||
order?: number;
|
||||
entries: (context: WizardDirectoryContext) => WizardDirectoryEntry[];
|
||||
};
|
||||
|
||||
export type WizardDirectoriesUiCapability = {
|
||||
directories: WizardDirectoryContribution[];
|
||||
};
|
||||
|
||||
export type PlatformPublicRouteContext = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo | null;
|
||||
|
||||
@@ -129,3 +129,21 @@ const externalShortcutMarkup = renderToStaticMarkup(
|
||||
);
|
||||
assertEqual(externalShortcutMarkup.includes("Target alpha"), true, "an external count shortcut synchronizes the grid query");
|
||||
assertEqual(externalShortcutMarkup.includes("Ordinary row"), false, "the synchronized query does not disagree with visible rows");
|
||||
|
||||
const fixedCoverMarkup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
id="fixed-cover-regression"
|
||||
rows={[{ id: "row", first: "First", second: "Second" }]}
|
||||
columns={[
|
||||
{ id: "first", header: "First", width: 100, value: (row) => row.first },
|
||||
{ id: "second", header: "Second", width: 200, value: (row) => row.second },
|
||||
{ id: "actions", header: "Actions", width: 72, sticky: "end", value: () => "" }
|
||||
]}
|
||||
getRowKey={(row) => row.id}
|
||||
/>
|
||||
);
|
||||
assertEqual(
|
||||
fixedCoverMarkup.includes("data-grid-buffer-cell"),
|
||||
false,
|
||||
"cover grids never render a synthetic blank column"
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
distributeDataGridResizeAmount,
|
||||
effectiveDataGridColumnMinWidth,
|
||||
fitDataGridColumns,
|
||||
resizeDataGridColumn,
|
||||
type DataGridSizingColumn
|
||||
} from "../src/components/table/dataGridSizing";
|
||||
|
||||
@@ -45,7 +46,7 @@ assertWidths(
|
||||
{ fixed: 100, primary: 200, secondary: 300 },
|
||||
"fraction tracks receive residual width by declared weight"
|
||||
);
|
||||
assertEqual(weightedLayout.bufferWidth, 0, "fraction tracks cover the available container");
|
||||
assertEqual(weightedLayout.underflowWidth, 0, "fraction tracks cover the available container");
|
||||
|
||||
const boundedLayout = fitDataGridColumns([
|
||||
{ id: "fixed", width: 100 },
|
||||
@@ -54,10 +55,10 @@ const boundedLayout = fitDataGridColumns([
|
||||
], 600);
|
||||
assertWidths(
|
||||
boundedLayout.widths,
|
||||
{ fixed: 100, primary: 150, secondary: 200 },
|
||||
"fraction tracks stop at their declared maxima"
|
||||
{ fixed: 250, primary: 150, secondary: 200 },
|
||||
"real columns absorb cover space after fraction tracks reach their preferred maxima"
|
||||
);
|
||||
assertEqual(boundedLayout.bufferWidth, 150, "space beyond column maxima becomes the synthetic buffer");
|
||||
assertEqual(boundedLayout.underflowWidth, 0, "cover layouts never create a synthetic filler track");
|
||||
|
||||
const overflowLayout = fitDataGridColumns([
|
||||
{ id: "fixed", width: 200 },
|
||||
@@ -66,10 +67,10 @@ const overflowLayout = fitDataGridColumns([
|
||||
], 600);
|
||||
assertWidths(
|
||||
overflowLayout.widths,
|
||||
{ fixed: 200, primary: 320, secondary: 260 },
|
||||
"columns never shrink below declared minima"
|
||||
{ fixed: 72, primary: 320, secondary: 260 },
|
||||
"ordinary fixed tracks also shrink before a cover layout overflows"
|
||||
);
|
||||
assertEqual(overflowLayout.overflowWidth, 180, "minimum-width excess remains horizontal overflow");
|
||||
assertEqual(overflowLayout.overflowWidth, 52, "hard minimum-width excess remains horizontal overflow");
|
||||
|
||||
const percentageLayout = fitDataGridColumns([
|
||||
{ id: "fixed", width: 100 },
|
||||
@@ -78,10 +79,10 @@ const percentageLayout = fitDataGridColumns([
|
||||
], 1000);
|
||||
assertWidths(
|
||||
percentageLayout.widths,
|
||||
{ fixed: 100, primary: 300, secondary: 200 },
|
||||
"percentage tracks retain their preferred container share"
|
||||
{ fixed: 100, primary: 540, secondary: 360 },
|
||||
"percentage tracks absorb residual cover space proportionally"
|
||||
);
|
||||
assertEqual(percentageLayout.bufferWidth, 400, "unused percentage space remains a neutral buffer");
|
||||
assertEqual(percentageLayout.underflowWidth, 0, "percentage layouts cover the complete container");
|
||||
|
||||
const overriddenLayout = fitDataGridColumns(weightedColumns, 800, {}, { primary: 450 });
|
||||
assertWidths(
|
||||
@@ -93,20 +94,20 @@ assertWidths(
|
||||
const overriddenOverflowLayout = fitDataGridColumns(weightedColumns, 500, {}, { primary: 450 });
|
||||
assertWidths(
|
||||
overriddenOverflowLayout.widths,
|
||||
{ fixed: 100, primary: 450, secondary: 100 },
|
||||
"a narrow viewport preserves the user override without squashing peers below their minima"
|
||||
{ fixed: 72, primary: 328, secondary: 100 },
|
||||
"a cover layout treats a persisted user width as preferred under viewport pressure"
|
||||
);
|
||||
assertEqual(
|
||||
overriddenOverflowLayout.overflowWidth,
|
||||
150,
|
||||
"a user override that cannot fit becomes horizontal overflow"
|
||||
0,
|
||||
"a persisted user preference does not create avoidable horizontal overflow"
|
||||
);
|
||||
|
||||
const clampedOverrideLayout = fitDataGridColumns(weightedColumns, 800, {}, { primary: 900 });
|
||||
const clampedOverrideLayout = fitDataGridColumns(weightedColumns, 800, {}, { primary: 900 }, "free");
|
||||
assertEqual(
|
||||
clampedOverrideLayout.widths.primary,
|
||||
500,
|
||||
"user overrides remain bounded by the column maximum"
|
||||
"free-layout user overrides remain bounded by the column maximum"
|
||||
);
|
||||
|
||||
const redistributedGrowth = distributeDataGridResizeAmount(300, [
|
||||
@@ -141,6 +142,120 @@ assertWidths(
|
||||
"legacy fill columns remain stable under the explicit sizing contract"
|
||||
);
|
||||
|
||||
const fixedCoverColumns: DataGridSizingColumn[] = [
|
||||
{ id: "first", width: 100 },
|
||||
{ id: "second", width: 200 },
|
||||
{ id: "actions", width: 80, sticky: "end" }
|
||||
];
|
||||
const fixedCoverLayout = fitDataGridColumns(fixedCoverColumns, 760);
|
||||
assertWidths(
|
||||
fixedCoverLayout.widths,
|
||||
{ first: 226.67, second: 453.33, actions: 80 },
|
||||
"all-fixed cover grids stretch ordinary columns proportionally"
|
||||
);
|
||||
assertEqual(fixedCoverLayout.underflowWidth, 0, "all-fixed cover grids leave no blank track");
|
||||
|
||||
const softMaximumLayout = fitDataGridColumns([
|
||||
{ id: "first", width: 100, maxWidth: 100 },
|
||||
{ id: "second", width: 200, maxWidth: 200 },
|
||||
{ id: "actions", width: 80, sticky: "end" }
|
||||
], 680);
|
||||
assertWidths(
|
||||
softMaximumLayout.widths,
|
||||
{ first: 200, second: 400, actions: 80 },
|
||||
"coverage may exceed preferred maxima instead of rendering a filler column"
|
||||
);
|
||||
|
||||
const resizeColumns: DataGridSizingColumn[] = [
|
||||
{ id: "first", width: 200, minWidth: 100, maxWidth: 500, resizable: true },
|
||||
{ id: "middle", width: 300, minWidth: 100, maxWidth: 500, resizable: true },
|
||||
{ id: "last", width: 300, minWidth: 100, maxWidth: 500, resizable: true },
|
||||
{ id: "actions", width: 100, sticky: "end" }
|
||||
];
|
||||
const resizeBase = { first: 200, middle: 300, last: 300, actions: 100 };
|
||||
const resizedFirst = resizeDataGridColumn(resizeColumns, resizeBase, "first", 120, "cover");
|
||||
assertWidths(
|
||||
resizedFirst.widths,
|
||||
{ first: 320, middle: 240, last: 240, actions: 100 },
|
||||
"right-side peers compensate an earlier cover-column resize"
|
||||
);
|
||||
|
||||
const resizedLast = resizeDataGridColumn(resizeColumns, resizeBase, "last", 120, "cover");
|
||||
assertWidths(
|
||||
resizedLast.widths,
|
||||
{ first: 140, middle: 240, last: 420, actions: 100 },
|
||||
"left-side peers compensate the last resizable cover column"
|
||||
);
|
||||
|
||||
const committedLayout = fitDataGridColumns(
|
||||
resizeColumns,
|
||||
900,
|
||||
{},
|
||||
resizedLast.widths,
|
||||
"cover"
|
||||
);
|
||||
assertWidths(
|
||||
committedLayout.widths,
|
||||
resizedLast.widths,
|
||||
"committing a cover resize preserves the drag layout without a mouse-up snap"
|
||||
);
|
||||
|
||||
const fixedPeerResize = resizeDataGridColumn([
|
||||
{ id: "editable", width: 200, minWidth: 100, maxWidth: 500, resizable: true },
|
||||
{ id: "fixed", width: 300 },
|
||||
{ id: "actions", width: 100, sticky: "end" }
|
||||
], { editable: 200, fixed: 300, actions: 100 }, "editable", 80, "cover");
|
||||
assertWidths(
|
||||
fixedPeerResize.widths,
|
||||
{ editable: 280, fixed: 220, actions: 100 },
|
||||
"non-resizable tracks may compensate layout without exposing a resize handle"
|
||||
);
|
||||
|
||||
const coverBeyondPassiveMax = resizeDataGridColumn([
|
||||
{ id: "first", width: 200, minWidth: 100, maxWidth: 200, resizable: true },
|
||||
{ id: "second", width: 300, minWidth: 100, maxWidth: 300, resizable: true },
|
||||
{ id: "actions", width: 100, sticky: "end" }
|
||||
], { first: 200, second: 300, actions: 100 }, "first", -80, "cover");
|
||||
assertWidths(
|
||||
coverBeyondPassiveMax.widths,
|
||||
{ first: 120, second: 380, actions: 100 },
|
||||
"cover compensation may exceed a peer's preferred maximum to retain full coverage"
|
||||
);
|
||||
|
||||
const constrainedAtPassiveMax = resizeDataGridColumn([
|
||||
{ id: "first", width: 200, minWidth: 100, maxWidth: 200, resizable: true },
|
||||
{ id: "second", width: 300, minWidth: 100, maxWidth: 300, resizable: true },
|
||||
{ id: "actions", width: 100, sticky: "end" }
|
||||
], { first: 200, second: 300, actions: 100 }, "first", -80, "constrained");
|
||||
assertWidths(
|
||||
constrainedAtPassiveMax.widths,
|
||||
{ first: 200, second: 300, actions: 100 },
|
||||
"constrained compensation stops when every peer is at its hard maximum"
|
||||
);
|
||||
|
||||
const initiallyExpandedColumn = resizeDataGridColumn([
|
||||
{ id: "first", width: 100, minWidth: 72, maxWidth: 100, resizable: true },
|
||||
{ id: "second", width: 200, minWidth: 72, resizable: true }
|
||||
], { first: 180, second: 320 }, "first", 20, "cover");
|
||||
assertEqual(
|
||||
initiallyExpandedColumn.widths.first,
|
||||
180,
|
||||
"a cover-expanded active track does not snap backwards on a growth drag"
|
||||
);
|
||||
|
||||
const freeResize = resizeDataGridColumn(
|
||||
resizeColumns,
|
||||
resizeBase,
|
||||
"last",
|
||||
120,
|
||||
"free"
|
||||
);
|
||||
assertWidths(
|
||||
freeResize.widths,
|
||||
{ first: 200, middle: 300, last: 420, actions: 100 },
|
||||
"free resizing changes only the active track"
|
||||
);
|
||||
|
||||
const originalSignature = dataGridLayoutSignature(weightedColumns, "container", "cover");
|
||||
const changedSignature = dataGridLayoutSignature(
|
||||
weightedColumns.map((column) => column.id === "primary" ? { ...column, maxWidth: 420 } : column),
|
||||
|
||||
@@ -34,6 +34,7 @@ const defaultWebModulePackages = [
|
||||
"@govoplan/risk-compliance-webui",
|
||||
"@govoplan/scheduling-webui",
|
||||
"@govoplan/search-webui",
|
||||
"@govoplan/tenancy-webui",
|
||||
"@govoplan/views-webui",
|
||||
"@govoplan/workflow-webui"
|
||||
];
|
||||
@@ -236,6 +237,7 @@ export default defineConfig({
|
||||
fileURLToPath(new URL('../../govoplan-risk-compliance/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-search/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-tenancy/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-views/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user