diff --git a/Source/DotNET/Model/Common/Dock.cs b/Source/DotNET/Model/Common/Dock.cs new file mode 100644 index 0000000..71eff84 --- /dev/null +++ b/Source/DotNET/Model/Common/Dock.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Common; + +/// +/// The edge of a dock panel a child is docked against. +/// +public enum Dock +{ + /// + /// Docked against the left edge. + /// + Left = 0, + + /// + /// Docked against the top edge. + /// + Top = 1, + + /// + /// Docked against the right edge. + /// + Right = 2, + + /// + /// Docked against the bottom edge. + /// + Bottom = 3 +} diff --git a/Source/DotNET/Model/Common/GridLength.cs b/Source/DotNET/Model/Common/GridLength.cs new file mode 100644 index 0000000..c990064 --- /dev/null +++ b/Source/DotNET/Model/Common/GridLength.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Common; + +/// +/// The length of a grid row or column, which can be absolute, sized to its content, or a weighted +/// share of the space the absolute and content-sized tracks leave behind. +/// +/// The numeric value, read according to . +/// How is interpreted. +public record GridLength(double Value = 1, GridUnitType UnitType = GridUnitType.Star) +{ + /// + /// A length sized to the content it holds. + /// + public static readonly GridLength Auto = new(0, GridUnitType.Auto); + + /// + /// A single share of the leftover space. + /// + public static readonly GridLength Star = new(1, GridUnitType.Star); + + /// + /// Create an absolute length. + /// + /// The absolute length. + /// A new . + public static GridLength Absolute(double value) => new(value, GridUnitType.Absolute); + + /// + /// Create a weighted share of the leftover space. + /// + /// The weight, relative to the other starred tracks. + /// A new . + public static GridLength Stars(double weight) => new(weight, GridUnitType.Star); +} diff --git a/Source/DotNET/Model/Common/GridUnitType.cs b/Source/DotNET/Model/Common/GridUnitType.cs new file mode 100644 index 0000000..00fa96b --- /dev/null +++ b/Source/DotNET/Model/Common/GridUnitType.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Common; + +/// +/// How a 's value is interpreted. +/// +public enum GridUnitType +{ + /// + /// The length is decided by the content it holds. + /// + Auto = 0, + + /// + /// The length is an absolute measurement. + /// + Absolute = 1, + + /// + /// The length is a weighted share of whatever space is left over. + /// + Star = 2 +} diff --git a/Source/DotNET/Model/Common/Orientation.cs b/Source/DotNET/Model/Common/Orientation.cs new file mode 100644 index 0000000..dd1ab23 --- /dev/null +++ b/Source/DotNET/Model/Common/Orientation.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Common; + +/// +/// The direction an element lays its children out in. +/// +public enum Orientation +{ + /// + /// Children are laid out left to right. + /// + Horizontal = 0, + + /// + /// Children are laid out top to bottom. + /// + Vertical = 1 +} diff --git a/Source/DotNET/Model/Elements/Panels/ColumnDefinition.cs b/Source/DotNET/Model/Elements/Panels/ColumnDefinition.cs new file mode 100644 index 0000000..3330082 --- /dev/null +++ b/Source/DotNET/Model/Elements/Panels/ColumnDefinition.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Scene.Model.Common; + +namespace Cratis.Scene.Model.Elements.Panels; + +/// +/// One column of a . +/// +public record ColumnDefinition +{ + /// + /// Gets how wide the column is. + /// + public GridLength Width { get; init; } = GridLength.Star; + + /// + /// Gets the width the column never goes below. + /// + public double MinimumWidth { get; init; } + + /// + /// Gets the width the column never goes above. + /// + public double MaximumWidth { get; init; } = double.PositiveInfinity; +} diff --git a/Source/DotNET/Model/Elements/Panels/DockPanel.cs b/Source/DotNET/Model/Elements/Panels/DockPanel.cs new file mode 100644 index 0000000..3e3b412 --- /dev/null +++ b/Source/DotNET/Model/Elements/Panels/DockPanel.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Elements.Panels; + +/// +/// Lays its children out against its edges, each child stating which edge through the Dock key +/// of its . +/// +public record DockPanel : Panel +{ + /// + /// Gets whether the last child spreads into whatever space the docked children left behind. + /// + public bool LastChildFill { get; init; } = true; +} diff --git a/Source/DotNET/Model/Elements/Panels/Grid.cs b/Source/DotNET/Model/Elements/Panels/Grid.cs new file mode 100644 index 0000000..de07f9b --- /dev/null +++ b/Source/DotNET/Model/Elements/Panels/Grid.cs @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Elements.Panels; + +// There is deliberately no Canvas panel here. Absolute placement is already the layout model's job - +// FreeformArrangement carries an ElementPlacement (x, y, width, height) per element *per size class*, +// which says strictly more than a canvas with attached Left/Top coordinates can. A Canvas panel would +// also declare no properties of its own, and every element kind in this model is told apart by a +// property no other kind has - so it could not be recognized at render time either. + +/// +/// Lays its children out in rows and columns. A child states which cell it occupies through the +/// Grid.Row, Grid.Column, Grid.RowSpan and Grid.ColumnSpan keys of its +/// . +/// +public record Grid : Panel +{ + /// + /// Gets the rows, top to bottom. + /// + public IReadOnlyList Rows { get; init; } = []; + + /// + /// Gets the columns, left to right. + /// + public IReadOnlyList Columns { get; init; } = []; +} diff --git a/Source/DotNET/Model/Elements/Panels/RowDefinition.cs b/Source/DotNET/Model/Elements/Panels/RowDefinition.cs new file mode 100644 index 0000000..e2d8c5e --- /dev/null +++ b/Source/DotNET/Model/Elements/Panels/RowDefinition.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Scene.Model.Common; + +namespace Cratis.Scene.Model.Elements.Panels; + +/// +/// One row of a . +/// +public record RowDefinition +{ + /// + /// Gets how tall the row is. + /// + public GridLength Height { get; init; } = GridLength.Star; + + /// + /// Gets the height the row never goes below. + /// + public double MinimumHeight { get; init; } + + /// + /// Gets the height the row never goes above. + /// + public double MaximumHeight { get; init; } = double.PositiveInfinity; +} diff --git a/Source/DotNET/Model/Elements/Panels/StackPanel.cs b/Source/DotNET/Model/Elements/Panels/StackPanel.cs new file mode 100644 index 0000000..839dbf8 --- /dev/null +++ b/Source/DotNET/Model/Elements/Panels/StackPanel.cs @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Scene.Model.Common; + +namespace Cratis.Scene.Model.Elements.Panels; + +/// +/// Lays its children out in a single line. +/// +public record StackPanel : Panel +{ + /// + /// Gets the direction the line runs in. + /// + public Orientation Orientation { get; init; } = Orientation.Vertical; + + /// + /// Gets the space left between one child and the next. + /// + public double Spacing { get; init; } +} diff --git a/Source/DotNET/Model/Elements/Panels/WrapPanel.cs b/Source/DotNET/Model/Elements/Panels/WrapPanel.cs new file mode 100644 index 0000000..7380a4d --- /dev/null +++ b/Source/DotNET/Model/Elements/Panels/WrapPanel.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Scene.Model.Common; + +namespace Cratis.Scene.Model.Elements.Panels; + +/// +/// Lays its children out in a line and starts a new one whenever the current line runs out of room. +/// +public record WrapPanel : Panel +{ + /// + /// Gets the direction each line runs in. + /// + public Orientation Orientation { get; init; } = Orientation.Horizontal; + + /// + /// Gets the width every child is laid out at, or to let each child keep its own. + /// + public double? ItemWidth { get; init; } + + /// + /// Gets the height every child is laid out at, or to let each child keep its own. + /// + public double? ItemHeight { get; init; } +} diff --git a/Source/JavaScript/blueprint.components/.storybook/preview.tsx b/Source/JavaScript/blueprint.components/.storybook/preview.tsx index e3823a3..132e979 100644 --- a/Source/JavaScript/blueprint.components/.storybook/preview.tsx +++ b/Source/JavaScript/blueprint.components/.storybook/preview.tsx @@ -2,20 +2,16 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { Preview } from '@storybook/react'; +import { PrimeReactProvider } from '@primereact/core'; import { CratisComponentsProvider } from '@cratis/components/Common'; -// The stylesheet layers a page in this blueprint needs, in the order they resolve. PrimeReact 11 ships -// zero CSS - `primereact/resources/themes/*` does not exist any more - and `@cratis/components` 3.0.0 took -// its own CSS out of the JavaScript module graph, so every sheet below is an explicit import rather than -// something a bundler injects behind an `import './Foo.css'`. +// The stylesheet layers a page in this blueprint needs, in the order they resolve. Components 4 owns its +// own CSS outright and keeps it out of the JavaScript module graph, so every sheet below is an explicit +// import rather than something a bundler injects behind an `import './Foo.css'`. // // `tokens` defines the `--cratis-*` layer, `styles` is every component stylesheet plus the Tailwind // utilities that consume it, and `theme` is the Cratis-authored MIT baseline that assigns those tokens -// actual values. That order matters: the last two both read the first. `theme` is what gives this preview -// a look at all, because with no `@primeuix/themes` preset there are no `--p-*` values for the tokens to -// resolve to - which also means the raw PrimeReact widgets the default blueprint's shell renders come out -// structural rather than styled. That is the honest picture of an unstyled-first host, and this package -// takes no dependency on a preset to paper over it. +// actual values. That order matters: the last two both read the first. // // The Scene package's bridge puts Scene's `--scene-*` tokens in front of that, and the default blueprint's // `layout.css` draws the shell every one of these pages sits inside - which is the sheet this package does @@ -31,24 +27,31 @@ import 'primeicons/primeicons.css'; import '../../components/theme/sceneTokenBridge.css'; import '../../blueprint.default/shell/layout.css'; +// The baseline theme is scoped to a `cratis-theme` ancestor rather than to `:root`, so something has to +// carry the class, and overlays portal to the body rather than into the story's subtree. +document.body.classList.add('cratis-theme'); + /** - * Every story renders inside `CratisComponentsProvider`, the library's own configuration provider over - * PrimeReact's. Without it the wrapped components fall back to PrimeReact's defaults rather than Cratis', - * so a story would show something subtly different from what an application renders - which defeats the - * point of having stories at all. + * Two providers, because these pages are two things at once. + * + * The page bodies are Components 4, which owns its own markup and configuration and no longer sits on + * PrimeReact at all - `CratisComponentsProvider` now carries only what the library itself owns. * - * On PrimeReact 11 it is also load-bearing rather than merely advisable: every v11 component resolves its - * configuration, theme and z-index registry through `PrimeReactProvider`, which this wraps, and throws - * outright without one. These pages sit inside the default blueprint's shell, which reaches for PrimeReact - * directly in five places, so removing this decorator would not degrade the stories - it would stop them - * rendering. + * The shell they sit inside is the default blueprint's, and that reaches for PrimeReact directly in five + * places (`Topbar`, `Sidebar`, `Breadcrumb`, `UserMenu`, `ConfigPanel`). Every PrimeReact 11 component + * resolves its configuration, theme and z-index registry through `PrimeReactProvider` and throws without + * one. Until Components 3 that provider came for free, because `CratisComponentsProvider` wrapped it; + * under Components 4 it does not, so this preview supplies it explicitly. Dropping it would not degrade + * these stories - it would stop them rendering. */ const preview: Preview = { decorators: [ Story => ( - - - + + + + + ), ], parameters: { diff --git a/Source/JavaScript/blueprint.components/package.json b/Source/JavaScript/blueprint.components/package.json index 95a5ce8..c7fa268 100644 --- a/Source/JavaScript/blueprint.components/package.json +++ b/Source/JavaScript/blueprint.components/package.json @@ -50,7 +50,7 @@ "build-storybook": "storybook build" }, "dependencies": { - "@cratis/components": "^3.0.0", + "@cratis/components": "^4.1.1", "@cratis/scene.blueprint.default": "1.0.0", "@cratis/scene.components": "1.0.0", "@cratis/scene.engine": "1.0.0", @@ -58,7 +58,7 @@ "@cratis/scene.react": "1.0.0" }, "devDependencies": { - "@cratis/components": "^3.0.0", + "@cratis/components": "^4.1.1", "@cratis/scene.blueprint.default": "1.0.0", "@cratis/scene.components": "1.0.0", "@cratis/scene.engine": "1.0.0", @@ -76,7 +76,7 @@ "storybook": "^10.4.1" }, "peerDependencies": { - "@cratis/components": "^3.0.0", + "@cratis/components": "^4.1.1", "@primereact/core": "^11.0.0", "@primereact/headless": "^11.0.0", "primeicons": "^8.0.0", diff --git a/Source/JavaScript/components/.storybook/preview.tsx b/Source/JavaScript/components/.storybook/preview.tsx index 4d47ccf..833714d 100644 --- a/Source/JavaScript/components/.storybook/preview.tsx +++ b/Source/JavaScript/components/.storybook/preview.tsx @@ -6,11 +6,9 @@ import { CratisComponentsProvider } from '@cratis/components/Common'; // The stylesheet stack a `@cratis/components` component needs, in the order the layers resolve. // -// PrimeReact 11 ships no CSS whatsoever - `primereact/resources/` does not exist, so the compiled theme -// this file used to import has nothing left to resolve to. A look is no longer a stylesheet at all: it is -// either a `@primeuix/themes` preset handed to the provider, which `@primeuix/styled` turns into `--p-*` -// custom properties at runtime, or a sheet that assigns the library's own `--cratis-*` tokens directly. -// This preview takes the second path - see the decorator below for why. +// Components 4 owns its own markup and styling outright - there is no PrimeReact underneath it any more, +// and so no preset and no `--p-*` values behind the tokens. A look is one thing now: a sheet that assigns +// the library's own `--cratis-*` tokens. // // `tokens` declares the `--cratis-*` layer every component stylesheet reads, `styles` is that component // CSS plus the compiled Tailwind utilities and the vendored Allotment sheet `DataPage`'s split view needs, @@ -20,35 +18,33 @@ import { CratisComponentsProvider } from '@cratis/components/Common'; import '@cratis/components/tokens'; import '@cratis/components/styles'; import '@cratis/components/theme'; +// Components 4 renders an `Icon` given a string as `` - a consumer-owned icon font, +// whichever one the consumer happens to load. These stories demonstrate that with `pi pi-*`, so the +// preview loads primeicons to make them visible. It is a Storybook devDependency for exactly this reason +// and nothing in the package's own source reaches for it. import 'primeicons/primeicons.css'; import '../theme/sceneTokenBridge.css'; // The baseline theme is scoped to a `cratis-theme` ancestor rather than to `:root`, so something has to -// carry the class. `` rather than a wrapper inside each story, because PrimeReact 11 portals its -// overlays - dialogs, select panels, the filter panel - straight to `document.body`; a wrapper would -// leave every one of them outside the themed subtree and rendering unstyled. +// carry the class. `` rather than a wrapper inside each story, because overlays - dialogs, select +// panels, the filter panel - are portaled to `document.body`; a wrapper would leave every one of them +// outside the themed subtree and rendering unstyled. document.body.classList.add('cratis-theme'); /** - * Every story renders inside `CratisComponentsProvider`, the library's own configuration provider over - * PrimeReact's. Without it the wrapped components fall back to PrimeReact's defaults rather than - * Cratis', so a story would be showing something subtly different from what an application renders - - * which defeats the point of having stories at all. + * Every story renders inside `CratisComponentsProvider`, so a story shows what an application shows. * - * `unstyled` is the posture the imported `@cratis/components/theme` is written for: it is Cratis-authored - * MIT CSS that assigns the `--cratis-*` tokens outright, so it needs neither a `@primeuix/themes` preset - * nor the PrimeUI license key a styled preset is gated behind. Choosing it over a preset also keeps this - * package's dependency surface honest - `@primeuix/themes` is not one of its dependencies, and a preview - * is the wrong place to start relying on a package nobody declared. - * - * It is the right choice for these stories on its own terms too. The `--cratis-*` token layer is exactly - * what `theme/sceneTokenBridge.css` overrides, so a Scene theme visibly takes over from the baseline in - * the `Themed` story rather than from a preset's `--p-*` values one level further down the chain. + * Components 4 narrowed the provider to configuration the library itself owns - `locale` and `messages`. + * The renderer keys it used to carry (`license`, `theme`, `defaults`, `pt`, `ripple`, `unstyled`) existed + * to configure PrimeReact underneath, and there is no PrimeReact underneath any more. `unstyled` in + * particular is gone rather than defaulted: the imported `@cratis/components/theme` is the look now, and + * `theme/sceneTokenBridge.css` overrides its `--cratis-*` tokens directly, which is what lets the `Themed` + * story visibly take over from the baseline. */ const preview: Preview = { decorators: [ Story => ( - + ), diff --git a/Source/JavaScript/components/common/SceneTooltip.tsx b/Source/JavaScript/components/common/SceneTooltip.tsx index 12fef10..2c6fa79 100644 --- a/Source/JavaScript/components/common/SceneTooltip.tsx +++ b/Source/JavaScript/components/common/SceneTooltip.tsx @@ -22,7 +22,11 @@ export function SceneTooltip({ element, slots }: RegisteredComponentProps) { position={unionProperty(element.properties, 'position', positions)} disabled={booleanProperty(element.properties, 'disabled')} > - {slots.content} + {/* Components 4 takes a single focusable trigger, which it clones to attach its own + class and part attributes, and a Scene slot is a list. The span is that one element: + without it a slot holding anything other than exactly one node fails to type, and a + slot is authored content we cannot assume the shape of. */} + {slots.content} ); } diff --git a/Source/JavaScript/components/editors/for_SceneObjectNavigationalBar/when_navigating_back_up_the_trail.tsx b/Source/JavaScript/components/editors/for_SceneObjectNavigationalBar/when_navigating_back_up_the_trail.tsx index efcf41f..230c86a 100644 --- a/Source/JavaScript/components/editors/for_SceneObjectNavigationalBar/when_navigating_back_up_the_trail.tsx +++ b/Source/JavaScript/components/editors/for_SceneObjectNavigationalBar/when_navigating_back_up_the_trail.tsx @@ -2,25 +2,19 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { fireEvent, render, screen } from '@testing-library/react'; -import { PrimeReactProvider } from '@primereact/core'; import { externalComponent } from '../../given'; import { SceneObjectNavigationalBar } from '../SceneObjectNavigationalBar'; describe('when navigating back up the trail', () => { /** - * The `PrimeReactProvider` became mandatory with PrimeReact 11: every v11 component bottoms out in - * `usePrimeReact()`, which throws when the context is absent rather than falling back to a default - * configuration. The bar reaches one through the tooltip on its crumbs, so without this the spec fails - * at `render` with a provider error instead of exercising the trail at all. No preset is supplied - - * what is specified here is the navigation behavior, not how a crumb looks. + * No provider wrapper. Components 3 needed `PrimeReactProvider` here because the tooltip on each crumb + * bottomed out in a PrimeReact component that threw without one; Components 4 owns its own tooltip and + * this package no longer depends on PrimeReact at all. What is specified here is the navigation + * behavior, not how a crumb looks. */ beforeEach(() => { const element = externalComponent('Cratis.Components:objectNavigationalBar', { navigationPath: ['shipping', 'address', 'street'] }); - render( - - - - ); + render(); fireEvent.click(screen.getByText('address')); }); diff --git a/Source/JavaScript/components/package.json b/Source/JavaScript/components/package.json index fd8f7bf..83928f5 100644 --- a/Source/JavaScript/components/package.json +++ b/Source/JavaScript/components/package.json @@ -58,27 +58,20 @@ "@cratis/scene.react": "1.0.0" }, "devDependencies": { - "@cratis/components": "^3.0.0", + "@cratis/components": "^4.1.1", "@cratis/scene.engine": "1.0.0", "@cratis/scene.model": "1.0.0", "@cratis/scene.react": "1.0.0", - "@primereact/core": "11.1.0", - "@primereact/headless": "11.1.0", "@storybook/addon-links": "^10.4.1", "@storybook/react": "^10.4.1", "@storybook/react-vite": "^10.4.1", "primeicons": "8.0.0", - "primereact": "11.1.0", "react": "^19.2.6", "react-dom": "^19.2.6", "storybook": "^10.4.1" }, "peerDependencies": { - "@cratis/components": "^3.0.0", - "@primereact/core": "^11.0.0", - "@primereact/headless": "^11.0.0", - "primeicons": "^8.0.0", - "primereact": "^11.0.0", + "@cratis/components": "^4.1.1", "react": "^19.0.0", "react-dom": "^19.0.0" } diff --git a/Source/JavaScript/engine/for_panelKind/when_telling_the_panels_apart.ts b/Source/JavaScript/engine/for_panelKind/when_telling_the_panels_apart.ts new file mode 100644 index 0000000..81cac86 --- /dev/null +++ b/Source/JavaScript/engine/for_panelKind/when_telling_the_panels_apart.ts @@ -0,0 +1,42 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { DockPanel, Grid, HorizontalAlignment, Orientation, Panel, StackPanel, VerticalAlignment, Visibility, WrapPanel } from '@cratis/scene.model'; +import { isDockPanel, isGrid, isStackPanel, isWrapPanel } from '../index'; + +const base = { + id: 'panel', + properties: {}, + visibility: Visibility.Visible, + isEnabled: true, + opacity: 1, + zIndex: 0, + size: {}, + name: 'panel', + minimumSize: {}, + maximumSize: {}, + margin: { left: 0, top: 0, right: 0, bottom: 0 }, + horizontalAlignment: HorizontalAlignment.Stretch, + verticalAlignment: VerticalAlignment.Stretch, + children: [], +}; + +const grid: Grid = { ...base, rows: [], columns: [] }; +const dock: DockPanel = { ...base, lastChildFill: true }; +const stack: StackPanel = { ...base, orientation: Orientation.Vertical, spacing: 0 }; +const wrap: WrapPanel = { ...base, orientation: Orientation.Horizontal }; +const plain: Panel = { ...base }; + +describe('when telling the panels apart', () => { + it('should recognize a grid by its tracks', () => isGrid(grid).should.be.true); + it('should recognize a dock panel by its fill flag', () => isDockPanel(dock).should.be.true); + it('should recognize a stack panel by its spacing', () => isStackPanel(stack).should.be.true); + it('should recognize a wrap panel by an orientation with no spacing', () => isWrapPanel(wrap).should.be.true); + + it('should not mistake a stack panel for a wrap panel', () => isWrapPanel(stack).should.be.false); + it('should not mistake a wrap panel for a stack panel', () => isStackPanel(wrap).should.be.false); + it('should not mistake a grid for a dock panel', () => isDockPanel(grid).should.be.false); + + it('should leave a plain panel unclaimed by every guard', () => + [isGrid(plain), isDockPanel(plain), isStackPanel(plain), isWrapPanel(plain)].should.have.members([false, false, false, false])); +}); diff --git a/Source/JavaScript/engine/index.ts b/Source/JavaScript/engine/index.ts index 323fb15..fe37e50 100644 --- a/Source/JavaScript/engine/index.ts +++ b/Source/JavaScript/engine/index.ts @@ -5,6 +5,7 @@ export * from './Renderer'; export * from './BindingResolver'; export * from './renderElement'; export * from './elementKind'; +export * from './panelKind'; export * from './flowNodeKind'; export * from './ComponentResolution'; export * from './resolveComponentName'; diff --git a/Source/JavaScript/engine/panelKind.ts b/Source/JavaScript/engine/panelKind.ts new file mode 100644 index 0000000..a8dd278 --- /dev/null +++ b/Source/JavaScript/engine/panelKind.ts @@ -0,0 +1,33 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { DockPanel, Grid, Panel, StackPanel, WrapPanel } from '@cratis/scene.model'; + +/** + * Type guards telling the concrete panels apart, by the property each one alone declares - the same way + * {@link isPanel} and its siblings tell the element kinds apart. The model carries no discriminator, so a + * panel is recognized by its own shape rather than by a tag it was serialized with. + * + * A plain {@link Panel} matches none of these. That is a real case rather than an oversight: a panel that + * says nothing about how it arranges its children is a grouping, and a renderer is free to lay it out in + * whatever way its platform considers neutral. + */ + +export function isGrid(panel: Panel): panel is Grid { + return 'rows' in panel && 'columns' in panel; +} + +export function isDockPanel(panel: Panel): panel is DockPanel { + return 'lastChildFill' in panel; +} + +export function isStackPanel(panel: Panel): panel is StackPanel { + return 'spacing' in panel && 'orientation' in panel; +} + +export function isWrapPanel(panel: Panel): panel is WrapPanel { + // A wrap panel and a stack panel both orient their line; only the stack panel spaces its children, + // and only the wrap panel sizes them. Checking for the absence of `spacing` is what separates a wrap + // panel that happens to size neither of its axes from a stack panel. + return 'orientation' in panel && !('spacing' in panel); +} diff --git a/Source/JavaScript/model/common/Dock.ts b/Source/JavaScript/model/common/Dock.ts new file mode 100644 index 0000000..1ba25f3 --- /dev/null +++ b/Source/JavaScript/model/common/Dock.ts @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * The edge of a dock panel a child is docked against. + */ +export enum Dock { + Left = 'Left', + Top = 'Top', + Right = 'Right', + Bottom = 'Bottom', +} diff --git a/Source/JavaScript/model/common/GridLength.ts b/Source/JavaScript/model/common/GridLength.ts new file mode 100644 index 0000000..792d5d6 --- /dev/null +++ b/Source/JavaScript/model/common/GridLength.ts @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { GridUnitType } from './GridUnitType'; + +/** + * The length of a grid row or column, which can be absolute, sized to its content, or a weighted + * share of the space the absolute and content-sized tracks leave behind. + */ +export interface GridLength { + value: number; + unitType: GridUnitType; +} + +export const GridLengthPropertyNames: (keyof GridLength)[] = ['value', 'unitType']; diff --git a/Source/JavaScript/model/common/GridUnitType.ts b/Source/JavaScript/model/common/GridUnitType.ts new file mode 100644 index 0000000..7eab28d --- /dev/null +++ b/Source/JavaScript/model/common/GridUnitType.ts @@ -0,0 +1,11 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * How a {@link GridLength}'s value is interpreted. + */ +export enum GridUnitType { + Auto = 'Auto', + Absolute = 'Absolute', + Star = 'Star', +} diff --git a/Source/JavaScript/model/common/Orientation.ts b/Source/JavaScript/model/common/Orientation.ts new file mode 100644 index 0000000..822d325 --- /dev/null +++ b/Source/JavaScript/model/common/Orientation.ts @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * The direction an element lays its children out in. + */ +export enum Orientation { + Horizontal = 'Horizontal', + Vertical = 'Vertical', +} diff --git a/Source/JavaScript/model/common/index.ts b/Source/JavaScript/model/common/index.ts index 467f235..46711b0 100644 --- a/Source/JavaScript/model/common/index.ts +++ b/Source/JavaScript/model/common/index.ts @@ -8,3 +8,7 @@ export * from './HorizontalAlignment'; export * from './VerticalAlignment'; export * from './Visibility'; export * from './BindingExpression'; +export * from './Orientation'; +export * from './Dock'; +export * from './GridUnitType'; +export * from './GridLength'; diff --git a/Source/JavaScript/model/elements/index.ts b/Source/JavaScript/model/elements/index.ts index 3ba3293..5016594 100644 --- a/Source/JavaScript/model/elements/index.ts +++ b/Source/JavaScript/model/elements/index.ts @@ -9,3 +9,4 @@ export * from './Panel'; export * from './ItemsControl'; export * from './ContentControl'; export * from './ExternalComponent'; +export * from './panels'; diff --git a/Source/JavaScript/model/elements/panels/ColumnDefinition.ts b/Source/JavaScript/model/elements/panels/ColumnDefinition.ts new file mode 100644 index 0000000..63c3ebe --- /dev/null +++ b/Source/JavaScript/model/elements/panels/ColumnDefinition.ts @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { GridLength } from '../../common'; + +/** + * One column of a {@link Grid}. + */ +export interface ColumnDefinition { + width: GridLength; + minimumWidth: number; + maximumWidth: number; +} + +export const ColumnDefinitionPropertyNames: (keyof ColumnDefinition)[] = ['width', 'minimumWidth', 'maximumWidth']; diff --git a/Source/JavaScript/model/elements/panels/DockPanel.ts b/Source/JavaScript/model/elements/panels/DockPanel.ts new file mode 100644 index 0000000..6502016 --- /dev/null +++ b/Source/JavaScript/model/elements/panels/DockPanel.ts @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Panel } from '../Panel'; + +/** + * Lays its children out against its edges, each child stating which edge through the `Dock` key + * of its `properties`. + */ +export interface DockPanel extends Panel { + lastChildFill: boolean; +} + +export const DockPanelPropertyNames: (keyof DockPanel)[] = ['lastChildFill']; diff --git a/Source/JavaScript/model/elements/panels/Grid.ts b/Source/JavaScript/model/elements/panels/Grid.ts new file mode 100644 index 0000000..70d3f71 --- /dev/null +++ b/Source/JavaScript/model/elements/panels/Grid.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Panel } from '../Panel'; +import { RowDefinition } from './RowDefinition'; +import { ColumnDefinition } from './ColumnDefinition'; + +/** + * Lays its children out in rows and columns. A child states which cell it occupies through the + * `Grid.Row`, `Grid.Column`, `Grid.RowSpan` and `Grid.ColumnSpan` keys of its `properties`. + */ +export interface Grid extends Panel { + rows: RowDefinition[]; + columns: ColumnDefinition[]; +} + +export const GridPropertyNames: (keyof Grid)[] = ['rows', 'columns']; diff --git a/Source/JavaScript/model/elements/panels/RowDefinition.ts b/Source/JavaScript/model/elements/panels/RowDefinition.ts new file mode 100644 index 0000000..5aa1a40 --- /dev/null +++ b/Source/JavaScript/model/elements/panels/RowDefinition.ts @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { GridLength } from '../../common'; + +/** + * One row of a {@link Grid}. + */ +export interface RowDefinition { + height: GridLength; + minimumHeight: number; + maximumHeight: number; +} + +export const RowDefinitionPropertyNames: (keyof RowDefinition)[] = ['height', 'minimumHeight', 'maximumHeight']; diff --git a/Source/JavaScript/model/elements/panels/StackPanel.ts b/Source/JavaScript/model/elements/panels/StackPanel.ts new file mode 100644 index 0000000..5b59e79 --- /dev/null +++ b/Source/JavaScript/model/elements/panels/StackPanel.ts @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Panel } from '../Panel'; +import { Orientation } from '../../common'; + +/** + * Lays its children out in a single line. + */ +export interface StackPanel extends Panel { + orientation: Orientation; + spacing: number; +} + +export const StackPanelPropertyNames: (keyof StackPanel)[] = ['orientation', 'spacing']; diff --git a/Source/JavaScript/model/elements/panels/WrapPanel.ts b/Source/JavaScript/model/elements/panels/WrapPanel.ts new file mode 100644 index 0000000..7ed3503 --- /dev/null +++ b/Source/JavaScript/model/elements/panels/WrapPanel.ts @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Panel } from '../Panel'; +import { Orientation } from '../../common'; + +/** + * Lays its children out in a line and starts a new one whenever the current line runs out of room. + */ +export interface WrapPanel extends Panel { + orientation: Orientation; + itemWidth?: number; + itemHeight?: number; +} + +export const WrapPanelPropertyNames: (keyof WrapPanel)[] = ['orientation', 'itemWidth', 'itemHeight']; diff --git a/Source/JavaScript/model/elements/panels/index.ts b/Source/JavaScript/model/elements/panels/index.ts new file mode 100644 index 0000000..a5a6f81 --- /dev/null +++ b/Source/JavaScript/model/elements/panels/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export * from './StackPanel'; +export * from './DockPanel'; +export * from './WrapPanel'; +export * from './RowDefinition'; +export * from './ColumnDefinition'; +export * from './Grid'; diff --git a/Source/JavaScript/react/for_panelLayout/when_arranging_a_panel.ts b/Source/JavaScript/react/for_panelLayout/when_arranging_a_panel.ts new file mode 100644 index 0000000..09f4d29 --- /dev/null +++ b/Source/JavaScript/react/for_panelLayout/when_arranging_a_panel.ts @@ -0,0 +1,112 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ColumnDefinition, Dock, DockPanel, Grid, GridUnitType, HorizontalAlignment, Orientation, RowDefinition, SceneElement, StackPanel, VerticalAlignment, Visibility, WrapPanel } from '@cratis/scene.model'; +import { childStyle, panelStyle } from '../renderer/panelLayout'; + +const base = { + id: 'panel', + properties: {}, + visibility: Visibility.Visible, + isEnabled: true, + opacity: 1, + zIndex: 0, + size: {}, + name: 'panel', + minimumSize: {}, + maximumSize: {}, + margin: { left: 0, top: 0, right: 0, bottom: 0 }, + horizontalAlignment: HorizontalAlignment.Stretch, + verticalAlignment: VerticalAlignment.Stretch, + children: [] as SceneElement[], +}; + +const child = (properties: Record = {}): SceneElement => ({ id: 'child', properties }); + +const star = (value: number): RowDefinition['height'] => ({ value, unitType: GridUnitType.Star }); +const row = (over: Partial = {}): RowDefinition => ({ height: star(1), minimumHeight: 0, maximumHeight: Number.POSITIVE_INFINITY, ...over }); +const column = (over: Partial = {}): ColumnDefinition => ({ width: star(1), minimumWidth: 0, maximumWidth: Number.POSITIVE_INFINITY, ...over }); + +describe('when arranging a panel', () => { + it('should lay a stack panel out along its orientation', () => { + const panel: StackPanel = { ...base, orientation: Orientation.Horizontal, spacing: 8 }; + panelStyle(panel)!.flexDirection!.should.equal('row'); + }); + + it('should space a stack panel by its spacing', () => { + const panel: StackPanel = { ...base, orientation: Orientation.Vertical, spacing: 12 }; + panelStyle(panel)!.gap!.should.equal('12px'); + }); + + it('should wrap a wrap panel', () => { + const panel: WrapPanel = { ...base, orientation: Orientation.Horizontal }; + panelStyle(panel)!.flexWrap!.should.equal('wrap'); + }); + + it('should turn star tracks into fractions', () => { + const panel: Grid = { ...base, rows: [row(), row({ height: star(2) })], columns: [] }; + panelStyle(panel)!.gridTemplateRows!.should.equal('1fr 2fr'); + }); + + it('should turn an absolute track into pixels', () => { + const panel: Grid = { ...base, rows: [], columns: [column({ width: { value: 240, unitType: GridUnitType.Absolute } })] }; + panelStyle(panel)!.gridTemplateColumns!.should.equal('240px'); + }); + + it('should turn a content-sized track into auto', () => { + const panel: Grid = { ...base, rows: [], columns: [column({ width: { value: 0, unitType: GridUnitType.Auto } })] }; + panelStyle(panel)!.gridTemplateColumns!.should.equal('auto'); + }); + + it('should bound a track that states a minimum', () => { + const panel: Grid = { ...base, rows: [row({ minimumHeight: 40 })], columns: [] }; + panelStyle(panel)!.gridTemplateRows!.should.equal('minmax(40px, 1fr)'); + }); + + it('should leave a plain panel unstyled', () => (panelStyle({ ...base }) === undefined).should.be.true); +}); + +describe('when placing a child in a panel', () => { + it('should place a grid child in the cell it names, one-based for CSS', () => { + const panel: Grid = { ...base, rows: [row()], columns: [column()] }; + childStyle(panel, child({ 'Grid.Row': 1, 'Grid.Column': 2 }), 0)!.gridRow!.should.equal('2 / span 1'); + }); + + it('should span a grid child that asks for it', () => { + const panel: Grid = { ...base, rows: [row()], columns: [column()] }; + childStyle(panel, child({ 'Grid.Column': 0, 'Grid.ColumnSpan': 3 }), 0)!.gridColumn!.should.equal('1 / span 3'); + }); + + it('should leave a grid child that names no cell to the implicit flow', () => { + const panel: Grid = { ...base, rows: [row()], columns: [column()] }; + (childStyle(panel, child(), 0) === undefined).should.be.true; + }); + + it('should let a dock panel last child fill what is left', () => { + const kid = child(); + const panel: DockPanel = { ...base, lastChildFill: true, children: [kid] }; + childStyle(panel, kid, 0)!.flex!.should.equal('1 1 auto'); + }); + + it('should keep a docked child at its natural size', () => { + const first = child({ Dock: Dock.Top }); + const panel: DockPanel = { ...base, lastChildFill: true, children: [first, child()] }; + childStyle(panel, first, 0)!.flex!.should.equal('0 0 auto'); + }); + + it('should not let the last child fill when the panel says not to', () => { + const kid = child(); + const panel: DockPanel = { ...base, lastChildFill: false, children: [kid] }; + childStyle(panel, kid, 0)!.flex!.should.equal('0 0 auto'); + }); + + it('should size wrap panel children when the panel sizes them', () => { + const panel: WrapPanel = { ...base, orientation: Orientation.Horizontal, itemWidth: 120 }; + childStyle(panel, child(), 0)!.width!.should.equal(120); + }); + + it('should leave wrap panel children their own size when the panel sizes neither axis', () => { + const panel: WrapPanel = { ...base, orientation: Orientation.Horizontal }; + (childStyle(panel, child(), 0) === undefined).should.be.true; + }); +}); diff --git a/Source/JavaScript/react/renderer/createReactRenderer.tsx b/Source/JavaScript/react/renderer/createReactRenderer.tsx index 129f2b6..b4b1f99 100644 --- a/Source/JavaScript/react/renderer/createReactRenderer.tsx +++ b/Source/JavaScript/react/renderer/createReactRenderer.tsx @@ -5,13 +5,16 @@ import { ReactNode, createElement } from 'react'; import { Renderer } from '@cratis/scene.engine'; import { ContentControl, ExternalComponent, ItemsControl, Panel } from '@cratis/scene.model'; import { ComponentRegistry } from './ComponentRegistry'; +import { childStyle, panelStyle } from './panelLayout'; import { UnresolvedComponent } from './UnresolvedComponent'; /** - * Creates a {@link Renderer} that turns a Scene element tree into React elements. `Panel`, `ItemsControl` - * and `ContentControl` render as plain wrapping `div`s here - arranging them according to a layout's - * `flow`/`freeform` arrangement is Scene#4's job, layered on top of this renderer rather than folded - * into it. + * Creates a {@link Renderer} that turns a Scene element tree into React elements. + * + * `ItemsControl` and `ContentControl` render as plain wrapping `div`s. A `Panel` renders as the + * arrangement it declares - a grid, a stack, a wrap or a dock - because that is what those panels are + * for; see `panelLayout` for the mapping. Arranging a *layout's slots* by `flow`/`freeform` is a + * different thing and stays Scene#4's, layered on top of this renderer rather than folded into it. */ export function createReactRenderer(registry: ComponentRegistry): Renderer { return { @@ -29,7 +32,20 @@ export function createReactRenderer(registry: ComponentRegistry): Renderer { + const style = childStyle(element, element.children[index], index); + return style + ? createElement('div', { key: `${element.id}-${index}`, 'data-scene-placement': index, style }, child) + : child; + }); + + return createElement( + 'div', + { key: element.id, 'data-scene-id': element.id, 'data-scene-kind': 'Panel', style: panelStyle(element) }, + placed); }, }; } diff --git a/Source/JavaScript/react/renderer/panelLayout.ts b/Source/JavaScript/react/renderer/panelLayout.ts new file mode 100644 index 0000000..d424339 --- /dev/null +++ b/Source/JavaScript/react/renderer/panelLayout.ts @@ -0,0 +1,109 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { CSSProperties } from 'react'; +import { isDockPanel, isGrid, isStackPanel, isWrapPanel } from '@cratis/scene.engine'; +import { Dock, GridUnitType, Orientation } from '@cratis/scene.model'; +import type { ColumnDefinition, GridLength, Panel, RowDefinition, SceneElement } from '@cratis/scene.model'; + +/** + * Turns a panel into the CSS that arranges it, and a child into the CSS that places it inside one. + * + * The panels say what an arrangement *is*; this says what it looks like on the web. A second renderer - + * native, desktop - answers the same question with its own platform's layout primitives, which is why + * this lives in `Scene.React` rather than in the model or the engine. + * + * Where a panel needs to say something about an individual child - which grid cell, which edge - it does + * so through that child's own `properties` bag, because the model has no attached properties of its own. + * The keys are the ones the model's documentation names: `Grid.Row`, `Grid.Column`, `Grid.RowSpan`, + * `Grid.ColumnSpan` and `Dock`. + */ + +const track = (length: GridLength): string => { + switch (length.unitType) { + case GridUnitType.Auto: return 'auto'; + case GridUnitType.Absolute: return `${length.value}px`; + case GridUnitType.Star: return `${length.value}fr`; + } +}; + +const bound = (minimum: number, maximum: number, size: string): string => + minimum === 0 && !Number.isFinite(maximum) + ? size + : `minmax(${minimum}px, ${Number.isFinite(maximum) ? `${maximum}px` : size})`; + +const rowTrack = (row: RowDefinition): string => bound(row.minimumHeight, row.maximumHeight, track(row.height)); +const columnTrack = (column: ColumnDefinition): string => bound(column.minimumWidth, column.maximumWidth, track(column.width)); + +const number = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined); + +/** The CSS that arranges a panel's own children. */ +export function panelStyle(panel: Panel): CSSProperties | undefined { + if (isGrid(panel)) { + return { + display: 'grid', + gridTemplateRows: panel.rows.map(rowTrack).join(' ') || undefined, + gridTemplateColumns: panel.columns.map(columnTrack).join(' ') || undefined, + }; + } + + if (isDockPanel(panel)) { + // Docking has no single CSS primitive. A column of rows with a middle that grows is the shape a + // dock panel actually produces, and `lastChildFill` decides whether the final child is that + // middle or just another docked edge. + return { display: 'flex', flexDirection: 'column' }; + } + + if (isStackPanel(panel)) { + return { + display: 'flex', + flexDirection: panel.orientation === Orientation.Horizontal ? 'row' : 'column', + gap: panel.spacing ? `${panel.spacing}px` : undefined, + }; + } + + if (isWrapPanel(panel)) { + return { + display: 'flex', + flexWrap: 'wrap', + flexDirection: panel.orientation === Orientation.Horizontal ? 'row' : 'column', + }; + } + + return undefined; +} + +/** The CSS that places one child within its panel, or `undefined` when the panel places children implicitly. */ +export function childStyle(panel: Panel, child: SceneElement, index: number): CSSProperties | undefined { + const properties = child.properties ?? {}; + + if (isGrid(panel)) { + const row = number(properties['Grid.Row']); + const column = number(properties['Grid.Column']); + const rowSpan = number(properties['Grid.RowSpan']) ?? 1; + const columnSpan = number(properties['Grid.ColumnSpan']) ?? 1; + if (row === undefined && column === undefined) return undefined; + return { + gridRow: row === undefined ? undefined : `${row + 1} / span ${rowSpan}`, + gridColumn: column === undefined ? undefined : `${column + 1} / span ${columnSpan}`, + }; + } + + if (isDockPanel(panel)) { + const dock = properties['Dock'] as Dock | undefined; + const isFill = panel.lastChildFill && index === (panel.children?.length ?? 0) - 1; + if (isFill) return { flex: '1 1 auto', minHeight: 0 }; + // Left and right dock along the cross axis of the column the panel lays out, so they become a row + // of their own rather than a sibling in the column. + return dock === Dock.Left || dock === Dock.Right + ? { alignSelf: dock === Dock.Left ? 'flex-start' : 'flex-end', flex: '0 0 auto' } + : { flex: '0 0 auto' }; + } + + if (isWrapPanel(panel)) { + if (panel.itemWidth === undefined && panel.itemHeight === undefined) return undefined; + return { width: panel.itemWidth, height: panel.itemHeight, flex: '0 0 auto' }; + } + + return undefined; +} diff --git a/scene-model-shape.json b/scene-model-shape.json index 1e1a988..ba7328d 100644 --- a/scene-model-shape.json +++ b/scene-model-shape.json @@ -241,6 +241,36 @@ "content", "displayName", "description" + ], + "GridLength": [ + "value", + "unitType" + ], + "StackPanel": [ + "orientation", + "spacing" + ], + "DockPanel": [ + "lastChildFill" + ], + "WrapPanel": [ + "orientation", + "itemWidth", + "itemHeight" + ], + "RowDefinition": [ + "height", + "minimumHeight", + "maximumHeight" + ], + "ColumnDefinition": [ + "width", + "minimumWidth", + "maximumWidth" + ], + "Grid": [ + "rows", + "columns" ] }, "enums": { @@ -278,6 +308,21 @@ "Row", "Column", "Grid" + ], + "Orientation": [ + "Horizontal", + "Vertical" + ], + "Dock": [ + "Left", + "Top", + "Right", + "Bottom" + ], + "GridUnitType": [ + "Auto", + "Absolute", + "Star" ] } }