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
|
- 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
|
- `@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,
|
Platform and feature modules own their backend routers, models, migrations,
|
||||||
permissions, frontend packages, nav items, and route contributions. Access,
|
permissions, frontend packages, nav items, and route contributions. Access,
|
||||||
tenancy, policy, audit, and admin behavior live in their owning platform
|
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",
|
"route",
|
||||||
"capability",
|
"capability",
|
||||||
"policy",
|
"policy",
|
||||||
|
"release_catalog",
|
||||||
"configuration_package",
|
"configuration_package",
|
||||||
"wiki",
|
"wiki",
|
||||||
"repository",
|
"repository",
|
||||||
]
|
]
|
||||||
DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
|
DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
|
||||||
|
CapabilityStability = Literal["experimental", "stable", "deprecated"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -336,12 +338,25 @@ class DocumentationSourceDefinition:
|
|||||||
documentation_types: tuple[DocumentationType, ...] = ("admin",)
|
documentation_types: tuple[DocumentationType, ...] = ("admin",)
|
||||||
condition: DocumentationCondition = field(default_factory=DocumentationCondition)
|
condition: DocumentationCondition = field(default_factory=DocumentationCondition)
|
||||||
state: DocumentationSourceState = "configured"
|
state: DocumentationSourceState = "configured"
|
||||||
|
state_reason: str | None = None
|
||||||
provenance: Mapping[str, Any] = field(default_factory=dict)
|
provenance: Mapping[str, Any] = field(default_factory=dict)
|
||||||
inspection: Mapping[str, Any] = field(default_factory=dict)
|
inspection: Mapping[str, Any] = field(default_factory=dict)
|
||||||
link: DocumentationLink | None = None
|
link: DocumentationLink | None = None
|
||||||
configuration_key: str | 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):
|
class ResourceAclProvider(Protocol):
|
||||||
resource_type: str
|
resource_type: str
|
||||||
|
|
||||||
@@ -412,6 +427,7 @@ class ModuleManifest:
|
|||||||
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
|
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
|
||||||
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
|
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
|
||||||
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
|
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
|
||||||
|
capability_documentation: Mapping[str, CapabilityDocumentation] = field(default_factory=dict)
|
||||||
search_providers: tuple["SearchProviderRegistration", ...] = ()
|
search_providers: tuple["SearchProviderRegistration", ...] = ()
|
||||||
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
|
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
|
||||||
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
|
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
|
||||||
|
|||||||
@@ -614,6 +614,28 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _validate_documentation_extensions(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()
|
provider_keys: set[str] = set()
|
||||||
for registration in manifest.documentation_configuration_providers:
|
for registration in manifest.documentation_configuration_providers:
|
||||||
if not registration.keys:
|
if not registration.keys:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
DocumentationConfigurationDecision,
|
DocumentationConfigurationDecision,
|
||||||
DocumentationConfigurationProviderRegistration,
|
DocumentationConfigurationProviderRegistration,
|
||||||
DocumentationCondition,
|
DocumentationCondition,
|
||||||
@@ -116,6 +117,39 @@ class DocumentationTopicContractTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(RegistryError, "duplicate documentation configuration key"):
|
with self.assertRaisesRegex(RegistryError, "duplicate documentation configuration key"):
|
||||||
duplicate.validate()
|
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:
|
def registry_for(*topics: DocumentationTopic) -> PlatformRegistry:
|
||||||
registry = PlatformRegistry()
|
registry = PlatformRegistry()
|
||||||
|
|||||||
Generated
+20
@@ -29,6 +29,7 @@
|
|||||||
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
|
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
|
||||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||||
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
||||||
|
"@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui",
|
||||||
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||||
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui",
|
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui",
|
||||||
"@tiptap/core": "^3.29.2",
|
"@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": {
|
"../../govoplan-views/webui": {
|
||||||
"name": "@govoplan/views-webui",
|
"name": "@govoplan/views-webui",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -1305,6 +1321,10 @@
|
|||||||
"resolved": "../../govoplan-search/webui",
|
"resolved": "../../govoplan-search/webui",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@govoplan/tenancy-webui": {
|
||||||
|
"resolved": "../../govoplan-tenancy/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@govoplan/views-webui": {
|
"node_modules/@govoplan/views-webui": {
|
||||||
"resolved": "../../govoplan-views/webui",
|
"resolved": "../../govoplan-views/webui",
|
||||||
"link": true
|
"link": true
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
|
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
|
||||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||||
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
||||||
|
"@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui",
|
||||||
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||||
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui",
|
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui",
|
||||||
"@tiptap/core": "^3.29.2",
|
"@tiptap/core": "^3.29.2",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const packageByModule = {
|
|||||||
risk_compliance: "@govoplan/risk-compliance-webui",
|
risk_compliance: "@govoplan/risk-compliance-webui",
|
||||||
scheduling: "@govoplan/scheduling-webui",
|
scheduling: "@govoplan/scheduling-webui",
|
||||||
search: "@govoplan/search-webui",
|
search: "@govoplan/search-webui",
|
||||||
|
tenancy: "@govoplan/tenancy-webui",
|
||||||
views: "@govoplan/views-webui",
|
views: "@govoplan/views-webui",
|
||||||
workflow: "@govoplan/workflow-webui"
|
workflow: "@govoplan/workflow-webui"
|
||||||
};
|
};
|
||||||
@@ -30,6 +31,8 @@ const packageByModule = {
|
|||||||
const cases = [
|
const cases = [
|
||||||
{ name: "core-only", modules: [] },
|
{ name: "core-only", modules: [] },
|
||||||
{ name: "access-only", modules: ["access"] },
|
{ name: "access-only", modules: ["access"] },
|
||||||
|
{ name: "tenancy-only", modules: ["tenancy"] },
|
||||||
|
{ name: "access-with-tenancy", modules: ["access", "tenancy"] },
|
||||||
{ name: "admin-only", modules: ["admin"] },
|
{ name: "admin-only", modules: ["admin"] },
|
||||||
{ name: "addresses-only", modules: ["addresses"] },
|
{ name: "addresses-only", modules: ["addresses"] },
|
||||||
{ name: "access-with-admin", modules: ["access", "admin"] },
|
{ name: "access-with-admin", modules: ["access", "admin"] },
|
||||||
@@ -58,7 +61,7 @@ const cases = [
|
|||||||
{ name: "search-only", modules: ["search"] },
|
{ name: "search-only", modules: ["search"] },
|
||||||
{ name: "risk-compliance-only", modules: ["risk_compliance"] },
|
{ name: "risk-compliance-only", modules: ["risk_compliance"] },
|
||||||
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
{ 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;
|
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 TableActionGroup from "./TableActionGroup";
|
||||||
import { usePlatformLanguage, i18nMessage } from "../../i18n/LanguageContext";
|
import { usePlatformLanguage, i18nMessage } from "../../i18n/LanguageContext";
|
||||||
import {
|
import {
|
||||||
DATA_GRID_MAX_TRACK_WIDTH as BUFFER_MAX_WIDTH,
|
DATA_GRID_MAX_TRACK_WIDTH,
|
||||||
dataGridColumnPixelWidth as columnPixelWidth,
|
dataGridColumnPixelWidth as columnPixelWidth,
|
||||||
dataGridColumnResizeWeight,
|
|
||||||
dataGridColumnTrackWithMinimum as columnTrackWithMinimum,
|
dataGridColumnTrackWithMinimum as columnTrackWithMinimum,
|
||||||
dataGridLayoutSignature,
|
dataGridLayoutSignature,
|
||||||
dataGridWidthsForLayout,
|
dataGridWidthsForLayout,
|
||||||
distributeDataGridResizeAmount as distributeResizeAmount,
|
|
||||||
effectiveDataGridColumnMaxWidth as effectiveColumnMaxWidth,
|
effectiveDataGridColumnMaxWidth as effectiveColumnMaxWidth,
|
||||||
effectiveDataGridColumnMinWidth as effectiveColumnMinWidth,
|
effectiveDataGridColumnMinWidth as effectiveColumnMinWidth,
|
||||||
fitDataGridColumns,
|
fitDataGridColumns,
|
||||||
isFlexibleDataGridWidth as isFlexibleWidth,
|
isFlexibleDataGridWidth as isFlexibleWidth,
|
||||||
roundDataGridWidths as roundWidthRecord,
|
resizeDataGridColumn,
|
||||||
totalDataGridColumnWidth as totalColumnWidth,
|
roundDataGridWidths as roundWidthRecord
|
||||||
type DataGridResizeTarget as ResizeTarget
|
|
||||||
} from "./dataGridSizing";
|
} from "./dataGridSizing";
|
||||||
|
|
||||||
export type DataGridSortDirection = "asc" | "desc";
|
export type DataGridSortDirection = "asc" | "desc";
|
||||||
@@ -89,7 +86,10 @@ export type DataGridColumn<T> = {
|
|||||||
width?: number | string;
|
width?: number | string;
|
||||||
/** Hard lower bound. Header controls may require a larger accessible minimum. */
|
/** Hard lower bound. Header controls may require a larger accessible minimum. */
|
||||||
minWidth?: number;
|
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;
|
maxWidth?: number;
|
||||||
resizable?: boolean;
|
resizable?: boolean;
|
||||||
/** @deprecated Kept for source compatibility. Resize space is now distributed across resizable columns. */
|
/** @deprecated Kept for source compatibility. Resize space is now distributed across resizable columns. */
|
||||||
@@ -119,8 +119,6 @@ type DataGridState = {
|
|||||||
layoutSignature?: string;
|
layoutSignature?: string;
|
||||||
/** Legacy layout state; removed when persisted state is sanitized. */
|
/** Legacy layout state; removed when persisted state is sanitized. */
|
||||||
fillColumnId?: string | null;
|
fillColumnId?: string | null;
|
||||||
/** Width of the synthetic, handle-less coverage track. */
|
|
||||||
bufferWidth?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type DataGridBaseProps<T> = {
|
type DataGridBaseProps<T> = {
|
||||||
@@ -136,7 +134,11 @@ type DataGridBaseProps<T> = {
|
|||||||
initialFit?: DataGridInitialFit;
|
initialFit?: DataGridInitialFit;
|
||||||
/** @deprecated Use initialFit instead. */
|
/** @deprecated Use initialFit instead. */
|
||||||
fit?: DataGridInitialFit;
|
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;
|
resizeBehavior?: DataGridResizeBehavior;
|
||||||
className?: string;
|
className?: string;
|
||||||
rowClassName?: (row: T, index: number) => string | undefined;
|
rowClassName?: (row: T, index: number) => string | undefined;
|
||||||
@@ -190,28 +192,14 @@ type FilterPosition = {
|
|||||||
type ColumnResizeState = {
|
type ColumnResizeState = {
|
||||||
columnId: string;
|
columnId: string;
|
||||||
startX: number;
|
startX: number;
|
||||||
startWidth: number;
|
|
||||||
minWidth: number;
|
|
||||||
maxWidth: number;
|
|
||||||
baseWidths: Record<string, number>;
|
baseWidths: Record<string, number>;
|
||||||
rightResizeTargets: ResizeTarget[];
|
uncompensatedShrinkRoom: number;
|
||||||
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;
|
|
||||||
behavior: DataGridResizeBehavior;
|
behavior: DataGridResizeBehavior;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_PREFIX = "govoplan.datagrid.";
|
const STORAGE_PREFIX = "govoplan.datagrid.";
|
||||||
const FILTER_POPOVER_WIDTH = 320;
|
const FILTER_POPOVER_WIDTH = 320;
|
||||||
const FILTER_POPOVER_MARGIN = 12;
|
const FILTER_POPOVER_MARGIN = 12;
|
||||||
const BUFFER_COLUMN_ID = "__data_grid_resize_buffer__";
|
|
||||||
|
|
||||||
export default function DataGrid<T>({
|
export default function DataGrid<T>({
|
||||||
id,
|
id,
|
||||||
@@ -241,8 +229,6 @@ export default function DataGrid<T>({
|
|||||||
"cover" :
|
"cover" :
|
||||||
resizeBehavior;
|
resizeBehavior;
|
||||||
const layoutSignature = dataGridLayoutSignature(columns, resolvedInitialFit, effectiveResizeBehavior);
|
const layoutSignature = dataGridLayoutSignature(columns, resolvedInitialFit, effectiveResizeBehavior);
|
||||||
const bufferInsertIndex = findBufferInsertIndex(columns);
|
|
||||||
const containerResizeColumns = columns.filter(isContainerResizeColumn);
|
|
||||||
|
|
||||||
const localStorageKey = storageKey ?? `${STORAGE_PREFIX}${id}`;
|
const localStorageKey = storageKey ?? `${STORAGE_PREFIX}${id}`;
|
||||||
const initialFiltersKey = JSON.stringify(initialFilters);
|
const initialFiltersKey = JSON.stringify(initialFilters);
|
||||||
@@ -368,31 +354,33 @@ export default function DataGrid<T>({
|
|||||||
const userWidths = signatureMatches ? current.userWidths ?? {} : {};
|
const userWidths = signatureMatches ? current.userWidths ?? {} : {};
|
||||||
const mustFit = effectiveResizeBehavior !== "free" || resolvedInitialFit === "container";
|
const mustFit = effectiveResizeBehavior !== "free" || resolvedInitialFit === "container";
|
||||||
if (!mustFit) {
|
if (!mustFit) {
|
||||||
if (signatureMatches && current.widths === undefined && current.bufferWidth === undefined) return current;
|
if (signatureMatches && current.widths === undefined) return current;
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
widths: undefined,
|
widths: undefined,
|
||||||
userWidths,
|
userWidths,
|
||||||
layoutSignature,
|
layoutSignature,
|
||||||
fillColumnId: undefined,
|
fillColumnId: undefined
|
||||||
bufferWidth: undefined
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const layout = fitDataGridColumns(columns, nextContainerWidth, measuredWidths, userWidths);
|
const layout = fitDataGridColumns(
|
||||||
const nextBufferWidth = normalizeBufferWidth(layout.bufferWidth, containerResizeColumns.length === 0);
|
columns,
|
||||||
|
nextContainerWidth,
|
||||||
|
measuredWidths,
|
||||||
|
userWidths,
|
||||||
|
effectiveResizeBehavior
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
signatureMatches
|
signatureMatches
|
||||||
&& shallowEqualNumberRecords(current.widths ?? {}, layout.widths)
|
&& shallowEqualNumberRecords(current.widths ?? {}, layout.widths)
|
||||||
&& nextBufferWidth === current.bufferWidth
|
|
||||||
) return current;
|
) return current;
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
widths: layout.widths,
|
widths: layout.widths,
|
||||||
userWidths,
|
userWidths,
|
||||||
layoutSignature,
|
layoutSignature,
|
||||||
fillColumnId: undefined,
|
fillColumnId: undefined
|
||||||
bufferWidth: nextBufferWidth
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -421,57 +409,25 @@ export default function DataGrid<T>({
|
|||||||
|
|
||||||
function onMove(event: MouseEvent) {
|
function onMove(event: MouseEvent) {
|
||||||
const rawDelta = event.clientX - activeResize.startX;
|
const rawDelta = event.clientX - activeResize.startX;
|
||||||
const activeDelta = Math.min(
|
const resized = resizeDataGridColumn(
|
||||||
activeResize.maxWidth - activeResize.startWidth,
|
columns,
|
||||||
Math.max(activeResize.minWidth - activeResize.startWidth, rawDelta)
|
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) => ({
|
setState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
widths: roundWidthRecord(nextWidths),
|
widths: resized.widths,
|
||||||
userWidths: {
|
userWidths: activeResize.behavior === "free"
|
||||||
...(current.layoutSignature === layoutSignature ? current.userWidths ?? {} : {}),
|
? {
|
||||||
[activeResize.columnId]: Math.round(nextWidths[activeResize.columnId] * 100) / 100
|
...(current.layoutSignature === layoutSignature ? current.userWidths ?? {} : {}),
|
||||||
},
|
[activeResize.columnId]: resized.widths[activeResize.columnId]
|
||||||
|
}
|
||||||
|
: resized.widths,
|
||||||
layoutSignature,
|
layoutSignature,
|
||||||
fillColumnId: undefined,
|
fillColumnId: undefined
|
||||||
bufferWidth: normalizeBufferWidth(nextBufferWidth, containerResizeColumns.length === 0)
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,7 +447,7 @@ export default function DataGrid<T>({
|
|||||||
window.removeEventListener("mousemove", onMove);
|
window.removeEventListener("mousemove", onMove);
|
||||||
window.removeEventListener("mouseup", onUp);
|
window.removeEventListener("mouseup", onUp);
|
||||||
};
|
};
|
||||||
}, [resizeState, columns, effectiveResizeBehavior, containerResizeColumns.length, layoutSignature]);
|
}, [resizeState, columns, effectiveResizeBehavior, layoutSignature]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openFilterColumnId) return undefined;
|
if (!openFilterColumnId) return undefined;
|
||||||
@@ -594,13 +550,8 @@ export default function DataGrid<T>({
|
|||||||
if (pagination && pagination.page !== paginationPage) pagination.onPageChange(paginationPage);
|
if (pagination && pagination.page !== paginationPage) pagination.onPageChange(paginationPage);
|
||||||
}, [pagination, 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 actualTracks = columns.map((column) => widthForColumn(column, state.widths?.[column.id]));
|
||||||
const templateTracks = [...actualTracks];
|
const templateColumns = actualTracks.join(" ");
|
||||||
if (shouldRenderBuffer) templateTracks.splice(bufferInsertIndex, 0, `${bufferWidth}px`);
|
|
||||||
const templateColumns = templateTracks.join(" ");
|
|
||||||
const hasFlexibleColumns = columns.some((column) => !state.widths?.[column.id] && isFlexibleColumn(column));
|
const hasFlexibleColumns = columns.some((column) => !state.widths?.[column.id] && isFlexibleColumn(column));
|
||||||
const stickyOffsets = useMemo(() => computeStickyOffsets(columns, state.widths, measuredWidths), [columns, state.widths, measuredWidths]);
|
const stickyOffsets = useMemo(() => computeStickyOffsets(columns, state.widths, measuredWidths), [columns, state.widths, measuredWidths]);
|
||||||
const gridClassName = [
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`data-grid-shell data-grid-${resolvedInitialFit} data-grid-shell-resize-${effectiveResizeBehavior} ${className}`.trim()}
|
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="data-grid-scroll-region" ref={scrollRegionRef}>
|
||||||
<div className={gridClassName} role="table" aria-label={id} style={{ gridTemplateColumns: templateColumns }}>
|
<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 sorted = state.sort?.columnId === column.id ? state.sort.direction : undefined;
|
||||||
const hasFilter = Boolean((state.filters?.[column.id] ?? "").trim());
|
const hasFilter = Boolean((state.filters?.[column.id] ?? "").trim());
|
||||||
const cells: ReactNode[] = [];
|
return (
|
||||||
if (shouldRenderBuffer && bufferInsertIndex === columnIndex) {
|
|
||||||
cells.push(bufferCell("header-buffer", "data-grid-header-cell", "columnheader"));
|
|
||||||
}
|
|
||||||
cells.push(
|
|
||||||
<div
|
<div
|
||||||
key={`header-${column.id}`}
|
key={`header-${column.id}`}
|
||||||
role="columnheader"
|
role="columnheader"
|
||||||
@@ -721,57 +657,28 @@ export default function DataGrid<T>({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const baseWidths = measuredColumnWidths(columns, headerCellRefs.current, state.widths, measuredWidths);
|
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 activeColumnIndex = columns.findIndex((candidate) => candidate.id === column.id);
|
||||||
const rightResizeTargets = columns.
|
|
||||||
slice(activeColumnIndex + 1).
|
|
||||||
filter(isResizeCompensationColumn).
|
|
||||||
map((candidate) => resizeTargetForColumn(candidate, baseWidths));
|
|
||||||
const isLastResizableColumn = !columns.
|
const isLastResizableColumn = !columns.
|
||||||
slice(activeColumnIndex + 1).
|
slice(activeColumnIndex + 1).
|
||||||
some(isResizeCompensationColumn);
|
some(isResizeCompensationColumn);
|
||||||
const canUseBuffer = effectiveResizeBehavior !== "free" && activeColumnIndex < bufferInsertIndex;
|
|
||||||
const storedBufferWidth = Math.max(0, state.bufferWidth ?? 0);
|
|
||||||
const scrollElement = scrollRegionRef.current;
|
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 ?
|
const totalHorizontalOverflow = scrollElement ?
|
||||||
Math.max(0, scrollElement.scrollWidth - scrollElement.clientWidth) :
|
Math.max(0, scrollElement.scrollWidth - scrollElement.clientWidth) :
|
||||||
0;
|
0;
|
||||||
const shrinkRoomWithoutScroll = scrollElement ?
|
const shrinkRoomWithoutScroll = scrollElement && !isLastResizableColumn ?
|
||||||
Math.max(0, totalHorizontalOverflow - scrollElement.scrollLeft) :
|
Math.max(0, totalHorizontalOverflow - scrollElement.scrollLeft) :
|
||||||
0;
|
totalHorizontalOverflow;
|
||||||
const lastResizableShrinkRoom = Math.min(
|
|
||||||
totalHorizontalOverflow,
|
|
||||||
Math.max(0, currentWidth - effectiveColumnMinWidth(column))
|
|
||||||
);
|
|
||||||
|
|
||||||
setState((current) => ({
|
setState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
widths: roundWidthRecord(baseWidths),
|
widths: roundWidthRecord(baseWidths),
|
||||||
fillColumnId: undefined,
|
fillColumnId: undefined
|
||||||
bufferWidth: normalizeBufferWidth(activeBufferWidth, containerResizeColumns.length === 0)
|
|
||||||
}));
|
}));
|
||||||
setResizeState({
|
setResizeState({
|
||||||
columnId: column.id,
|
columnId: column.id,
|
||||||
startX: event.clientX,
|
startX: event.clientX,
|
||||||
startWidth: currentWidth,
|
|
||||||
minWidth: effectiveColumnMinWidth(column),
|
|
||||||
maxWidth: effectiveColumnMaxWidth(column),
|
|
||||||
baseWidths,
|
baseWidths,
|
||||||
rightResizeTargets,
|
uncompensatedShrinkRoom: shrinkRoomWithoutScroll,
|
||||||
bufferTarget: canUseBuffer ? {
|
|
||||||
columnId: BUFFER_COLUMN_ID,
|
|
||||||
startWidth: activeBufferWidth,
|
|
||||||
minWidth: 0,
|
|
||||||
maxWidth: BUFFER_MAX_WIDTH
|
|
||||||
} : undefined,
|
|
||||||
bufferStartWidth: activeBufferWidth,
|
|
||||||
shrinkRoomWithoutScroll,
|
|
||||||
lastResizableShrinkRoom,
|
|
||||||
isLastResizableColumn,
|
|
||||||
behavior: effectiveResizeBehavior
|
behavior: effectiveResizeBehavior
|
||||||
});
|
});
|
||||||
}}>
|
}}>
|
||||||
@@ -781,30 +688,25 @@ export default function DataGrid<T>({
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
return cells;
|
|
||||||
})}
|
})}
|
||||||
{shouldRenderBuffer && bufferInsertIndex === columns.length ?
|
|
||||||
bufferCell("header-buffer-end", "data-grid-header-cell", "columnheader") :
|
|
||||||
null}
|
|
||||||
{renderedRows.length === 0 ? (() => {
|
{renderedRows.length === 0 ? (() => {
|
||||||
const filteredEmpty = rows.length > 0 && hasActiveFilters;
|
const filteredEmpty = rows.length > 0 && hasActiveFilters;
|
||||||
const actionColumnIndex = columns.findIndex((column) => column.id === emptyActionColumnId);
|
const actionColumnIndex = columns.findIndex((column) => column.id === emptyActionColumnId);
|
||||||
const actionColumn = actionColumnIndex >= 0 ? columns[actionColumnIndex] : undefined;
|
const actionColumn = actionColumnIndex >= 0 ? columns[actionColumnIndex] : undefined;
|
||||||
const actionLayoutIndex = actionColumnIndex + (shouldRenderBuffer && bufferInsertIndex <= actionColumnIndex ? 1 : 0);
|
if (!filteredEmpty && emptyAction && actionColumn && actionColumnIndex > 0) {
|
||||||
if (!filteredEmpty && emptyAction && actionColumn && actionLayoutIndex > 0) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="data-grid-cell data-grid-body-cell data-grid-empty-message data-grid-row-even is-last-row"
|
className="data-grid-cell data-grid-body-cell data-grid-empty-message data-grid-row-even is-last-row"
|
||||||
role="cell"
|
role="cell"
|
||||||
style={{ gridColumn: `1 / ${actionLayoutIndex + 1}` }}>
|
style={{ gridColumn: `1 / ${actionColumnIndex + 1}` }}>
|
||||||
|
|
||||||
{translatedEmptyText}
|
{translatedEmptyText}
|
||||||
</div>
|
</div>
|
||||||
<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()}
|
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"
|
role="cell"
|
||||||
style={{ ...stickyStyle(actionColumn, stickyOffsets[actionColumnIndex]), gridColumn: `${actionLayoutIndex + 1} / ${actionLayoutIndex + 2}` }}>
|
style={{ ...stickyStyle(actionColumn, stickyOffsets[actionColumnIndex]), gridColumn: `${actionColumnIndex + 1} / ${actionColumnIndex + 2}` }}>
|
||||||
|
|
||||||
{emptyAction}
|
{emptyAction}
|
||||||
</div>
|
</div>
|
||||||
@@ -824,9 +726,6 @@ export default function DataGrid<T>({
|
|||||||
const lastRowClass = visibleIndex === renderedRows.length - 1 ? "is-last-row" : "";
|
const lastRowClass = visibleIndex === renderedRows.length - 1 ? "is-last-row" : "";
|
||||||
const cells: ReactNode[] = [];
|
const cells: ReactNode[] = [];
|
||||||
columns.forEach((column, columnIndex) => {
|
columns.forEach((column, columnIndex) => {
|
||||||
if (shouldRenderBuffer && bufferInsertIndex === columnIndex) {
|
|
||||||
cells.push(bufferCell(`${rowKey}-buffer`, `data-grid-body-cell ${parityClass} ${lastRowClass}`, "cell"));
|
|
||||||
}
|
|
||||||
cells.push(
|
cells.push(
|
||||||
<div
|
<div
|
||||||
key={`${rowKey}-${column.id}`}
|
key={`${rowKey}-${column.id}`}
|
||||||
@@ -838,9 +737,6 @@ export default function DataGrid<T>({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
if (shouldRenderBuffer && bufferInsertIndex === columns.length) {
|
|
||||||
cells.push(bufferCell(`${rowKey}-buffer-end`, `data-grid-body-cell ${parityClass} ${lastRowClass}`, "cell"));
|
|
||||||
}
|
|
||||||
return cells;
|
return cells;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -1369,7 +1265,9 @@ layoutSignature: string)
|
|||||||
const column = columnMap.get(columnId);
|
const column = columnMap.get(columnId);
|
||||||
if (!column || !Number.isFinite(width)) return [];
|
if (!column || !Number.isFinite(width)) return [];
|
||||||
const minimum = effectiveColumnMinWidth(column);
|
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))]];
|
return [[columnId, Math.min(maximum, Math.max(minimum, width))]];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -1377,103 +1275,33 @@ layoutSignature: string)
|
|||||||
const nextUserWidths = signatureMatches ? sanitizeWidths(state.userWidths) : {};
|
const nextUserWidths = signatureMatches ? sanitizeWidths(state.userWidths) : {};
|
||||||
const normalizedWidths = Object.keys(nextWidths).length > 0 ? roundWidthRecord(nextWidths) : undefined;
|
const normalizedWidths = Object.keys(nextWidths).length > 0 ? roundWidthRecord(nextWidths) : undefined;
|
||||||
const normalizedUserWidths = Object.keys(nextUserWidths).length > 0 ? roundWidthRecord(nextUserWidths) : 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 widthsChanged = !shallowEqualNumberRecords(state.widths ?? {}, normalizedWidths ?? {});
|
||||||
const userWidthsChanged = !shallowEqualNumberRecords(state.userWidths ?? {}, normalizedUserWidths ?? {});
|
const userWidthsChanged = !shallowEqualNumberRecords(state.userWidths ?? {}, normalizedUserWidths ?? {});
|
||||||
const bufferChanged = normalizedBufferWidth !== state.bufferWidth;
|
|
||||||
const fillChanged = state.fillColumnId !== undefined;
|
const fillChanged = state.fillColumnId !== undefined;
|
||||||
const signatureChanged = state.layoutSignature !== layoutSignature;
|
const signatureChanged = state.layoutSignature !== layoutSignature;
|
||||||
if (!widthsChanged && !userWidthsChanged && !bufferChanged && !fillChanged && !signatureChanged) return state;
|
if (!widthsChanged && !userWidthsChanged && !fillChanged && !signatureChanged) return state;
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
widths: normalizedWidths,
|
widths: normalizedWidths,
|
||||||
userWidths: normalizedUserWidths,
|
userWidths: normalizedUserWidths,
|
||||||
layoutSignature,
|
layoutSignature,
|
||||||
fillColumnId: undefined,
|
fillColumnId: undefined
|
||||||
bufferWidth: normalizedBufferWidth
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function widthForColumn<T>(column: DataGridColumn<T>, savedWidth?: number): string {
|
function widthForColumn<T>(column: DataGridColumn<T>, savedWidth?: number): string {
|
||||||
const minimum = effectiveColumnMinWidth(column);
|
const minimum = effectiveColumnMinWidth(column);
|
||||||
const maximum = effectiveColumnMaxWidth(column, minimum);
|
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 (typeof column.width === "number") return `${Math.min(maximum, Math.max(minimum, column.width))}px`;
|
||||||
if (column.width) return columnTrackWithMinimum(column.width, minimum);
|
if (column.width) return columnTrackWithMinimum(column.width, minimum);
|
||||||
return `minmax(${minimum}px, 1fr)`;
|
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 {
|
function isResizeCompensationColumn<T>(column: DataGridColumn<T>): boolean {
|
||||||
return Boolean(column.resizable && !column.sticky);
|
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>(
|
function measuredColumnWidths<T>(
|
||||||
columns: DataGridColumn<T>[],
|
columns: DataGridColumn<T>[],
|
||||||
elements: Record<string, HTMLDivElement | null>,
|
elements: Record<string, HTMLDivElement | null>,
|
||||||
@@ -1484,11 +1312,14 @@ measuredWidths?: Record<string, number>)
|
|||||||
for (const column of columns) {
|
for (const column of columns) {
|
||||||
const element = elements[column.id];
|
const element = elements[column.id];
|
||||||
const measured = element ? Math.round(element.getBoundingClientRect().width) : undefined;
|
const measured = element ? Math.round(element.getBoundingClientRect().width) : undefined;
|
||||||
result[column.id] = columnPixelWidth(
|
const savedWidth = savedWidths?.[column.id];
|
||||||
column,
|
result[column.id] = savedWidth !== undefined
|
||||||
savedWidths?.[column.id],
|
? Math.min(DATA_GRID_MAX_TRACK_WIDTH, Math.max(effectiveColumnMinWidth(column), savedWidth))
|
||||||
measured && measured > 0 ? measured : measuredWidths?.[column.id]
|
: columnPixelWidth(
|
||||||
);
|
column,
|
||||||
|
undefined,
|
||||||
|
measured && measured > 0 ? measured : measuredWidths?.[column.id]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -1504,7 +1335,7 @@ function computeStickyOffsets<T>(columns: DataGridColumn<T>[], savedWidths?: Rec
|
|||||||
columns.forEach((column, index) => {
|
columns.forEach((column, index) => {
|
||||||
if (column.sticky === "start") {
|
if (column.sticky === "start") {
|
||||||
offsets[index] = left;
|
offsets[index] = left;
|
||||||
left += columnPixelWidth(column, savedWidths?.[column.id], measuredWidths?.[column.id]);
|
left += renderedColumnPixelWidth(column, savedWidths?.[column.id], measuredWidths?.[column.id]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let right = 0;
|
let right = 0;
|
||||||
@@ -1512,12 +1343,22 @@ function computeStickyOffsets<T>(columns: DataGridColumn<T>[], savedWidths?: Rec
|
|||||||
const index = columns.length - 1 - reverseIndex;
|
const index = columns.length - 1 - reverseIndex;
|
||||||
if (column.sticky === "end") {
|
if (column.sticky === "end") {
|
||||||
offsets[index] = right;
|
offsets[index] = right;
|
||||||
right += columnPixelWidth(column, savedWidths?.[column.id], measuredWidths?.[column.id]);
|
right += renderedColumnPixelWidth(column, savedWidths?.[column.id], measuredWidths?.[column.id]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return offsets;
|
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 {
|
function stickyClass<T>(column: DataGridColumn<T>): string {
|
||||||
if (column.sticky === "start") return "is-sticky-start";
|
if (column.sticky === "start") return "is-sticky-start";
|
||||||
if (column.sticky === "end") return "is-sticky-end";
|
if (column.sticky === "end") return "is-sticky-end";
|
||||||
|
|||||||
@@ -19,12 +19,19 @@ export type DataGridResizeTarget = {
|
|||||||
weight?: number;
|
weight?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DataGridFitMode = "free" | "cover" | "constrained";
|
||||||
|
|
||||||
export type DataGridColumnLayout = {
|
export type DataGridColumnLayout = {
|
||||||
widths: Record<string, number>;
|
widths: Record<string, number>;
|
||||||
bufferWidth: number;
|
underflowWidth: number;
|
||||||
overflowWidth: number;
|
overflowWidth: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DataGridColumnResize = {
|
||||||
|
widths: Record<string, number>;
|
||||||
|
appliedDelta: number;
|
||||||
|
};
|
||||||
|
|
||||||
const MIN_HEADER_LABEL_WIDTH = 72;
|
const MIN_HEADER_LABEL_WIDTH = 72;
|
||||||
const SORT_CONTROL_RESERVE = 24;
|
const SORT_CONTROL_RESERVE = 24;
|
||||||
const FILTER_CONTROL_RESERVE = 32;
|
const FILTER_CONTROL_RESERVE = 32;
|
||||||
@@ -125,7 +132,8 @@ export function fitDataGridColumns(
|
|||||||
columns: DataGridSizingColumn[],
|
columns: DataGridSizingColumn[],
|
||||||
containerWidth: number,
|
containerWidth: number,
|
||||||
measuredWidths: Record<string, number> = {},
|
measuredWidths: Record<string, number> = {},
|
||||||
userWidths: Record<string, number> = {}
|
userWidths: Record<string, number> = {},
|
||||||
|
fitMode: DataGridFitMode = "cover"
|
||||||
): DataGridColumnLayout {
|
): DataGridColumnLayout {
|
||||||
const safeContainerWidth = Math.max(0, containerWidth);
|
const safeContainerWidth = Math.max(0, containerWidth);
|
||||||
const widths: Record<string, number> = {};
|
const widths: Record<string, number> = {};
|
||||||
@@ -135,37 +143,160 @@ export function fitDataGridColumns(
|
|||||||
const maximum = effectiveDataGridColumnMaxWidth(column, minimum);
|
const maximum = effectiveDataGridColumnMaxWidth(column, minimum);
|
||||||
const override = userWidths[column.id];
|
const override = userWidths[column.id];
|
||||||
widths[column.id] = override !== undefined && Number.isFinite(override)
|
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]);
|
: preferredDataGridColumnWidth(column, safeContainerWidth, measuredWidths[column.id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferredTotal = totalDataGridColumnWidth(columns, widths);
|
const preferredTotal = totalDataGridColumnWidth(columns, widths);
|
||||||
const requestedAmount = safeContainerWidth - preferredTotal;
|
let remaining = safeContainerWidth - preferredTotal;
|
||||||
const targets = columns
|
const ordinaryColumns = columns.filter((column) => !column.sticky);
|
||||||
.filter((column) => userWidths[column.id] === undefined)
|
const coverageColumns = ordinaryColumns.length > 0 ? ordinaryColumns : columns;
|
||||||
.filter((column) => requestedAmount >= 0
|
const automaticColumns = coverageColumns.filter((column) => userWidths[column.id] === undefined);
|
||||||
? dataGridColumnGrowthWeight(column) > 0
|
const responsiveColumns = automaticColumns.filter((column) =>
|
||||||
: isFlexibleDataGridWidth(column.width, column.fill) || Boolean(column.resizable))
|
isFlexibleDataGridWidth(column.width, column.fill)
|
||||||
.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)) {
|
if (fitMode === "free") {
|
||||||
widths[columnId] += amount;
|
const targets = automaticColumns
|
||||||
|
.filter((column) => remaining >= 0
|
||||||
|
? dataGridColumnGrowthWeight(column) > 0
|
||||||
|
: isFlexibleDataGridWidth(column.width, column.fill) || Boolean(column.resizable))
|
||||||
|
.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),
|
||||||
|
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 {
|
return {
|
||||||
widths: roundDataGridWidths(widths),
|
widths: roundDataGridWidths(widths),
|
||||||
bufferWidth: Math.round(Math.max(0, remaining) * 100) / 100,
|
appliedDelta: roundWidth(appliedDelta)
|
||||||
overflowWidth: Math.round(Math.max(0, -remaining) * 100) / 100
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +392,74 @@ function dataGridColumnGrowthWeight(column: DataGridSizingColumn): number {
|
|||||||
if (column.fill || column.width === undefined) return 1;
|
if (column.fill || column.width === undefined) return 1;
|
||||||
if (typeof column.width !== "string") return 0;
|
if (typeof column.width !== "string") return 0;
|
||||||
const preferred = minmaxParts(column.width)?.preferred ?? column.width;
|
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 {
|
function parsePixelTrack(track?: string): number | null {
|
||||||
@@ -301,3 +499,7 @@ function normalizedWeight(weight?: number): number {
|
|||||||
function clampWidth(width: number, minimum: number, maximum: number): number {
|
function clampWidth(width: number, minimum: number, maximum: number): number {
|
||||||
return Math.min(maximum, Math.max(minimum, width));
|
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/moduleEvents";
|
||||||
export * from "./platform/ViewContext";
|
export * from "./platform/ViewContext";
|
||||||
export * from "./platform/views";
|
export * from "./platform/views";
|
||||||
|
export * from "./platform/wizards";
|
||||||
|
|
||||||
export * from "./utils/permissions";
|
export * from "./utils/permissions";
|
||||||
export * from "./utils/fieldHelp";
|
export * from "./utils/fieldHelp";
|
||||||
@@ -85,6 +86,8 @@ export { default as DisabledActionTooltip } from "./components/DisabledActionToo
|
|||||||
export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip";
|
export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip";
|
||||||
export { default as DismissibleAlert } from "./components/DismissibleAlert";
|
export { default as DismissibleAlert } from "./components/DismissibleAlert";
|
||||||
export { default as ViewSurfaceRouteBoundary } from "./components/ViewSurfaceRouteBoundary";
|
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 { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
|
||||||
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
|
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
|
||||||
export { default as FileDropZone } from "./components/FileDropZone";
|
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; }
|
.wizard-heading h1 { margin: 0; }
|
||||||
.save-state { color: var(--green); font-size: 13px; font-weight: 700; }
|
.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-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); }
|
.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 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); }
|
.step.active button { background: var(--surface); color: var(--text-strong); }
|
||||||
|
|||||||
@@ -226,15 +226,6 @@
|
|||||||
min-width: 100%;
|
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 {
|
.data-grid-container .data-grid {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,6 +266,38 @@ export type PlatformRouteContext = {
|
|||||||
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
|
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 = {
|
export type PlatformPublicRouteContext = {
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
auth: AuthInfo | null;
|
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("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");
|
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,
|
distributeDataGridResizeAmount,
|
||||||
effectiveDataGridColumnMinWidth,
|
effectiveDataGridColumnMinWidth,
|
||||||
fitDataGridColumns,
|
fitDataGridColumns,
|
||||||
|
resizeDataGridColumn,
|
||||||
type DataGridSizingColumn
|
type DataGridSizingColumn
|
||||||
} from "../src/components/table/dataGridSizing";
|
} from "../src/components/table/dataGridSizing";
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ assertWidths(
|
|||||||
{ fixed: 100, primary: 200, secondary: 300 },
|
{ fixed: 100, primary: 200, secondary: 300 },
|
||||||
"fraction tracks receive residual width by declared weight"
|
"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([
|
const boundedLayout = fitDataGridColumns([
|
||||||
{ id: "fixed", width: 100 },
|
{ id: "fixed", width: 100 },
|
||||||
@@ -54,10 +55,10 @@ const boundedLayout = fitDataGridColumns([
|
|||||||
], 600);
|
], 600);
|
||||||
assertWidths(
|
assertWidths(
|
||||||
boundedLayout.widths,
|
boundedLayout.widths,
|
||||||
{ fixed: 100, primary: 150, secondary: 200 },
|
{ fixed: 250, primary: 150, secondary: 200 },
|
||||||
"fraction tracks stop at their declared maxima"
|
"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([
|
const overflowLayout = fitDataGridColumns([
|
||||||
{ id: "fixed", width: 200 },
|
{ id: "fixed", width: 200 },
|
||||||
@@ -66,10 +67,10 @@ const overflowLayout = fitDataGridColumns([
|
|||||||
], 600);
|
], 600);
|
||||||
assertWidths(
|
assertWidths(
|
||||||
overflowLayout.widths,
|
overflowLayout.widths,
|
||||||
{ fixed: 200, primary: 320, secondary: 260 },
|
{ fixed: 72, primary: 320, secondary: 260 },
|
||||||
"columns never shrink below declared minima"
|
"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([
|
const percentageLayout = fitDataGridColumns([
|
||||||
{ id: "fixed", width: 100 },
|
{ id: "fixed", width: 100 },
|
||||||
@@ -78,10 +79,10 @@ const percentageLayout = fitDataGridColumns([
|
|||||||
], 1000);
|
], 1000);
|
||||||
assertWidths(
|
assertWidths(
|
||||||
percentageLayout.widths,
|
percentageLayout.widths,
|
||||||
{ fixed: 100, primary: 300, secondary: 200 },
|
{ fixed: 100, primary: 540, secondary: 360 },
|
||||||
"percentage tracks retain their preferred container share"
|
"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 });
|
const overriddenLayout = fitDataGridColumns(weightedColumns, 800, {}, { primary: 450 });
|
||||||
assertWidths(
|
assertWidths(
|
||||||
@@ -93,20 +94,20 @@ assertWidths(
|
|||||||
const overriddenOverflowLayout = fitDataGridColumns(weightedColumns, 500, {}, { primary: 450 });
|
const overriddenOverflowLayout = fitDataGridColumns(weightedColumns, 500, {}, { primary: 450 });
|
||||||
assertWidths(
|
assertWidths(
|
||||||
overriddenOverflowLayout.widths,
|
overriddenOverflowLayout.widths,
|
||||||
{ fixed: 100, primary: 450, secondary: 100 },
|
{ fixed: 72, primary: 328, secondary: 100 },
|
||||||
"a narrow viewport preserves the user override without squashing peers below their minima"
|
"a cover layout treats a persisted user width as preferred under viewport pressure"
|
||||||
);
|
);
|
||||||
assertEqual(
|
assertEqual(
|
||||||
overriddenOverflowLayout.overflowWidth,
|
overriddenOverflowLayout.overflowWidth,
|
||||||
150,
|
0,
|
||||||
"a user override that cannot fit becomes horizontal overflow"
|
"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(
|
assertEqual(
|
||||||
clampedOverrideLayout.widths.primary,
|
clampedOverrideLayout.widths.primary,
|
||||||
500,
|
500,
|
||||||
"user overrides remain bounded by the column maximum"
|
"free-layout user overrides remain bounded by the column maximum"
|
||||||
);
|
);
|
||||||
|
|
||||||
const redistributedGrowth = distributeDataGridResizeAmount(300, [
|
const redistributedGrowth = distributeDataGridResizeAmount(300, [
|
||||||
@@ -141,6 +142,120 @@ assertWidths(
|
|||||||
"legacy fill columns remain stable under the explicit sizing contract"
|
"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 originalSignature = dataGridLayoutSignature(weightedColumns, "container", "cover");
|
||||||
const changedSignature = dataGridLayoutSignature(
|
const changedSignature = dataGridLayoutSignature(
|
||||||
weightedColumns.map((column) => column.id === "primary" ? { ...column, maxWidth: 420 } : column),
|
weightedColumns.map((column) => column.id === "primary" ? { ...column, maxWidth: 420 } : column),
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const defaultWebModulePackages = [
|
|||||||
"@govoplan/risk-compliance-webui",
|
"@govoplan/risk-compliance-webui",
|
||||||
"@govoplan/scheduling-webui",
|
"@govoplan/scheduling-webui",
|
||||||
"@govoplan/search-webui",
|
"@govoplan/search-webui",
|
||||||
|
"@govoplan/tenancy-webui",
|
||||||
"@govoplan/views-webui",
|
"@govoplan/views-webui",
|
||||||
"@govoplan/workflow-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-risk-compliance/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-scheduling/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-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-views/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))
|
fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user