From 2eb5e48dd928800df24f8be8128f328bc8d1cf55 Mon Sep 17 00:00:00 2001 From: Einar Date: Sun, 16 Aug 2026 10:21:04 +0200 Subject: [PATCH 1/7] Add the package model and dependency resolution engine A `ui profile` lists packages by name; nothing until now said what a name means or what else has to be active for it to work. `ScenePackage` is that declaration - kind, version, dependencies, and what the package contributes (components, layouts, themes). `PackageDependencyResolver` expands a chosen set into the complete, topologically ordered list a profile needs, so a package always outranks what it depends on. Missing dependencies, version conflicts and cycles are reported rather than silently resolved. `PackageCatalog` answers the questions a picker asks: which component libraries are a base to build on, and what else fits what is already chosen. `Theme` gains design tokens plus author, link and license, so a theme adopted from elsewhere credits its original creator rather than appearing to be ours. Both languages assert the same `package-dependency-fixtures.json` corpus, the pattern already used for model shape, package resolution, layout evaluation and theme compatibility. Co-Authored-By: Claude Opus 5 (1M context) --- ...rying_against_the_shared_fixture_corpus.cs | 48 ++++ .../PackageFixtures.cs | 63 +++++ ...lving_against_the_shared_fixture_corpus.cs | 92 +++++++ ...cking_against_the_shared_fixture_corpus.cs | 40 +++ .../Packages/MissingPackageDependency.cs | 12 + .../DotNET/Engine/Packages/PackageCatalog.cs | 102 ++++++++ .../Packages/PackageDependencyResolver.cs | Bin 0 -> 8335 bytes .../Engine/Packages/PackageSelection.cs | 37 +++ .../Engine/Packages/PackageVersionConflict.cs | 14 ++ .../Engine/Packages/PackageVersionRange.cs | 124 ++++++++++ .../Model/Packages/PackageDependency.cs | 17 ++ Source/DotNET/Model/Packages/PackageKind.cs | 32 +++ Source/DotNET/Model/Packages/ScenePackage.cs | 34 +++ Source/DotNET/Model/Profiles/Theme.cs | 30 ++- Source/JavaScript/engine/PackageSelection.ts | 87 +++++++ ...rying_against_the_shared_fixture_corpus.ts | 26 ++ .../for_packageDependencies/fixtures.ts | 44 ++++ ...lving_against_the_shared_fixture_corpus.ts | 39 +++ ...cking_against_the_shared_fixture_corpus.ts | 13 + Source/JavaScript/engine/index.ts | 4 + Source/JavaScript/engine/packageCatalog.ts | 65 +++++ .../JavaScript/engine/packageVersionRange.ts | 90 +++++++ .../engine/resolvePackageDependencies.ts | 156 ++++++++++++ Source/JavaScript/model/index.ts | 1 + Source/JavaScript/model/profiles/Theme.ts | 56 ++++- package-dependency-fixtures.json | 233 ++++++++++++++++++ scene-model-shape.json | 5 +- 27 files changed, 1454 insertions(+), 10 deletions(-) create mode 100644 Source/DotNET/Engine.Specs/for_PackageCatalog/when_querying_against_the_shared_fixture_corpus.cs create mode 100644 Source/DotNET/Engine.Specs/for_PackageDependencyResolver/PackageFixtures.cs create mode 100644 Source/DotNET/Engine.Specs/for_PackageDependencyResolver/when_resolving_against_the_shared_fixture_corpus.cs create mode 100644 Source/DotNET/Engine.Specs/for_PackageVersionRange/when_checking_against_the_shared_fixture_corpus.cs create mode 100644 Source/DotNET/Engine/Packages/MissingPackageDependency.cs create mode 100644 Source/DotNET/Engine/Packages/PackageCatalog.cs create mode 100644 Source/DotNET/Engine/Packages/PackageDependencyResolver.cs create mode 100644 Source/DotNET/Engine/Packages/PackageSelection.cs create mode 100644 Source/DotNET/Engine/Packages/PackageVersionConflict.cs create mode 100644 Source/DotNET/Engine/Packages/PackageVersionRange.cs create mode 100644 Source/DotNET/Model/Packages/PackageDependency.cs create mode 100644 Source/DotNET/Model/Packages/PackageKind.cs create mode 100644 Source/DotNET/Model/Packages/ScenePackage.cs create mode 100644 Source/JavaScript/engine/PackageSelection.ts create mode 100644 Source/JavaScript/engine/for_packageCatalog/when_querying_against_the_shared_fixture_corpus.ts create mode 100644 Source/JavaScript/engine/for_packageDependencies/fixtures.ts create mode 100644 Source/JavaScript/engine/for_packageDependencies/when_resolving_against_the_shared_fixture_corpus.ts create mode 100644 Source/JavaScript/engine/for_packageVersionRange/when_checking_against_the_shared_fixture_corpus.ts create mode 100644 Source/JavaScript/engine/packageCatalog.ts create mode 100644 Source/JavaScript/engine/packageVersionRange.ts create mode 100644 Source/JavaScript/engine/resolvePackageDependencies.ts create mode 100644 package-dependency-fixtures.json diff --git a/Source/DotNET/Engine.Specs/for_PackageCatalog/when_querying_against_the_shared_fixture_corpus.cs b/Source/DotNET/Engine.Specs/for_PackageCatalog/when_querying_against_the_shared_fixture_corpus.cs new file mode 100644 index 0000000..d67093d --- /dev/null +++ b/Source/DotNET/Engine.Specs/for_PackageCatalog/when_querying_against_the_shared_fixture_corpus.cs @@ -0,0 +1,48 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using Cratis.Scene.Engine.for_PackageDependencyResolver; +using Cratis.Scene.Engine.Packages; + +namespace Cratis.Scene.Engine.for_PackageCatalog; + +public class when_querying_against_the_shared_fixture_corpus : Specification +{ + record FixtureCase(string Name, string Query, IReadOnlyList Selected, IReadOnlyList Expected); + + List _cases = null!; + List<(FixtureCase Case, IReadOnlyList Actual)> _results = null!; + + void Establish() + { + using var document = JsonDocument.Parse(File.ReadAllText(PackageFixtures.FixturePath)); + _cases = + [ + .. document.RootElement.GetProperty("catalogCases").EnumerateArray().Select(element => new FixtureCase( + element.GetProperty("name").GetString()!, + element.GetProperty("query").GetString()!, + element.TryGetProperty("selected", out var selected) ? [.. selected.EnumerateArray().Select(value => value.GetString()!)] : [], + [.. element.GetProperty("expected").EnumerateArray().Select(value => value.GetString()!)])) + ]; + } + + void Because() => _results = [.. _cases.Select(fixtureCase => (fixtureCase, Run(fixtureCase)))]; + + [Fact] + void should_match_the_expected_result_for_every_case() + { + foreach (var (fixtureCase, actual) in _results) + { + (fixtureCase.Name, string.Join(',', actual)).ShouldEqual((fixtureCase.Name, string.Join(',', fixtureCase.Expected))); + } + } + + static IReadOnlyList Run(FixtureCase fixtureCase) => fixtureCase.Query switch + { + "baseComponentLibraries" => [.. PackageCatalog.BaseComponentLibraries(PackageFixtures.Catalog).Select(package => package.Name)], + "availableFor" => [.. PackageCatalog.AvailableFor(PackageFixtures.Catalog, fixtureCase.Selected).Select(package => package.Name)], + "componentsFor" => PackageCatalog.ComponentsFor(PackageFixtures.Catalog, fixtureCase.Selected), + _ => throw new NotSupportedException($"The fixture corpus asks for an unknown query '{fixtureCase.Query}'") + }; +} diff --git a/Source/DotNET/Engine.Specs/for_PackageDependencyResolver/PackageFixtures.cs b/Source/DotNET/Engine.Specs/for_PackageDependencyResolver/PackageFixtures.cs new file mode 100644 index 0000000..eee2b08 --- /dev/null +++ b/Source/DotNET/Engine.Specs/for_PackageDependencyResolver/PackageFixtures.cs @@ -0,0 +1,63 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using Cratis.Scene.Model.Packages; + +namespace Cratis.Scene.Engine.for_PackageDependencyResolver; + +/// +/// Reads the catalog out of package-dependency-fixtures.json - the same file the TypeScript specs +/// read, so both languages resolve against an identical set of packages rather than two hand-written +/// ones that can quietly diverge. +/// +public static class PackageFixtures +{ + static readonly Lazy> _catalog = new(Load); + + /// + /// Gets the shared fixture catalog. + /// + public static IReadOnlyList Catalog => _catalog.Value; + + /// + /// Gets the path of the shared fixture file, so a spec reading a different section of the same corpus + /// does not have to find the repository root for itself. + /// + public static string FixturePath => Path.Combine(FindRepositoryRoot(), "package-dependency-fixtures.json"); + + static IReadOnlyList Load() + { + using var document = JsonDocument.Parse(File.ReadAllText(FixturePath)); + return [.. document.RootElement.GetProperty("catalog").EnumerateArray().Select(ToPackage)]; + } + + static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Scene.slnx"))) + { + directory = directory.Parent; + } + + return directory?.FullName ?? throw new DirectoryNotFoundException("Could not locate the repository root (Scene.slnx) above " + AppContext.BaseDirectory); + } + + static ScenePackage ToPackage(JsonElement element) => + new( + element.GetProperty("name").GetString()!, + element.GetProperty("version").GetString()!, + Enum.Parse(element.GetProperty("kind").GetString()!), + [.. element.GetProperty("dependencies").EnumerateArray().Select(ToDependency)], + Strings(element, "components"), + Strings(element, "layouts"), + Strings(element, "themes")); + + static PackageDependency ToDependency(JsonElement element) => + new( + element.GetProperty("name").GetString()!, + element.TryGetProperty("versionRange", out var range) ? range.GetString() : null); + + static IReadOnlyList Strings(JsonElement element, string property) => + [.. element.GetProperty(property).EnumerateArray().Select(value => value.GetString()!)]; +} diff --git a/Source/DotNET/Engine.Specs/for_PackageDependencyResolver/when_resolving_against_the_shared_fixture_corpus.cs b/Source/DotNET/Engine.Specs/for_PackageDependencyResolver/when_resolving_against_the_shared_fixture_corpus.cs new file mode 100644 index 0000000..bfa0beb --- /dev/null +++ b/Source/DotNET/Engine.Specs/for_PackageDependencyResolver/when_resolving_against_the_shared_fixture_corpus.cs @@ -0,0 +1,92 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using Cratis.Scene.Engine.Packages; + +namespace Cratis.Scene.Engine.for_PackageDependencyResolver; + +public class when_resolving_against_the_shared_fixture_corpus : Specification +{ + record FixtureCase( + string Name, + IReadOnlyList Selected, + IReadOnlyList ExpectedPackages, + IReadOnlyList ExpectedAdded, + IReadOnlyList ExpectedMissing, + IReadOnlyList ExpectedVersionConflicts, + IReadOnlyList ExpectedCycles); + + List _cases = null!; + List<(FixtureCase Case, PackageSelection Selection)> _results = null!; + + void Establish() + { + using var document = JsonDocument.Parse(File.ReadAllText(PackageFixtures.FixturePath)); + _cases = [.. document.RootElement.GetProperty("resolutionCases").EnumerateArray().Select(ToFixtureCase)]; + } + + void Because() => _results = [.. _cases.Select(fixtureCase => ( + fixtureCase, + PackageDependencyResolver.Resolve(fixtureCase.Selected, PackageFixtures.Catalog)))]; + + [Fact] + void should_order_every_case_as_the_corpus_expects() => + AssertEach(result => result.Selection.Packages, expected => expected.ExpectedPackages); + + [Fact] + void should_report_the_expected_transitively_added_packages_for_every_case() => + AssertEach(result => result.Selection.Added, expected => expected.ExpectedAdded); + + [Fact] + void should_report_the_expected_missing_dependencies_for_every_case() => + AssertEach( + result => [.. result.Selection.Missing.Select(missing => $"{missing.Package}->{missing.DependsOn}")], + expected => expected.ExpectedMissing); + + [Fact] + void should_report_the_expected_version_conflicts_for_every_case() => + AssertEach( + result => [.. result.Selection.VersionConflicts.Select(conflict => $"{conflict.Package}->{conflict.DependsOn}@{conflict.RequiredRange}!={conflict.ActualVersion}")], + expected => expected.ExpectedVersionConflicts); + + [Fact] + void should_report_the_expected_cycles_for_every_case() => + AssertEach( + result => [.. result.Selection.Cycles.Select(cycle => string.Join('>', cycle))], + expected => expected.ExpectedCycles); + + [Fact] + void should_consider_a_case_with_nothing_wrong_valid() + { + foreach (var (fixtureCase, selection) in _results) + { + var expectedValid = fixtureCase.ExpectedMissing.Count == 0 && fixtureCase.ExpectedVersionConflicts.Count == 0 && fixtureCase.ExpectedCycles.Count == 0; + (fixtureCase.Name, selection.IsValid).ShouldEqual((fixtureCase.Name, expectedValid)); + } + } + + void AssertEach(Func<(FixtureCase Case, PackageSelection Selection), IReadOnlyList> actual, Func> expected) + { + foreach (var result in _results) + { + (result.Case.Name, Flatten(actual(result))).ShouldEqual((result.Case.Name, Flatten(expected(result.Case)))); + } + } + + static string Flatten(IReadOnlyList values) => string.Join(',', values); + + static FixtureCase ToFixtureCase(JsonElement element) => + new( + element.GetProperty("name").GetString()!, + Strings(element, "selected"), + Strings(element, "expectedPackages"), + Strings(element, "expectedAdded"), + [.. element.GetProperty("expectedMissing").EnumerateArray().Select(missing => $"{missing.GetProperty("package").GetString()}->{missing.GetProperty("dependsOn").GetString()}")], + [.. element.GetProperty("expectedVersionConflicts").EnumerateArray().Select(conflict => + $"{conflict.GetProperty("package").GetString()}->{conflict.GetProperty("dependsOn").GetString()}@{conflict.GetProperty("requiredRange").GetString()}!={conflict.GetProperty("actualVersion").GetString()}")], + [.. element.GetProperty("expectedCycles").EnumerateArray().Select(cycle => string.Join('>', cycle.EnumerateArray().Select(name => name.GetString()!)))]); + + static IReadOnlyList Strings(JsonElement element, string property) => + [.. element.GetProperty(property).EnumerateArray().Select(value => value.GetString()!)]; +} diff --git a/Source/DotNET/Engine.Specs/for_PackageVersionRange/when_checking_against_the_shared_fixture_corpus.cs b/Source/DotNET/Engine.Specs/for_PackageVersionRange/when_checking_against_the_shared_fixture_corpus.cs new file mode 100644 index 0000000..2c8d80c --- /dev/null +++ b/Source/DotNET/Engine.Specs/for_PackageVersionRange/when_checking_against_the_shared_fixture_corpus.cs @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using Cratis.Scene.Engine.for_PackageDependencyResolver; +using Cratis.Scene.Engine.Packages; + +namespace Cratis.Scene.Engine.for_PackageVersionRange; + +public class when_checking_against_the_shared_fixture_corpus : Specification +{ + record FixtureCase(string Version, string? Range, bool Expected); + + List _cases = null!; + List<(FixtureCase Case, bool Actual)> _results = null!; + + void Establish() + { + using var document = JsonDocument.Parse(File.ReadAllText(PackageFixtures.FixturePath)); + _cases = + [ + .. document.RootElement.GetProperty("versionRangeCases").EnumerateArray().Select(element => new FixtureCase( + element.GetProperty("version").GetString()!, + element.GetProperty("range").ValueKind == JsonValueKind.Null ? null : element.GetProperty("range").GetString(), + element.GetProperty("expected").GetBoolean())) + ]; + } + + void Because() => _results = [.. _cases.Select(fixtureCase => (fixtureCase, PackageVersionRange.IsSatisfiedBy(fixtureCase.Version, fixtureCase.Range)))]; + + [Fact] + void should_match_the_expected_outcome_for_every_case() + { + foreach (var (fixtureCase, actual) in _results) + { + var description = $"{fixtureCase.Version} against '{fixtureCase.Range ?? ""}'"; + (description, actual).ShouldEqual((description, fixtureCase.Expected)); + } + } +} diff --git a/Source/DotNET/Engine/Packages/MissingPackageDependency.cs b/Source/DotNET/Engine/Packages/MissingPackageDependency.cs new file mode 100644 index 0000000..338a071 --- /dev/null +++ b/Source/DotNET/Engine/Packages/MissingPackageDependency.cs @@ -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. + +namespace Cratis.Scene.Engine.Packages; + +/// +/// A dependency a selected package declares that nothing in the catalog can satisfy - so it cannot be +/// pulled in automatically, and the selection is genuinely incomplete rather than merely under-specified. +/// +/// The package that declares the dependency. +/// The name of the package it depends on, which the catalog does not contain. +public record MissingPackageDependency(string Package, string DependsOn); diff --git a/Source/DotNET/Engine/Packages/PackageCatalog.cs b/Source/DotNET/Engine/Packages/PackageCatalog.cs new file mode 100644 index 0000000..8b12863 --- /dev/null +++ b/Source/DotNET/Engine/Packages/PackageCatalog.cs @@ -0,0 +1,102 @@ +// 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.Packages; + +namespace Cratis.Scene.Engine.Packages; + +/// +/// Queries over a set of s - what a package picker asks to build the choices it +/// offers. Kept beside rather than inside it: the resolver answers +/// "given this selection, what does it need", these answer "what can be selected in the first place". +/// +public static class PackageCatalog +{ + /// + /// The packages of a given kind. + /// + /// The packages to filter. + /// The to keep. + /// Every package of , in catalog order. + public static IReadOnlyList OfKind(IReadOnlyList catalog, PackageKind kind) => + [.. catalog.Where(package => package.Kind == kind)]; + + /// + /// The component libraries a profile can be founded on - the ones that do not themselves layer on + /// another component library. + /// + /// The packages to search. + /// Every base , in catalog order. + /// + /// "Base" is not a declared property; it falls out of the dependency graph. PrimeReact depends on a + /// styling package but on no other component library, so it is a base. @cratis/components + /// depends on PrimeReact, so it is not - it is something you add on top of a base you already picked. + /// Deriving it this way means a third party shipping their own library gets classified correctly + /// without having to declare anything extra. + /// + public static IReadOnlyList BaseComponentLibraries(IReadOnlyList catalog) + { + var libraries = catalog.Where(package => package.Kind == PackageKind.ComponentLibrary).ToList(); + var names = new HashSet(libraries.Select(package => package.Name), StringComparer.Ordinal); + return [.. libraries.Where(package => !package.Dependencies.Any(dependency => names.Contains(dependency.Name)))]; + } + + /// + /// The packages that can be added to a selection without pulling anything else in - every dependency + /// they declare is already selected. + /// + /// The packages to search. + /// The package names already chosen. + /// Every not-yet-selected package whose dependencies the selection already satisfies, in catalog order. + /// + /// This is the "what else works with what I have picked" list: choose PrimeReact and Tailwind, and + /// @cratis/components becomes available because both of its dependencies are now met. It is + /// deliberately stricter than , which will happily add the + /// missing dependencies for you - a picker wants to show what fits, not what would drag more in. + /// + public static IReadOnlyList AvailableFor(IReadOnlyList catalog, IReadOnlyList selected) + { + var chosen = new HashSet(selected, StringComparer.Ordinal); + return + [ + .. catalog.Where(package => + !chosen.Contains(package.Name) && + package.Dependencies.All(dependency => chosen.Contains(dependency.Name))) + ]; + } + + /// + /// Every component name the selected packages declare between them, without duplicates. + /// + /// The packages to draw from. + /// The package names in scope. + /// The component names, sorted, so a component picker has a stable list to show. + public static IReadOnlyList ComponentsFor(IReadOnlyList catalog, IReadOnlyList selected) + { + var chosen = new HashSet(selected, StringComparer.Ordinal); + return + [ + .. catalog + .Where(package => chosen.Contains(package.Name)) + .SelectMany(package => package.Components) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + ]; + } + + /// + /// Builds the component-name catalog resolves against. + /// + /// The packages to draw from. + /// Each package's declared component names, keyed by package name. + public static IReadOnlyDictionary> ToComponentCatalog(IReadOnlyList catalog) + { + var components = new Dictionary>(StringComparer.Ordinal); + foreach (var package in catalog) + { + components[package.Name] = package.Components; + } + + return components; + } +} diff --git a/Source/DotNET/Engine/Packages/PackageDependencyResolver.cs b/Source/DotNET/Engine/Packages/PackageDependencyResolver.cs new file mode 100644 index 0000000000000000000000000000000000000000..d4a88a9cb28feb99c36b9334fc1af058ef2fc3c3 GIT binary patch literal 8335 zcmd5>OLN=E5zbk^V(h~%Sw(@#buBURN=l-Xy>?7xeQ^0O0*2&7-~gEcC8M(b@Ahv|-tHPP%je?4%0|0Pye><@Po1#JDSNMq>}0tV zZ*;DVQ-wH~LRr!5lz4l6BTD}`6Kkc!o9m0KU)EP*qe~^U;YGEr{;Bdt*s5y8rm|vl zzzty=7U9tzH&-S*IXO6Owo#Hb?o-*@s!(P2UgmdltK6%T6C?M^)iPJZbysGqvAnl% z38By2VZWEQT|tATxcX8{Q#c_-zN?%v!l_178-dg4biqeMq^4rY^J-t0s!EYe(C_`PFPAG}l)}}gd~%_j-kQ@!@0Hle zawF`)h}%}In?s?iA6=5D*7+U4hSYr3x`CK@eS6Slk%=4F;HItBI=8xRMDqa1@$W2O z{V8W_E*)d27@ML+AGsI7Q_KeAUbt}5@91@;T^u+#aD{Fxp( zQ5lkNU*mt%crny|Mdm+S;+LeGGyumcS+u8x$^k@?EJF@J7JzjAmBS^H;J^>|4g^!s zFzHkuZsGXCHE{S`KR|2`4;4Ba?B>0H^V60V8(@s6vYa+{>o zh&%PKgNEjd`KtNL{`50V~$C3}_vMiN_b5{>W)CeasH=>*)E<0QD4XaLw{*Ae8WLa%r zuC4~!%X_Iyc?&K=MB_TdJiE=rX;#5$jbn-XPUV?l9WiES%B8g}5fzCY2>}QnN_e&@ z1e>7Q(TWFK(N6@I&<(DQ*K}$m--uAYBZi^9?DudLFMnM@K@E@)QMUO>$UU7Lz$Lqp za^6~iO~c74T48&@+9E)~?HjF%MY@@VM;vt6v-v21Ey5b%fiw8`1#6YAs=?m~1TveI z#_2}iD_r3{C|EPy!tl@?t$2Vz5yvjCU?e0J1P?-GFYj;<3E>eW&1r!}w80HDF;k0o zNF&7|*8~QrZO0i%nsiUp9DHtY2?qptAE+y0Lb$K^xC7&Z`r905_-k1@#u{AU9G$*k zS(38a+!JLK=^{cE(!d5DgKs!Xc0pG;B7brVS#yLlauJ4*P^ML~6?POz84#L-L%3Z9 zj`xno9OD{|WJ-^ncf05jpgeLq!WGCtuRXI4jzgfBEs=UZpa)(!WU2R~5D3C>jNs3PLA1pxEOiDgk^t~ZnsuR;@O%dzd$;GZEc45(?*t~{d*1f z0P?+?-ug#Dr;fi@Gf^^96 z$aMX=hFWT-$SpbO1gXj2lO=x zu}btbIH<=E!EnqwXHkBC<4A;g&-!#~{7i#JZ%4qyhH}I>`!)(k<#>_az%)_9Da?+bk^=1!>;r!%5T~siW9iEH zSQ?)*bZR>1>)_<>TTeoYRnCe;P=7|8`MfpjB9n?V5OL2rXb)#kX5X7Cl_zBj0#)|O z&7lMHj5wy`NLUMWM3h!0$T_LyODawP!Pe_otYgD)1=qD7d5QY2m~L26&ok%8 z8lIEBXDM_~=(+xj8lJe-O%?E{q!~!A+oOksGG?N#?pNvTL*p7fRsUQNEX18^p(lD_ zl3?P%SV7;QvSA^KlO^@JoF-%Gc$!K_mUdivhYqY?F^CB#9+oh%#Nlgu_044}H$!HI z+fv^h+!0MT2M;@A7uFO(RHH%s=^zN#9E||)z`kgr`c0dzPM8o0V~y3*way^%kpu)U zOVZ`IP6rYpUHyrceTFiZv6V$;{Ky)HRLwZ}%2b=VFCzNoz=)GV!1k-AV1oW8!m%zf z4~k{rm`t7o@V2T-s*<LkfF}qVhFd9r5jZf&q0x}8=gu}D0g~oM#Lk2 z`|T0bFV6b~7;UG|<3s5wIBBBnp*KC$=_`E#^8JF$zvzAjAt$QFxMl!SW2xdwaK<$Q zpkgv>lFtQ7#_S3G9&z#*8h$euKm0J|bo?@;uD;g|_`GxLDAP24otq~yKcAkydZba8 zVd=c?TlZTmrIW)UBcx+65qoV~KRhTEMrAYrLz9hWIJB-b#}NgFB!~rtnG5EZ7-MDP zn%Ab*wAtC}u-yrt%U^+4RZaX5W;-Mt?h5S7z2sh@o9|7A!tBw|I72v;|AwYEl!yAO zkY64o8u+S2Yk7-e+_#l_NZi#&ZnN!-xTI zFf=By3s@*etCDgXW$5ATnXF7BqICRILpJ8*BMI6mY4hFxIzwMkAIhMkox&dtFh=r& zo`5mFNVWuO1P=Mf6S97qp3KFoe!V)m!Y8l)uWZer45k5-qCXlWBzKxF7@kaYoGLLq zdqzgB%=>QPcV|?(h$g)lMtpZxPE>eb=|X%&w9b6iXG?Z;J)~*Mn_lKo-t0CIB@H*x z6B5-sDJB(A`4j!fuBl>sxA8T9mVJKA^Ry4~3@E*H+21N{WU^3bp$$4> zlU^r9LePgahz91wi_@Rq_i}5&+xYg0^C!~0XV#%^qh;L7M^kq7z*DF`<7CG9Ncj*f zdF1V6(cp(Om(MFbpAw(Fu1g&wG)E8xVLzxnnh)&;EIQr(ZJChUpFjRrcpStdt7M-&UT&`CI l4Fn*tJ0a*B3jxmLKcF+B)B%R?-RX~AkvIAUUr)ZC{0CMx`mO)~ literal 0 HcmV?d00001 diff --git a/Source/DotNET/Engine/Packages/PackageSelection.cs b/Source/DotNET/Engine/Packages/PackageSelection.cs new file mode 100644 index 0000000..1575588 --- /dev/null +++ b/Source/DotNET/Engine/Packages/PackageSelection.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.Engine.Packages; + +/// +/// The outcome of expanding a chosen set of package names into the full set a +/// actually needs. +/// +/// +/// Every package the selection requires, in ascending override-priority order: a package always appears +/// after the packages it depends on, so a package that layers on top of another shadows it on a name +/// collision. That is the order a list should carry. +/// +/// +/// The packages pulled in transitively that the caller did not choose - what a package picker tells the +/// user it is about to add on their behalf. +/// +/// Dependencies nothing in the catalog can satisfy. +/// Dependencies satisfied by name but not by version. +/// +/// Dependency cycles found while ordering, each listed as the packages involved. A cycle has no valid +/// priority order, so its members are emitted in catalog order and reported here rather than silently +/// arranged into one. +/// +public record PackageSelection( + IReadOnlyList Packages, + IReadOnlyList Added, + IReadOnlyList Missing, + IReadOnlyList VersionConflicts, + IReadOnlyList> Cycles) +{ + /// + /// Whether the selection is complete and orderable - nothing missing, no version conflict, no cycle. + /// + public bool IsValid => Missing.Count == 0 && VersionConflicts.Count == 0 && Cycles.Count == 0; +} diff --git a/Source/DotNET/Engine/Packages/PackageVersionConflict.cs b/Source/DotNET/Engine/Packages/PackageVersionConflict.cs new file mode 100644 index 0000000..d63d378 --- /dev/null +++ b/Source/DotNET/Engine/Packages/PackageVersionConflict.cs @@ -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. + +namespace Cratis.Scene.Engine.Packages; + +/// +/// A dependency that resolved to a package the catalog does contain, but at a version its declared +/// range does not accept. +/// +/// The package that declares the dependency. +/// The name of the package it depends on. +/// The range asked for. +/// The version the catalog actually offers. +public record PackageVersionConflict(string Package, string DependsOn, string RequiredRange, string ActualVersion); diff --git a/Source/DotNET/Engine/Packages/PackageVersionRange.cs b/Source/DotNET/Engine/Packages/PackageVersionRange.cs new file mode 100644 index 0000000..5ff4dde --- /dev/null +++ b/Source/DotNET/Engine/Packages/PackageVersionRange.cs @@ -0,0 +1,124 @@ +// 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.Engine.Packages; + +/// +/// Checks a against a package's actual +/// version. A deliberately small subset of semver - enough to express what a package declaration +/// realistically needs, and small enough that the TypeScript twin in @cratis/scene.engine can +/// match it exactly rather than approximately. Both sides are asserted against the same shared fixture +/// corpus. +/// +/// +/// Supported: an empty range or * (anything), a bare or =-prefixed exact version, +/// ^ (npm caret - compatible within the leftmost non-zero component), ~ (patch-level +/// changes), and the comparisons >=, >, <=, <. Versions are read +/// as major.minor.patch with any pre-release or build metadata suffix ignored - a package +/// declaring a pre-release version is compared on its numeric components alone. Anything the parser does +/// not recognize is treated as unsatisfiable rather than as "anything", so a typo surfaces as a conflict +/// instead of silently passing. +/// +public static class PackageVersionRange +{ + /// + /// Whether a version satisfies a range. + /// + /// The version to check, as a package's carries it. + /// The range to check against, or for "any version". + /// when satisfies . + public static bool IsSatisfiedBy(string version, string? range) + { + var trimmed = range?.Trim(); + if (string.IsNullOrEmpty(trimmed) || trimmed == "*") + { + return true; + } + + if (!TryParse(version, out var actual)) + { + return false; + } + + var (op, literal) = Split(trimmed); + if (!TryParse(literal, out var required)) + { + return false; + } + + return op switch + { + "^" => Compare(actual, required) >= 0 && Compare(actual, CaretUpperBound(required)) < 0, + "~" => Compare(actual, required) >= 0 && Compare(actual, (required.Major, required.Minor + 1, 0)) < 0, + ">=" => Compare(actual, required) >= 0, + ">" => Compare(actual, required) > 0, + "<=" => Compare(actual, required) <= 0, + "<" => Compare(actual, required) < 0, + _ => Compare(actual, required) == 0 + }; + } + + static (string Operator, string Literal) Split(string range) + { + foreach (var op in (string[])[">=", "<=", "^", "~", ">", "<", "="]) + { + if (range.StartsWith(op, StringComparison.Ordinal)) + { + return (op == "=" ? string.Empty : op, range[op.Length..].Trim()); + } + } + + return (string.Empty, range); + } + + /// + /// Applies npm's caret rule: compatibility is bounded by the leftmost non-zero component, so + /// ^1.2.3 allows anything below 2.0.0, ^0.2.3 anything below 0.3.0, and + /// ^0.0.3 only 0.0.3 itself. + /// + /// The version the caret was written against. + /// The exclusive upper bound the caret allows. + static (int Major, int Minor, int Patch) CaretUpperBound((int Major, int Minor, int Patch) version) => + version switch + { + { Major: > 0 } => (version.Major + 1, 0, 0), + { Minor: > 0 } => (0, version.Minor + 1, 0), + _ => (0, 0, version.Patch + 1) + }; + + static int Compare((int Major, int Minor, int Patch) left, (int Major, int Minor, int Patch) right) + { + if (left.Major != right.Major) return left.Major.CompareTo(right.Major); + if (left.Minor != right.Minor) return left.Minor.CompareTo(right.Minor); + return left.Patch.CompareTo(right.Patch); + } + + static bool TryParse(string value, out (int Major, int Minor, int Patch) version) + { + version = default; + var numeric = value.Trim(); + var suffix = numeric.IndexOfAny(['-', '+']); + if (suffix >= 0) + { + numeric = numeric[..suffix]; + } + + var parts = numeric.Split('.'); + if (parts.Length is 0 or > 3) + { + return false; + } + + var components = new int[3]; + for (var index = 0; index < parts.Length; index++) + { + if (!int.TryParse(parts[index], out components[index]) || components[index] < 0) + { + return false; + } + } + + version = (components[0], components[1], components[2]); + return true; + } +} diff --git a/Source/DotNET/Model/Packages/PackageDependency.cs b/Source/DotNET/Model/Packages/PackageDependency.cs new file mode 100644 index 0000000..4140c6b --- /dev/null +++ b/Source/DotNET/Model/Packages/PackageDependency.cs @@ -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. + +namespace Cratis.Scene.Model.Packages; + +/// +/// One package a needs present in the same to +/// work. @cratis/components is written against PrimeReact and Tailwind, so it declares both - a +/// profile listing it without them is stating something that cannot render, and that has to be visible +/// rather than discovered at runtime. +/// +/// The name of the package depended on, matching that package's . +/// +/// An optional semver range the dependency must satisfy. Left when any version +/// will do - which is the common case, since a profile only ever activates one version of a package. +/// +public record PackageDependency(string Name, string? VersionRange = null); diff --git a/Source/DotNET/Model/Packages/PackageKind.cs b/Source/DotNET/Model/Packages/PackageKind.cs new file mode 100644 index 0000000..b981d7d --- /dev/null +++ b/Source/DotNET/Model/Packages/PackageKind.cs @@ -0,0 +1,32 @@ +// 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.Packages; + +/// +/// What a contributes to a . A profile's package +/// list mixes all three kinds freely - the kind says what a package is for, never where it sits in the +/// override-priority order. +/// +public enum PackageKind +{ + /// + /// Declares component names a screen can resolve against. A package with no dependency on another + /// is a base library (PrimeReact, core); one that depends on + /// another layers on top of it. + /// + ComponentLibrary = 0, + + /// + /// Contributes styling rather than components - a utility CSS system such as Tailwind. A styling + /// package usually declares no components at all; component libraries depend on it to say "my + /// components are written against this styling system". + /// + Styling = 1, + + /// + /// Provides ready-made s and the shell components that fill their slots. + /// A layout package depends on the component libraries its shell is built from. + /// + Layout = 2 +} diff --git a/Source/DotNET/Model/Packages/ScenePackage.cs b/Source/DotNET/Model/Packages/ScenePackage.cs new file mode 100644 index 0000000..33208d4 --- /dev/null +++ b/Source/DotNET/Model/Packages/ScenePackage.cs @@ -0,0 +1,34 @@ +// 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.Packages; + +/// +/// What a 's entry actually names. +/// A profile lists packages by name only; this is the declaration behind the name - what it contributes, +/// and what else has to be active for it to work. +/// +/// The name a lists, and the name a refers to. +/// The package's own version, so a has something to check against. +/// What the package contributes. +/// Other packages that must be active in the same profile for this one to work. +/// The component names this package declares - the catalog entry resolution walks. +/// The names of the s this package provides, empty for a package that provides none. +/// The names of the s this package ships, empty for a package that ships none. +/// A human-readable name for a package picker, falling back to when absent. +/// A one-line description for a package picker. +/// +/// The module that implements the package - an npm package name for a web renderer. Design-time tooling +/// needs it to know what to import; the model itself never loads anything. +/// +public record ScenePackage( + string Name, + string Version, + PackageKind Kind, + IReadOnlyList Dependencies, + IReadOnlyList Components, + IReadOnlyList Layouts, + IReadOnlyList Themes, + string? DisplayName = null, + string? Description = null, + string? Module = null); diff --git a/Source/DotNET/Model/Profiles/Theme.cs b/Source/DotNET/Model/Profiles/Theme.cs index ac5422b..3dcc76f 100644 --- a/Source/DotNET/Model/Profiles/Theme.cs +++ b/Source/DotNET/Model/Profiles/Theme.cs @@ -5,11 +5,31 @@ namespace Cratis.Scene.Model.Profiles; /// /// A named token/styling layer, declaring which component packages it is known to work with. An -/// incompatible theme/package pairing is a warning, not an error — the theme might still work by -/// coincidence, but the gap must be visible. The token model's own shape (colors, spacing, typography) -/// is intentionally out of scope here — is what the engine and Stage's build -/// need to validate compatibility; applying tokens is a renderer concern. +/// incompatible theme/package pairing is a warning, not an error - the theme might still work by +/// coincidence, but the gap must be visible. /// /// The theme's name. /// The component packages this theme is declared compatible with. -public record Theme(string Name, IReadOnlyList CompatibleWith); +/// +/// The theme's design tokens, keyed by semantic name (primary.color, surface.background, +/// content.borderColor, ...). Deliberately semantic rather than CSS: a renderer decides how a token +/// becomes a custom property, a native style, or anything else. Empty for a theme a package applies by +/// its own means rather than through tokens. +/// +/// Whether the theme is a dark scheme, so a picker can group and preview it correctly. +/// +/// Who created the theme. A theme adopted from somewhere else - PrimeTek's free presets, a community +/// theme - must credit its original creator here rather than appear to be ours. +/// +/// A link to the original creator or the theme's home, shown alongside . +/// The license the theme is used under, so redistributing it stays honest. +/// A one-line description for a theme picker. +public record Theme( + string Name, + IReadOnlyList CompatibleWith, + IReadOnlyDictionary? Tokens = null, + bool IsDark = false, + string? Author = null, + string? AuthorUrl = null, + string? License = null, + string? Description = null); diff --git a/Source/JavaScript/engine/PackageSelection.ts b/Source/JavaScript/engine/PackageSelection.ts new file mode 100644 index 0000000..24109cc --- /dev/null +++ b/Source/JavaScript/engine/PackageSelection.ts @@ -0,0 +1,87 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * A dependency a selected package declares that nothing in the catalog can satisfy — so it cannot be + * pulled in automatically, and the selection is genuinely incomplete rather than merely under-specified. + */ +export interface MissingPackageDependency { + /** + * The package that declares the dependency. + */ + package: string; + + /** + * The name of the package it depends on, which the catalog does not contain. + */ + dependsOn: string; +} + +/** + * A dependency that resolved to a package the catalog does contain, but at a version its declared range + * does not accept. + */ +export interface PackageVersionConflict { + /** + * The package that declares the dependency. + */ + package: string; + + /** + * The name of the package it depends on. + */ + dependsOn: string; + + /** + * The range the declaring package asked for. + */ + requiredRange: string; + + /** + * The version the catalog actually offers. + */ + actualVersion: string; +} + +/** + * The outcome of expanding a chosen set of package names into the full set a {@link UiProfile} actually + * needs. + */ +export interface PackageSelection { + /** + * Every package the selection requires, in ascending override-priority order: a package always + * appears after the packages it depends on, so a package that layers on top of another shadows it on + * a name collision. That is the order a profile's `packages` list should carry. + */ + packages: string[]; + + /** + * The packages pulled in transitively that the caller did not choose — what a package picker tells + * the user it is about to add on their behalf. + */ + added: string[]; + + /** + * Dependencies nothing in the catalog can satisfy. + */ + missing: MissingPackageDependency[]; + + /** + * Dependencies satisfied by name but not by version. + */ + versionConflicts: PackageVersionConflict[]; + + /** + * Dependency cycles found while ordering, each listed as the packages involved. A cycle has no valid + * priority order, so its members are emitted in discovery order and reported here rather than + * silently arranged into one. + */ + cycles: string[][]; +} + +/** + * Whether a selection is complete and orderable — nothing missing, no version conflict, no cycle. + */ +export function isPackageSelectionValid(selection: PackageSelection): boolean { + return selection.missing.length === 0 && selection.versionConflicts.length === 0 && selection.cycles.length === 0; +} diff --git a/Source/JavaScript/engine/for_packageCatalog/when_querying_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_packageCatalog/when_querying_against_the_shared_fixture_corpus.ts new file mode 100644 index 0000000..a288c22 --- /dev/null +++ b/Source/JavaScript/engine/for_packageCatalog/when_querying_against_the_shared_fixture_corpus.ts @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { availablePackagesFor, baseComponentLibraries, componentsForPackages } from '../index'; +import { CatalogFixtureCase, corpus } from '../for_packageDependencies/fixtures'; + +function run(fixtureCase: CatalogFixtureCase): string[] { + switch (fixtureCase.query) { + case 'baseComponentLibraries': + return baseComponentLibraries(corpus.catalog).map((scenePackage) => scenePackage.name); + case 'availableFor': + return availablePackagesFor(corpus.catalog, fixtureCase.selected ?? []).map((scenePackage) => scenePackage.name); + case 'componentsFor': + return componentsForPackages(corpus.catalog, fixtureCase.selected ?? []); + default: + throw new Error(`The fixture corpus asks for an unknown query '${fixtureCase.query}'`); + } +} + +describe('when querying against the shared fixture corpus', () => { + for (const fixtureCase of corpus.catalogCases) { + it(`should match the expected result for "${fixtureCase.name}"`, () => { + run(fixtureCase).should.deep.equal(fixtureCase.expected); + }); + } +}); diff --git a/Source/JavaScript/engine/for_packageDependencies/fixtures.ts b/Source/JavaScript/engine/for_packageDependencies/fixtures.ts new file mode 100644 index 0000000..7a71d38 --- /dev/null +++ b/Source/JavaScript/engine/for_packageDependencies/fixtures.ts @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { ScenePackage } from '@cratis/scene.model'; + +export interface ResolutionFixtureCase { + name: string; + selected: string[]; + expectedPackages: string[]; + expectedAdded: string[]; + expectedMissing: { package: string; dependsOn: string }[]; + expectedVersionConflicts: { package: string; dependsOn: string; requiredRange: string; actualVersion: string }[]; + expectedCycles: string[][]; +} + +export interface VersionRangeFixtureCase { + version: string; + range: string | null; + expected: boolean; +} + +export interface CatalogFixtureCase { + name: string; + query: string; + selected?: string[]; + expected: string[]; +} + +interface FixtureCorpus { + catalog: ScenePackage[]; + resolutionCases: ResolutionFixtureCase[]; + versionRangeCases: VersionRangeFixtureCase[]; + catalogCases: CatalogFixtureCase[]; +} + +const fixturePath = join(import.meta.dirname, '..', '..', '..', '..', 'package-dependency-fixtures.json'); + +/** + * The same corpus the C# specs read, so both languages resolve against an identical catalog rather than + * two hand-written ones that can quietly diverge. + */ +export const corpus = JSON.parse(readFileSync(fixturePath, 'utf-8')) as FixtureCorpus; diff --git a/Source/JavaScript/engine/for_packageDependencies/when_resolving_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_packageDependencies/when_resolving_against_the_shared_fixture_corpus.ts new file mode 100644 index 0000000..aa3ab6c --- /dev/null +++ b/Source/JavaScript/engine/for_packageDependencies/when_resolving_against_the_shared_fixture_corpus.ts @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { isPackageSelectionValid, resolvePackageDependencies } from '../index'; +import { corpus } from './fixtures'; + +describe('when resolving against the shared fixture corpus', () => { + for (const fixtureCase of corpus.resolutionCases) { + const selection = resolvePackageDependencies(fixtureCase.selected, corpus.catalog); + + it(`should order the packages as expected for "${fixtureCase.name}"`, () => { + selection.packages.should.deep.equal(fixtureCase.expectedPackages); + }); + + it(`should report the expected transitively added packages for "${fixtureCase.name}"`, () => { + selection.added.should.deep.equal(fixtureCase.expectedAdded); + }); + + it(`should report the expected missing dependencies for "${fixtureCase.name}"`, () => { + selection.missing.should.deep.equal(fixtureCase.expectedMissing); + }); + + it(`should report the expected version conflicts for "${fixtureCase.name}"`, () => { + selection.versionConflicts.should.deep.equal(fixtureCase.expectedVersionConflicts); + }); + + it(`should report the expected cycles for "${fixtureCase.name}"`, () => { + selection.cycles.should.deep.equal(fixtureCase.expectedCycles); + }); + + it(`should consider "${fixtureCase.name}" valid only when nothing is wrong`, () => { + const expectedValid = + fixtureCase.expectedMissing.length === 0 && + fixtureCase.expectedVersionConflicts.length === 0 && + fixtureCase.expectedCycles.length === 0; + isPackageSelectionValid(selection).should.equal(expectedValid); + }); + } +}); diff --git a/Source/JavaScript/engine/for_packageVersionRange/when_checking_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_packageVersionRange/when_checking_against_the_shared_fixture_corpus.ts new file mode 100644 index 0000000..3526ff8 --- /dev/null +++ b/Source/JavaScript/engine/for_packageVersionRange/when_checking_against_the_shared_fixture_corpus.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { isVersionSatisfiedBy } from '../index'; +import { corpus } from '../for_packageDependencies/fixtures'; + +describe('when checking against the shared fixture corpus', () => { + for (const fixtureCase of corpus.versionRangeCases) { + it(`should decide ${fixtureCase.version} against '${fixtureCase.range ?? ''}' the same way the corpus does`, () => { + isVersionSatisfiedBy(fixtureCase.version, fixtureCase.range ?? undefined).should.equal(fixtureCase.expected); + }); + } +}); diff --git a/Source/JavaScript/engine/index.ts b/Source/JavaScript/engine/index.ts index 11e19be..63b9b2c 100644 --- a/Source/JavaScript/engine/index.ts +++ b/Source/JavaScript/engine/index.ts @@ -13,5 +13,9 @@ export * from './evaluateFreeformArrangement'; export * from './evaluateFreeformSlotArrangement'; export * from './aggregateContributions'; export * from './themeCompatibility'; +export * from './PackageSelection'; +export * from './packageVersionRange'; +export * from './resolvePackageDependencies'; +export * from './packageCatalog'; export * from './buildStarterProfile'; export * from './incompatibleStarterThemes'; diff --git a/Source/JavaScript/engine/packageCatalog.ts b/Source/JavaScript/engine/packageCatalog.ts new file mode 100644 index 0000000..746500d --- /dev/null +++ b/Source/JavaScript/engine/packageCatalog.ts @@ -0,0 +1,65 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PackageKind, ScenePackage } from '@cratis/scene.model'; + +/** + * The packages of a given kind. + */ +export function packagesOfKind(catalog: ScenePackage[], kind: PackageKind): ScenePackage[] { + return catalog.filter((scenePackage) => scenePackage.kind === kind); +} + +/** + * The component libraries a profile can be founded on — the ones that do not themselves layer on another + * component library. + * + * "Base" is not a declared property; it falls out of the dependency graph. PrimeReact depends on a + * styling package but on no other component library, so it is a base. `@cratis/components` depends on + * PrimeReact, so it is not — it is something you add on top of a base you already picked. Deriving it + * this way means a third party shipping their own library gets classified correctly without having to + * declare anything extra. + */ +export function baseComponentLibraries(catalog: ScenePackage[]): ScenePackage[] { + const libraries = packagesOfKind(catalog, PackageKind.ComponentLibrary); + const names = new Set(libraries.map((scenePackage) => scenePackage.name)); + return libraries.filter((scenePackage) => !scenePackage.dependencies.some((dependency) => names.has(dependency.name))); +} + +/** + * The packages that can be added to a selection without pulling anything else in — every dependency they + * declare is already selected. + * + * This is the "what else works with what I have picked" list: choose PrimeReact and Tailwind, and + * `@cratis/components` becomes available because both of its dependencies are now met. It is + * deliberately stricter than {@link resolvePackageDependencies}, which will happily add the missing + * dependencies for you — a picker wants to show what fits, not what would drag more in. + */ +export function availablePackagesFor(catalog: ScenePackage[], selected: string[]): ScenePackage[] { + const chosen = new Set(selected); + return catalog.filter( + (scenePackage) => !chosen.has(scenePackage.name) && scenePackage.dependencies.every((dependency) => chosen.has(dependency.name)) + ); +} + +/** + * Every component name the selected packages declare between them, without duplicates, sorted so a + * component picker has a stable list to show. + */ +export function componentsForPackages(catalog: ScenePackage[], selected: string[]): string[] { + const chosen = new Set(selected); + const components = catalog.filter((scenePackage) => chosen.has(scenePackage.name)).flatMap((scenePackage) => scenePackage.components); + return [...new Set(components)].sort(); +} + +/** + * Builds the component-name catalog `resolveComponentName` resolves against. + */ +export function toComponentCatalog(catalog: ScenePackage[]): Record { + const components: Record = {}; + for (const scenePackage of catalog) { + components[scenePackage.name] = scenePackage.components; + } + + return components; +} diff --git a/Source/JavaScript/engine/packageVersionRange.ts b/Source/JavaScript/engine/packageVersionRange.ts new file mode 100644 index 0000000..0f3bf85 --- /dev/null +++ b/Source/JavaScript/engine/packageVersionRange.ts @@ -0,0 +1,90 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Checks a {@link PackageDependency.versionRange} against a package's actual version. A deliberately + * small subset of semver — enough to express what a package declaration realistically needs, and small + * enough that the C# twin in `Cratis.Scene.Engine` can match it exactly rather than approximately. Both + * sides are asserted against the same shared fixture corpus. + * + * Supported: an empty range or `*` (anything), a bare or `=`-prefixed exact version, `^` (npm caret — + * compatible within the leftmost non-zero component), `~` (patch-level changes), and the comparisons + * `>=`, `>`, `<=`, `<`. Versions are read as `major.minor.patch` with any pre-release or build metadata + * suffix ignored. Anything the parser does not recognize is treated as unsatisfiable rather than as + * "anything", so a typo surfaces as a conflict instead of silently passing. + */ +export function isVersionSatisfiedBy(version: string, range: string | undefined): boolean { + const trimmed = range?.trim(); + if (!trimmed || trimmed === '*') return true; + + const actual = parseVersion(version); + if (!actual) return false; + + const [operator, literal] = splitRange(trimmed); + const required = parseVersion(literal); + if (!required) return false; + + switch (operator) { + case '^': + return compare(actual, required) >= 0 && compare(actual, caretUpperBound(required)) < 0; + case '~': + return compare(actual, required) >= 0 && compare(actual, [required[0], required[1] + 1, 0]) < 0; + case '>=': + return compare(actual, required) >= 0; + case '>': + return compare(actual, required) > 0; + case '<=': + return compare(actual, required) <= 0; + case '<': + return compare(actual, required) < 0; + default: + return compare(actual, required) === 0; + } +} + +type Version = [number, number, number]; + +function splitRange(range: string): [string, string] { + for (const operator of ['>=', '<=', '^', '~', '>', '<', '=']) { + if (range.startsWith(operator)) { + return [operator === '=' ? '' : operator, range.substring(operator.length).trim()]; + } + } + + return ['', range]; +} + +/** + * npm's caret rule: compatibility is bounded by the leftmost non-zero component, so `^1.2.3` allows + * anything below `2.0.0`, `^0.2.3` anything below `0.3.0`, and `^0.0.3` only `0.0.3` itself. + */ +function caretUpperBound(version: Version): Version { + if (version[0] > 0) return [version[0] + 1, 0, 0]; + if (version[1] > 0) return [0, version[1] + 1, 0]; + return [0, 0, version[2] + 1]; +} + +function compare(left: Version, right: Version): number { + if (left[0] !== right[0]) return left[0] - right[0]; + if (left[1] !== right[1]) return left[1] - right[1]; + return left[2] - right[2]; +} + +function parseVersion(value: string): Version | undefined { + let numeric = value.trim(); + const suffix = numeric.search(/[-+]/); + if (suffix >= 0) { + numeric = numeric.substring(0, suffix); + } + + const parts = numeric.split('.'); + if (parts.length === 0 || parts.length > 3) return undefined; + + const components: Version = [0, 0, 0]; + for (let index = 0; index < parts.length; index++) { + if (!/^\d+$/.test(parts[index])) return undefined; + components[index] = parseInt(parts[index], 10); + } + + return components; +} diff --git a/Source/JavaScript/engine/resolvePackageDependencies.ts b/Source/JavaScript/engine/resolvePackageDependencies.ts new file mode 100644 index 0000000..bdd6ba4 --- /dev/null +++ b/Source/JavaScript/engine/resolvePackageDependencies.ts @@ -0,0 +1,156 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ScenePackage } from '@cratis/scene.model'; +import { MissingPackageDependency, PackageSelection, PackageVersionConflict } from './PackageSelection'; +import { isVersionSatisfiedBy } from './packageVersionRange'; + +/** + * Expands a chosen set of package names into the complete, correctly ordered list a {@link UiProfile} + * needs — the runtime half. The C# twin in `Cratis.Scene.Engine` implements the same algorithm for + * Studio's package picker and Stage's build; both are asserted against the same shared fixture corpus so + * they cannot drift apart. + * + * This is a different concern from `resolveComponentName`, which resolves one component *name* against + * an already-decided package list. This decides what that list is. + * + * Ordering is a stable topological sort: among packages whose dependencies are equally satisfied, the one + * the caller named first comes first, and packages pulled in transitively follow in the order they were + * discovered. When a cycle makes ordering impossible, the packages involved are reported in `cycles` and + * appended in discovery order rather than dropped. + */ +export function resolvePackageDependencies(selected: string[], catalog: ScenePackage[]): PackageSelection { + const index = new Map(catalog.map((scenePackage) => [scenePackage.name, scenePackage])); + const discovered: string[] = []; + const seen = new Set(selected); + const missing: MissingPackageDependency[] = []; + const versionConflicts: PackageVersionConflict[] = []; + + const pending = [...selected]; + while (pending.length > 0) { + const name = pending.shift()!; + discovered.push(name); + + const scenePackage = index.get(name); + if (!scenePackage) continue; + + for (const dependency of scenePackage.dependencies) { + const dependedOn = index.get(dependency.name); + if (!dependedOn) { + missing.push({ package: scenePackage.name, dependsOn: dependency.name }); + continue; + } + + if (!isVersionSatisfiedBy(dependedOn.version, dependency.versionRange)) { + versionConflicts.push({ + package: scenePackage.name, + dependsOn: dependency.name, + requiredRange: dependency.versionRange!, + actualVersion: dependedOn.version, + }); + } + + if (!seen.has(dependency.name)) { + seen.add(dependency.name); + pending.push(dependency.name); + } + } + } + + const cycles = findCycles(discovered, index); + const packages = topologicallyOrder(discovered, index); + const chosen = new Set(selected); + + return { + packages, + added: packages.filter((name) => !chosen.has(name)), + missing, + versionConflicts, + cycles, + }; +} + +function dependenciesWithin(name: string, index: Map, within: Set): string[] { + const scenePackage = index.get(name); + if (!scenePackage) return []; + const names = scenePackage.dependencies.map((dependency) => dependency.name).filter((dependency) => within.has(dependency)); + return [...new Set(names)]; +} + +/** + * Orders an already-closed set of package names so each follows the packages it depends on. + */ +function topologicallyOrder(names: string[], index: Map): string[] { + const within = new Set(names); + const remaining = new Map(names.map((name) => [name, dependenciesWithin(name, index, within).length])); + + const ordered: string[] = []; + const placed = new Set(); + let progressed = true; + while (progressed) { + progressed = false; + for (const name of names) { + if (placed.has(name) || remaining.get(name) !== 0) continue; + + ordered.push(name); + placed.add(name); + progressed = true; + + for (const other of names) { + if (placed.has(other)) continue; + if (dependenciesWithin(other, index, within).includes(name)) { + remaining.set(other, remaining.get(other)! - 1); + } + } + } + } + + // Anything left is inside a cycle, or downstream of one. It is reported through `cycles`; keeping it + // in the list means a package picker never silently loses a package it was told about. + ordered.push(...names.filter((name) => !placed.has(name))); + return ordered; +} + +/** + * Finds every dependency cycle, walking depth-first from each package in discovery order so the result + * is deterministic. A cycle is recorded once, keyed by the set of packages in it. + */ +function findCycles(names: string[], index: Map): string[][] { + const within = new Set(names); + const cycles: string[][] = []; + const recorded = new Set(); + const path: string[] = []; + const onPath = new Set(); + const explored = new Set(); + + const walk = (name: string) => { + if (onPath.has(name)) { + const cycle = path.slice(path.indexOf(name)); + const key = [...cycle].sort().join(' '); + if (!recorded.has(key)) { + recorded.add(key); + cycles.push(cycle); + } + + return; + } + + if (explored.has(name)) return; + explored.add(name); + + path.push(name); + onPath.add(name); + for (const dependency of dependenciesWithin(name, index, within)) { + walk(dependency); + } + + onPath.delete(name); + path.pop(); + }; + + for (const name of names) { + walk(name); + } + + return cycles; +} diff --git a/Source/JavaScript/model/index.ts b/Source/JavaScript/model/index.ts index 82f1a95..c12c99a 100644 --- a/Source/JavaScript/model/index.ts +++ b/Source/JavaScript/model/index.ts @@ -8,5 +8,6 @@ export * from './layouts'; export * from './forms'; export * from './contributionPoints'; export * from './profiles'; +export * from './packages'; export * from './screens'; export * from './starters'; diff --git a/Source/JavaScript/model/profiles/Theme.ts b/Source/JavaScript/model/profiles/Theme.ts index 87624e5..e4807ed 100644 --- a/Source/JavaScript/model/profiles/Theme.ts +++ b/Source/JavaScript/model/profiles/Theme.ts @@ -4,13 +4,61 @@ /** * A named token/styling layer, declaring which component packages it is known to work with. An * incompatible theme/package pairing is a warning, not an error — the theme might still work by - * coincidence, but the gap must be visible. The token model's own shape (colors, spacing, typography) - * is intentionally out of scope here — `compatibleWith` is what the engine and Stage's build need to - * validate compatibility; applying tokens is a renderer concern. + * coincidence, but the gap must be visible. */ export interface Theme { + /** + * The theme's name. + */ name: string; + + /** + * The component packages this theme is declared compatible with. + */ compatibleWith: string[]; + + /** + * The theme's design tokens, keyed by semantic name (`primary.color`, `surface.background`, + * `content.borderColor`, ...). Deliberately semantic rather than CSS: a renderer decides how a token + * becomes a custom property, a native style, or anything else. Absent for a theme a package applies + * by its own means rather than through tokens. + */ + tokens?: Record; + + /** + * Whether the theme is a dark scheme, so a picker can group and preview it correctly. + */ + isDark?: boolean; + + /** + * Who created the theme. A theme adopted from somewhere else — PrimeTek's free presets, a community + * theme — must credit its original creator here rather than appear to be ours. + */ + author?: string; + + /** + * A link to the original creator or the theme's home, shown alongside `author`. + */ + authorUrl?: string; + + /** + * The license the theme is used under, so redistributing it stays honest. + */ + license?: string; + + /** + * A one-line description for a theme picker. + */ + description?: string; } -export const ThemePropertyNames: (keyof Theme)[] = ['name', 'compatibleWith']; +export const ThemePropertyNames: (keyof Theme)[] = [ + 'name', + 'compatibleWith', + 'tokens', + 'isDark', + 'author', + 'authorUrl', + 'license', + 'description', +]; diff --git a/package-dependency-fixtures.json b/package-dependency-fixtures.json new file mode 100644 index 0000000..7785089 --- /dev/null +++ b/package-dependency-fixtures.json @@ -0,0 +1,233 @@ +{ + "description": "Shared behavior corpus for Cratis.Scene.Engine.Packages (C#) and resolvePackageDependencies.ts / packageVersionRange.ts / packageCatalog.ts (TypeScript, @cratis/scene.engine) - both sides assert every case here independently, so the two implementations cannot drift apart. Same pattern as package-resolution-fixtures.json and theme-compatibility-fixtures.json.", + "catalog": [ + { + "name": "core", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": ["button", "text", "card"], + "layouts": [], + "themes": [] + }, + { + "name": "Tailwind", + "version": "4.3.3", + "kind": "Styling", + "dependencies": [], + "components": [], + "layouts": [], + "themes": [] + }, + { + "name": "PrimeReact", + "version": "10.9.8", + "kind": "ComponentLibrary", + "dependencies": [{ "name": "Tailwind" }], + "components": ["button", "table", "dialog"], + "layouts": [], + "themes": ["Lara Light Blue", "Lara Dark Blue"] + }, + { + "name": "Cratis.Components", + "version": "2.8.1", + "kind": "ComponentLibrary", + "dependencies": [ + { "name": "PrimeReact", "versionRange": ">=10.9.0" }, + { "name": "Tailwind", "versionRange": "^4.0.0" } + ], + "components": ["dataPage", "commandForm", "table"], + "layouts": [], + "themes": [] + }, + { + "name": "Cratis.Layout.Default", + "version": "1.0.0", + "kind": "Layout", + "dependencies": [{ "name": "PrimeReact" }, { "name": "Cratis.Components" }], + "components": ["appShell", "topbar", "sidebar"], + "layouts": ["AppShell", "FullPage"], + "themes": [] + }, + { + "name": "Broken.MissingDependency", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [{ "name": "NotInCatalog" }], + "components": ["widget"], + "layouts": [], + "themes": [] + }, + { + "name": "Broken.WrongVersion", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [{ "name": "PrimeReact", "versionRange": "^11.0.0" }], + "components": ["widget"], + "layouts": [], + "themes": [] + }, + { + "name": "Cycle.A", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [{ "name": "Cycle.B" }], + "components": [], + "layouts": [], + "themes": [] + }, + { + "name": "Cycle.B", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [{ "name": "Cycle.A" }], + "components": [], + "layouts": [], + "themes": [] + } + ], + "resolutionCases": [ + { + "name": "a package with no dependencies resolves to itself", + "selected": ["core"], + "expectedPackages": ["core"], + "expectedAdded": [], + "expectedMissing": [], + "expectedVersionConflicts": [], + "expectedCycles": [] + }, + { + "name": "a dependency is pulled in automatically and ordered before the package that needs it", + "selected": ["PrimeReact"], + "expectedPackages": ["Tailwind", "PrimeReact"], + "expectedAdded": ["Tailwind"], + "expectedMissing": [], + "expectedVersionConflicts": [], + "expectedCycles": [] + }, + { + "name": "a transitive chain is fully expanded, dependencies first", + "selected": ["Cratis.Components"], + "expectedPackages": ["Tailwind", "PrimeReact", "Cratis.Components"], + "expectedAdded": ["Tailwind", "PrimeReact"], + "expectedMissing": [], + "expectedVersionConflicts": [], + "expectedCycles": [] + }, + { + "name": "a layout package pulls in both component libraries it is built from", + "selected": ["Cratis.Layout.Default"], + "expectedPackages": ["Tailwind", "PrimeReact", "Cratis.Components", "Cratis.Layout.Default"], + "expectedAdded": ["Tailwind", "PrimeReact", "Cratis.Components"], + "expectedMissing": [], + "expectedVersionConflicts": [], + "expectedCycles": [] + }, + { + "name": "an already-complete selection keeps the caller's order where dependencies allow it", + "selected": ["core", "Tailwind", "PrimeReact", "Cratis.Components"], + "expectedPackages": ["core", "Tailwind", "PrimeReact", "Cratis.Components"], + "expectedAdded": [], + "expectedMissing": [], + "expectedVersionConflicts": [], + "expectedCycles": [] + }, + { + "name": "a selection listing a dependent before its dependency is reordered so the dependency comes first", + "selected": ["Cratis.Components", "Tailwind", "PrimeReact"], + "expectedPackages": ["Tailwind", "PrimeReact", "Cratis.Components"], + "expectedAdded": [], + "expectedMissing": [], + "expectedVersionConflicts": [], + "expectedCycles": [] + }, + { + "name": "a dependency the catalog does not contain is reported as missing rather than silently dropped", + "selected": ["Broken.MissingDependency"], + "expectedPackages": ["Broken.MissingDependency"], + "expectedAdded": [], + "expectedMissing": [{ "package": "Broken.MissingDependency", "dependsOn": "NotInCatalog" }], + "expectedVersionConflicts": [], + "expectedCycles": [] + }, + { + "name": "a dependency satisfied by name but not by version is reported as a conflict, and still ordered", + "selected": ["Broken.WrongVersion"], + "expectedPackages": ["Tailwind", "PrimeReact", "Broken.WrongVersion"], + "expectedAdded": ["Tailwind", "PrimeReact"], + "expectedMissing": [], + "expectedVersionConflicts": [ + { "package": "Broken.WrongVersion", "dependsOn": "PrimeReact", "requiredRange": "^11.0.0", "actualVersion": "10.9.8" } + ], + "expectedCycles": [] + }, + { + "name": "a cycle is reported and its packages are kept rather than dropped", + "selected": ["Cycle.A"], + "expectedPackages": ["Cycle.A", "Cycle.B"], + "expectedAdded": ["Cycle.B"], + "expectedMissing": [], + "expectedVersionConflicts": [], + "expectedCycles": [["Cycle.A", "Cycle.B"]] + } + ], + "versionRangeCases": [ + { "version": "10.9.8", "range": null, "expected": true }, + { "version": "10.9.8", "range": "", "expected": true }, + { "version": "10.9.8", "range": "*", "expected": true }, + { "version": "10.9.8", "range": "10.9.8", "expected": true }, + { "version": "10.9.8", "range": "=10.9.8", "expected": true }, + { "version": "10.9.8", "range": "10.9.7", "expected": false }, + { "version": "10.9.8", "range": "^10.9.0", "expected": true }, + { "version": "10.9.8", "range": "^10.0.0", "expected": true }, + { "version": "11.1.0", "range": "^10.0.0", "expected": false }, + { "version": "10.8.0", "range": "^10.9.0", "expected": false }, + { "version": "0.2.5", "range": "^0.2.0", "expected": true }, + { "version": "0.3.0", "range": "^0.2.0", "expected": false }, + { "version": "0.0.3", "range": "^0.0.3", "expected": true }, + { "version": "0.0.4", "range": "^0.0.3", "expected": false }, + { "version": "10.9.8", "range": "~10.9.0", "expected": true }, + { "version": "10.10.0", "range": "~10.9.0", "expected": false }, + { "version": "10.9.8", "range": ">=10.9.0", "expected": true }, + { "version": "10.9.8", "range": ">=11.0.0", "expected": false }, + { "version": "10.9.8", "range": ">10.9.8", "expected": false }, + { "version": "10.9.9", "range": ">10.9.8", "expected": true }, + { "version": "10.9.8", "range": "<=10.9.8", "expected": true }, + { "version": "10.9.8", "range": "<10.9.8", "expected": false }, + { "version": "2.8.1-beta.1", "range": "^2.8.0", "expected": true }, + { "version": "10.9", "range": "^10.9.0", "expected": true }, + { "version": "10.9.8", "range": "not-a-range", "expected": false }, + { "version": "not-a-version", "range": "^1.0.0", "expected": false } + ], + "catalogCases": [ + { + "name": "base component libraries are the ones not layered on another component library", + "query": "baseComponentLibraries", + "expected": ["core", "PrimeReact", "Broken.MissingDependency"] + }, + { + "name": "only the packages needing nothing beyond it are available when just a styling package is selected", + "query": "availableFor", + "selected": ["Tailwind"], + "expected": ["core", "PrimeReact"] + }, + { + "name": "a component package becomes available once both of its dependencies are selected", + "query": "availableFor", + "selected": ["Tailwind", "PrimeReact"], + "expected": ["core", "Cratis.Components", "Broken.WrongVersion"] + }, + { + "name": "a layout package becomes available once the libraries it is built from are selected", + "query": "availableFor", + "selected": ["Tailwind", "PrimeReact", "Cratis.Components"], + "expected": ["core", "Cratis.Layout.Default", "Broken.WrongVersion"] + }, + { + "name": "components are the union of the selected packages' declarations, sorted and deduplicated", + "query": "componentsFor", + "selected": ["core", "PrimeReact"], + "expected": ["button", "card", "dialog", "table", "text"] + } + ] +} diff --git a/scene-model-shape.json b/scene-model-shape.json index e69ebc5..010e196 100644 --- a/scene-model-shape.json +++ b/scene-model-shape.json @@ -41,11 +41,14 @@ "Contribution": ["contributionPointName", "content", "order"], "NavigationItem": ["label", "targetScreen", "routeParameterBindings", "order", "group"], "UiProfile": ["name", "targetPlatform", "packages", "defaultSizeClass"], - "Theme": ["name", "compatibleWith"], + "Theme": ["name", "compatibleWith", "tokens", "isDark", "author", "authorUrl", "license", "description"], + "PackageDependency": ["name", "versionRange"], + "ScenePackage": ["name", "version", "kind", "dependencies", "components", "layouts", "themes", "displayName", "description", "module"], "Screen": ["name", "layout", "slotContent", "forms", "contributions"], "UiStarter": ["name", "packages", "themes", "gallery"] }, "enums": { + "PackageKind": ["ComponentLibrary", "Styling", "Layout"], "WidthSizeClass": ["Compact", "Regular"], "HeightSizeClass": ["Compact", "Regular"], "HorizontalAlignment": ["Stretch", "Left", "Center", "Right"], From cede487d72258dd095cd1c3d96a190ce83426fb5 Mon Sep 17 00:00:00 2001 From: Einar Date: Sun, 16 Aug 2026 11:03:33 +0200 Subject: [PATCH 2/7] Distinguish layouts from screen and dialog templates A layout and a template were the same word for two different things. They are now separate, and the difference is the point: A `Layout` is an application's base navigational look - the shell. An application has one, and selects it. A `ScreenTemplate` is a reusable shape that goes inside that shell, at module, feature or slice level, and an application has many. A `DialogTemplate` is the same for content that opens over the application rather than sitting inside it. `ScreenTemplate.FitsSlot` is what makes them compose. A template states the name of the slot it fills, never which container owns it - so a feature's template says "I go in the module content area" rather than naming one module, and stays reusable. `ScreenTemplateResolver` turns those names into a tree by finding which layout or template declares each. The same rule applies at every level, so nesting has no depth limit and no second mechanism. What it cannot resolve, it reports: a slot nothing declares, a slot name two containers declare, and templates that nest inside themselves. Guessing a parent renders content in the wrong region, which is far harder to diagnose than being told the name is ambiguous. `PackageKind.Layout` becomes `Blueprint` - the package that ships a coherent set of layouts, templates and the components filling them, and which an application selects one of. `ScenePackage` gains `ScreenTemplates` and `DialogTemplates` beside `Layouts`, and `validatePackageBundle` checks all three. Co-Authored-By: Claude Opus 5 (1M context) --- Documentation/blueprints/index.md | 52 ++ Documentation/blueprints/layouts.md | 79 ++ Documentation/blueprints/screen-templates.md | 90 ++ Documentation/blueprints/toc.yml | 6 + Documentation/index.md | 51 ++ Documentation/toc.yml | 7 + .../PackageFixtures.cs | 2 + ...lving_against_the_shared_fixture_corpus.cs | 101 +++ .../Engine/Screens/ScreenTemplatePlacement.cs | 14 + .../Screens/ScreenTemplateResolution.cs | 28 + .../Engine/Screens/ScreenTemplateResolver.cs | 185 ++++ .../Engine/Screens/UnplacedScreenTemplate.cs | 16 + Source/DotNET/Model/Layouts/Layout.cs | 9 +- Source/DotNET/Model/Packages/PackageKind.cs | 13 +- Source/DotNET/Model/Packages/ScenePackage.cs | 6 +- Source/DotNET/Model/Screens/DialogTemplate.cs | 34 + Source/DotNET/Model/Screens/Screen.cs | 21 +- Source/DotNET/Model/Screens/ScreenTemplate.cs | 54 ++ .../components/.storybook/main.experiment.txt | 0 .../JavaScript/components/.storybook/main.ts | 34 + .../bindings/ArcRuntimeBoundary.tsx | 36 + .../components/bindings/BindingKind.ts | 18 + .../components/bindings/BoundConstructor.ts | 21 + .../components/bindings/ElementBinding.ts | 19 + .../components/bindings/MissingBinding.tsx | 34 + .../components/bindings/Placeholder.tsx | 42 + .../components/bindings/bindingRegistry.ts | 105 +++ .../when_a_command_is_registered.ts | 30 + .../when_a_query_is_not_registered.ts | 17 + .../when_a_query_is_registered.ts | 30 + .../when_bindings_are_cleared.ts | 29 + .../when_resolving_a_command_binding.ts | 34 + .../when_resolving_a_query_binding.ts | 45 + .../JavaScript/components/bindings/index.ts | 11 + .../bindings/resolveElementBinding.ts | 33 + .../components/common/SceneDropdown.tsx | 31 + .../components/common/SceneErrorBoundary.tsx | 17 + .../components/common/SceneIcon.tsx | 18 + .../components/common/SceneTooltip.tsx | 28 + Source/JavaScript/components/common/index.ts | 7 + .../JavaScript/components/cratisComponents.ts | 91 ++ .../components/cratisComponentsPackage.ts | 101 +++ .../components/data/SceneDataTable.tsx | 41 + .../data/SceneObservableDataTable.tsx | 41 + .../when_the_query_binding_is_missing.tsx | 47 + Source/JavaScript/components/data/index.ts | 5 + .../dialogs/SceneBusyIndicatorDialog.tsx | 29 + .../components/dialogs/SceneCommandDialog.tsx | 42 + .../dialogs/SceneConfirmationDialog.tsx | 23 + .../components/dialogs/SceneDialog.tsx | 41 + .../dialogs/SceneStepperCommandDialog.tsx | 50 ++ Source/JavaScript/components/dialogs/index.ts | 8 + .../components/editors/SceneFilterPanel.tsx | 52 ++ .../editors/SceneObjectContentEditor.tsx | 36 + .../editors/SceneObjectNavigationalBar.tsx | 30 + .../components/editors/SceneSchemaEditor.tsx | 34 + .../components/editors/SceneTimeMachine.tsx | 32 + .../components/editors/filterDefinitions.ts | 59 ++ .../when_reading_filter_definitions.ts | 49 ++ .../when_reading_versions.ts | 39 + Source/JavaScript/components/editors/index.ts | 11 + .../components/editors/timeMachineVersions.ts | 44 + .../components/editors/useEditableCopy.ts | 32 + ...ing_a_screen_through_the_real_renderer.tsx | 37 + ...anks_it_above_the_packages_it_layers_on.ts | 49 ++ .../when_resolving_its_dependencies.ts | 68 ++ .../when_validating_the_bundle.ts | 39 + .../when_a_property_has_the_wrong_type.ts | 41 + .../when_a_property_is_missing.ts | 26 + .../when_a_property_is_present.ts | 36 + .../components/forms/SceneCommandForm.tsx | 45 + .../forms/fields/CommandFormField.tsx | 32 + .../components/forms/fields/FieldBinding.ts | 26 + .../forms/fields/SceneCalendarField.tsx | 41 + .../forms/fields/SceneCheckboxField.tsx | 29 + .../forms/fields/SceneChipsField.tsx | 34 + .../forms/fields/SceneColorPickerField.tsx | 31 + .../forms/fields/SceneDropdownField.tsx | 36 + .../forms/fields/SceneInputTextField.tsx | 37 + .../forms/fields/SceneMultiSelectField.tsx | 40 + .../forms/fields/SceneNumberField.tsx | 33 + .../forms/fields/SceneRadioButtonField.tsx | 37 + .../forms/fields/SceneRadioGroupField.tsx | 36 + .../forms/fields/SceneSliderField.tsx | 32 + .../forms/fields/SceneTextAreaField.tsx | 32 + .../when_the_field_names_no_property.tsx | 15 + .../when_resolving_a_field_binding.ts | 34 + .../components/forms/fields/index.ts | 18 + .../forms/fields/resolveFieldBinding.ts | 27 + Source/JavaScript/components/forms/index.ts | 5 + Source/JavaScript/components/given.ts | 33 + Source/JavaScript/components/index.ts | 14 + Source/JavaScript/components/package.json | 68 ++ .../components/pages/SceneDataPage.tsx | 40 + .../components/pages/SceneFormElement.tsx | 21 + .../JavaScript/components/pages/ScenePage.tsx | 30 + .../for_ScenePage/when_rendering_a_page.tsx | 38 + Source/JavaScript/components/pages/index.ts | 6 + Source/JavaScript/components/properties.ts | 112 +++ .../JavaScript/components/rollup.config.mjs | 14 + .../components/theme/sceneTokenBridge.css | 72 ++ .../components/toolbar/SceneToolbar.tsx | 28 + .../components/toolbar/SceneToolbarButton.tsx | 31 + .../components/toolbar/SceneToolbarGroup.tsx | 28 + .../toolbar/SceneToolbarSeparator.tsx | 21 + Source/JavaScript/components/toolbar/index.ts | 7 + Source/JavaScript/components/tsconfig.json | 30 + Source/JavaScript/components/vite.config.mts | 23 + ...lving_against_the_shared_fixture_corpus.ts | 49 ++ Source/JavaScript/engine/index.ts | 1 + .../engine/resolveScreenTemplates.ts | 174 ++++ .../layout.default/.storybook/main.ts | 32 + .../layout.default/ComponentName.ts | 60 ++ .../configuration/ColorScheme.ts | 17 + .../configuration/LayoutConfigProvider.tsx | 182 ++++ .../configuration/LayoutConfigState.ts | 46 + .../configuration/LayoutMode.ts | 50 ++ .../layout.default/configuration/MenuTheme.ts | 24 + .../layout.default/configuration/index.ts | 11 + .../configuration/layoutConfigStorage.ts | 102 +++ .../configuration/layoutConfigTransitions.ts | 150 ++++ .../configuration/layoutWrapperClasses.ts | 68 ++ .../layout.default/defaultBlueprint.ts | 66 ++ .../defaultBlueprintComponents.ts | 51 ++ .../layout.default/gallery/NavigationEntry.ts | 27 + .../gallery/TemplateSlotName.ts | 36 + .../gallery/applicationChrome.ts | 110 +++ .../gallery/assumedComponentNames.ts | 47 + .../layout.default/gallery/authTemplates.ts | 161 ++++ .../layout.default/gallery/composeScreen.ts | 61 ++ .../layout.default/gallery/dialogTemplates.ts | 79 ++ .../layout.default/gallery/elements.ts | 65 ++ .../layout.default/gallery/index.ts | 18 + .../layout.default/gallery/navigation.ts | 71 ++ .../layout.default/gallery/nesting.ts | 82 ++ .../layout.default/gallery/screens.ts | 97 +++ .../layout.default/gallery/statusTemplates.ts | 118 +++ .../gallery/supportTemplates.ts | 163 ++++ .../layout.default/gallery/widgets.ts | 63 ++ .../gallery/workspaceTemplates.ts | 183 ++++ Source/JavaScript/layout.default/index.ts | 12 + .../layout.default/layouts/LayoutName.ts | 18 + .../layout.default/layouts/SlotName.ts | 44 + .../layout.default/layouts/appShell.ts | 106 +++ .../layout.default/layouts/defaultLayouts.ts | 15 + .../layout.default/layouts/flowBuilders.ts | 49 ++ .../layout.default/layouts/fullPage.ts | 48 + .../layout.default/layouts/index.ts | 10 + .../layout.default/layouts/shellComponents.ts | 28 + Source/JavaScript/layout.default/package.json | 65 ++ .../JavaScript/layout.default/packageName.ts | 12 + .../layout.default/rollup.config.mjs | 14 + .../layout.default/shell/AppShell.tsx | 66 ++ .../layout.default/shell/Breadcrumb.tsx | 31 + .../layout.default/shell/ConfigPanel.tsx | 105 +++ .../layout.default/shell/Footer.tsx | 23 + .../layout.default/shell/FullPageShell.tsx | 41 + .../shell/LayoutModeSwitcher.tsx | 50 ++ .../JavaScript/layout.default/shell/Logo.tsx | 30 + .../JavaScript/layout.default/shell/Mask.tsx | 29 + .../JavaScript/layout.default/shell/Menu.tsx | 26 + .../layout.default/shell/MenuItem.tsx | 39 + .../layout.default/shell/PageHeader.tsx | 28 + .../layout.default/shell/RightPanel.tsx | 23 + .../layout.default/shell/Sidebar.tsx | 45 + .../layout.default/shell/ThemeOption.ts | 60 ++ .../layout.default/shell/ThemeSwitcher.tsx | 42 + .../layout.default/shell/Topbar.tsx | 49 ++ .../layout.default/shell/UserMenu.tsx | 53 ++ .../layout.default/shell/elementProperties.ts | 64 ++ .../JavaScript/layout.default/shell/index.ts | 21 + .../layout.default/shell/layout.css | 818 ++++++++++++++++++ .../themes/LayoutThemeProvider.tsx | 41 + .../themes/blueprintThemeCompatibility.ts | 19 + .../JavaScript/layout.default/themes/dark.ts | 35 + .../themes/defaultBlueprintThemes.ts | 14 + .../JavaScript/layout.default/themes/index.ts | 8 + .../JavaScript/layout.default/themes/light.ts | 38 + .../JavaScript/layout.default/tsconfig.json | 30 + .../JavaScript/layout.default/vite.config.mts | 10 + .../model/screens/DialogTemplate.ts | 57 ++ Source/JavaScript/model/screens/Screen.ts | 34 +- .../model/screens/ScreenTemplate.ts | 70 ++ Source/JavaScript/model/screens/index.ts | 2 + .../JavaScript/primereact/.storybook/main.ts | 32 + Source/JavaScript/primereact/SelectOption.ts | 24 + .../primereact/button/PrimeButton.tsx | 31 + .../primereact/button/PrimeButtonGroup.tsx | 24 + .../primereact/button/PrimeSpeedDial.tsx | 26 + .../primereact/button/PrimeSplitButton.tsx | 26 + Source/JavaScript/primereact/button/index.ts | 7 + .../primereact/data/ColumnDefinition.ts | 23 + .../primereact/data/PrimeColumn.tsx | 20 + .../primereact/data/PrimeDataTable.tsx | 36 + .../primereact/data/PrimeDataView.tsx | 34 + .../primereact/data/PrimeOrderList.tsx | 30 + .../data/PrimeOrganizationChart.tsx | 16 + .../primereact/data/PrimePaginator.tsx | 32 + .../primereact/data/PrimePickList.tsx | 36 + .../primereact/data/PrimeTimeline.tsx | 33 + .../JavaScript/primereact/data/PrimeTree.tsx | 21 + .../primereact/data/PrimeTreeTable.tsx | 32 + .../primereact/data/PrimeVirtualScroller.tsx | 31 + .../primereact/data/columnDefinitions.ts | 55 ++ Source/JavaScript/primereact/data/index.ts | 16 + .../when_swapping_the_theme_stylesheet.ts | 81 ++ .../when_deriving_columns.ts | 80 ++ .../for_menuItems/when_converting_entries.ts | 40 + .../when_inspecting_the_manifest.ts | 41 + .../when_inspecting_the_registry.ts | 33 + ...hen_resolving_a_name_core_also_declares.ts | 45 + .../when_validating_the_bundle.ts | 17 + .../when_checking_attribution.ts | 30 + .../when_checking_compatibility.ts | 26 + .../when_checking_the_token_vocabulary.ts | 51 ++ .../when_resolving_a_theme_stylesheet.ts | 22 + .../when_reading_a_boolean_property.ts | 35 + .../when_reading_a_number_property.ts | 39 + .../when_reading_a_string_property.ts | 31 + .../when_reading_an_array_property.ts | 41 + .../for_properties/when_reading_options.ts | 50 ++ .../for_treeNodes/when_converting_entries.ts | 43 + .../primereact/form/PrimeAutoComplete.tsx | 36 + .../primereact/form/PrimeCalendar.tsx | 39 + .../primereact/form/PrimeCascadeSelect.tsx | 31 + .../primereact/form/PrimeCheckbox.tsx | 29 + .../JavaScript/primereact/form/PrimeChips.tsx | 26 + .../primereact/form/PrimeColorPicker.tsx | 26 + .../primereact/form/PrimeDropdown.tsx | 34 + .../primereact/form/PrimeFloatLabel.tsx | 25 + .../primereact/form/PrimeIconField.tsx | 23 + .../primereact/form/PrimeInputMask.tsx | 27 + .../primereact/form/PrimeInputNumber.tsx | 32 + .../primereact/form/PrimeInputText.tsx | 31 + .../primereact/form/PrimeInputTextarea.tsx | 28 + .../JavaScript/primereact/form/PrimeKnob.tsx | 27 + .../primereact/form/PrimeListBox.tsx | 30 + .../primereact/form/PrimeMultiSelect.tsx | 28 + .../primereact/form/PrimePassword.tsx | 25 + .../primereact/form/PrimeRadioButton.tsx | 39 + .../primereact/form/PrimeRating.tsx | 25 + .../primereact/form/PrimeSelectButton.tsx | 28 + .../primereact/form/PrimeSlider.tsx | 29 + .../primereact/form/PrimeToggleSwitch.tsx | 31 + .../primereact/form/PrimeTreeSelect.tsx | 26 + Source/JavaScript/primereact/form/index.ts | 26 + Source/JavaScript/primereact/index.ts | 20 + .../primereact/media/PrimeCarousel.tsx | 32 + .../primereact/media/PrimeGalleria.tsx | 35 + .../primereact/media/PrimeImage.tsx | 26 + Source/JavaScript/primereact/media/index.ts | 6 + .../primereact/menu/PrimeBreadcrumb.tsx | 23 + .../primereact/menu/PrimeContextMenu.tsx | 25 + .../JavaScript/primereact/menu/PrimeDock.tsx | 23 + .../primereact/menu/PrimeMegaMenu.tsx | 24 + .../JavaScript/primereact/menu/PrimeMenu.tsx | 13 + .../primereact/menu/PrimeMenubar.tsx | 16 + .../primereact/menu/PrimePanelMenu.tsx | 18 + .../JavaScript/primereact/menu/PrimeSteps.tsx | 27 + .../primereact/menu/PrimeTabMenu.tsx | 27 + .../primereact/menu/PrimeTieredMenu.tsx | 13 + Source/JavaScript/primereact/menu/index.ts | 13 + Source/JavaScript/primereact/menuItems.ts | 57 ++ .../messages/PrimeInlineMessage.tsx | 23 + .../primereact/messages/PrimeMessage.tsx | 24 + .../primereact/messages/PrimeToast.tsx | 29 + .../JavaScript/primereact/messages/index.ts | 6 + .../primereact/misc/PrimeAvatar.tsx | 22 + .../JavaScript/primereact/misc/PrimeBadge.tsx | 20 + .../primereact/misc/PrimeBlockUI.tsx | 19 + .../JavaScript/primereact/misc/PrimeChip.tsx | 21 + .../primereact/misc/PrimeInplace.tsx | 22 + .../primereact/misc/PrimeProgressBar.tsx | 25 + .../primereact/misc/PrimeProgressSpinner.tsx | 14 + .../primereact/misc/PrimeScrollTop.tsx | 24 + .../primereact/misc/PrimeSkeleton.tsx | 20 + .../JavaScript/primereact/misc/PrimeTag.tsx | 21 + .../primereact/misc/PrimeTerminal.tsx | 24 + Source/JavaScript/primereact/misc/index.ts | 14 + .../primereact/overlay/PrimeConfirmDialog.tsx | 39 + .../primereact/overlay/PrimeDialog.tsx | 36 + .../primereact/overlay/PrimeOverlayPanel.tsx | 26 + .../primereact/overlay/PrimeSidebar.tsx | 31 + .../primereact/overlay/PrimeTooltip.tsx | 26 + Source/JavaScript/primereact/overlay/index.ts | 8 + Source/JavaScript/primereact/package.json | 68 ++ .../primereact/panel/PrimeAccordion.tsx | 29 + .../JavaScript/primereact/panel/PrimeCard.tsx | 21 + .../primereact/panel/PrimeDivider.tsx | 22 + .../primereact/panel/PrimeFieldset.tsx | 17 + .../primereact/panel/PrimePanel.tsx | 21 + .../primereact/panel/PrimeScrollPanel.tsx | 24 + .../primereact/panel/PrimeSplitter.tsx | 29 + .../primereact/panel/PrimeStepper.tsx | 42 + .../primereact/panel/PrimeTabView.tsx | 29 + .../primereact/panel/PrimeToolbar.tsx | 15 + Source/JavaScript/primereact/panel/index.ts | 13 + .../primereact/primeReactComponents.ts | 200 +++++ .../primereact/primeReactPackage.ts | 83 ++ .../JavaScript/primereact/primeReactTheme.css | 88 ++ Source/JavaScript/primereact/properties.ts | 134 +++ .../JavaScript/primereact/rollup.config.mjs | 23 + .../primereact/screen/PrimeAction.tsx | 37 + .../primereact/screen/PrimeField.tsx | 26 + .../primereact/screen/PrimeSection.tsx | 30 + .../primereact/screen/PrimeSummary.tsx | 33 + .../primereact/screen/PrimeText.tsx | 28 + .../primereact/screen/PrimeTitle.tsx | 27 + Source/JavaScript/primereact/screen/index.ts | 9 + .../primereact/theme/applyPrimeReactTheme.ts | 52 ++ Source/JavaScript/primereact/theme/index.ts | 8 + .../theme/primeReactThemeStylesheet.ts | 32 + .../primereact/theme/primeReactThemes.ts | 61 ++ .../primereact/theme/themePresets.ts | 535 ++++++++++++ .../primereact/theme/usePrimeReactTheme.ts | 43 + Source/JavaScript/primereact/treeNodes.ts | 55 ++ Source/JavaScript/primereact/tsconfig.json | 30 + Source/JavaScript/primereact/vite.config.mts | 18 + .../JavaScript/react/core/coreComponents.ts | 7 +- Source/JavaScript/react/core/corePackage.ts | 36 + Source/JavaScript/react/core/index.ts | 1 + .../when_switching_themes.ts | 74 ++ .../when_converting_a_token_name.ts | 22 + .../when_the_bundle_and_manifest_disagree.ts | 72 ++ .../when_the_bundle_matches_its_manifest.ts | 12 + Source/JavaScript/react/index.ts | 2 + .../react/theme/SceneThemeProvider.tsx | 58 ++ Source/JavaScript/react/theme/index.ts | 5 + Source/JavaScript/react/theme/themeTokens.ts | 59 ++ Source/JavaScript/tailwind/index.ts | 4 + Source/JavaScript/tailwind/package.json | 54 ++ Source/JavaScript/tailwind/rollup.config.mjs | 14 + Source/JavaScript/tailwind/tailwindPackage.ts | 38 + Source/JavaScript/tailwind/tsconfig.json | 30 + Source/JavaScript/tailwind/vite.config.mts | 10 + package-dependency-fixtures.json | 466 ++++++++-- scene-model-shape.json | 306 ++++++- screen-template-fixtures.json | 112 +++ 338 files changed, 14767 insertions(+), 142 deletions(-) create mode 100644 Documentation/blueprints/index.md create mode 100644 Documentation/blueprints/layouts.md create mode 100644 Documentation/blueprints/screen-templates.md create mode 100644 Documentation/blueprints/toc.yml create mode 100644 Documentation/index.md create mode 100644 Documentation/toc.yml create mode 100644 Source/DotNET/Engine.Specs/for_ScreenTemplateResolver/when_resolving_against_the_shared_fixture_corpus.cs create mode 100644 Source/DotNET/Engine/Screens/ScreenTemplatePlacement.cs create mode 100644 Source/DotNET/Engine/Screens/ScreenTemplateResolution.cs create mode 100644 Source/DotNET/Engine/Screens/ScreenTemplateResolver.cs create mode 100644 Source/DotNET/Engine/Screens/UnplacedScreenTemplate.cs create mode 100644 Source/DotNET/Model/Screens/DialogTemplate.cs create mode 100644 Source/DotNET/Model/Screens/ScreenTemplate.cs create mode 100644 Source/JavaScript/components/.storybook/main.experiment.txt create mode 100644 Source/JavaScript/components/.storybook/main.ts create mode 100644 Source/JavaScript/components/bindings/ArcRuntimeBoundary.tsx create mode 100644 Source/JavaScript/components/bindings/BindingKind.ts create mode 100644 Source/JavaScript/components/bindings/BoundConstructor.ts create mode 100644 Source/JavaScript/components/bindings/ElementBinding.ts create mode 100644 Source/JavaScript/components/bindings/MissingBinding.tsx create mode 100644 Source/JavaScript/components/bindings/Placeholder.tsx create mode 100644 Source/JavaScript/components/bindings/bindingRegistry.ts create mode 100644 Source/JavaScript/components/bindings/for_bindingRegistry/when_a_command_is_registered.ts create mode 100644 Source/JavaScript/components/bindings/for_bindingRegistry/when_a_query_is_not_registered.ts create mode 100644 Source/JavaScript/components/bindings/for_bindingRegistry/when_a_query_is_registered.ts create mode 100644 Source/JavaScript/components/bindings/for_bindingRegistry/when_bindings_are_cleared.ts create mode 100644 Source/JavaScript/components/bindings/for_resolveElementBinding/when_resolving_a_command_binding.ts create mode 100644 Source/JavaScript/components/bindings/for_resolveElementBinding/when_resolving_a_query_binding.ts create mode 100644 Source/JavaScript/components/bindings/index.ts create mode 100644 Source/JavaScript/components/bindings/resolveElementBinding.ts create mode 100644 Source/JavaScript/components/common/SceneDropdown.tsx create mode 100644 Source/JavaScript/components/common/SceneErrorBoundary.tsx create mode 100644 Source/JavaScript/components/common/SceneIcon.tsx create mode 100644 Source/JavaScript/components/common/SceneTooltip.tsx create mode 100644 Source/JavaScript/components/common/index.ts create mode 100644 Source/JavaScript/components/cratisComponents.ts create mode 100644 Source/JavaScript/components/cratisComponentsPackage.ts create mode 100644 Source/JavaScript/components/data/SceneDataTable.tsx create mode 100644 Source/JavaScript/components/data/SceneObservableDataTable.tsx create mode 100644 Source/JavaScript/components/data/for_SceneDataTable/when_the_query_binding_is_missing.tsx create mode 100644 Source/JavaScript/components/data/index.ts create mode 100644 Source/JavaScript/components/dialogs/SceneBusyIndicatorDialog.tsx create mode 100644 Source/JavaScript/components/dialogs/SceneCommandDialog.tsx create mode 100644 Source/JavaScript/components/dialogs/SceneConfirmationDialog.tsx create mode 100644 Source/JavaScript/components/dialogs/SceneDialog.tsx create mode 100644 Source/JavaScript/components/dialogs/SceneStepperCommandDialog.tsx create mode 100644 Source/JavaScript/components/dialogs/index.ts create mode 100644 Source/JavaScript/components/editors/SceneFilterPanel.tsx create mode 100644 Source/JavaScript/components/editors/SceneObjectContentEditor.tsx create mode 100644 Source/JavaScript/components/editors/SceneObjectNavigationalBar.tsx create mode 100644 Source/JavaScript/components/editors/SceneSchemaEditor.tsx create mode 100644 Source/JavaScript/components/editors/SceneTimeMachine.tsx create mode 100644 Source/JavaScript/components/editors/filterDefinitions.ts create mode 100644 Source/JavaScript/components/editors/for_filterDefinitions/when_reading_filter_definitions.ts create mode 100644 Source/JavaScript/components/editors/for_timeMachineVersions/when_reading_versions.ts create mode 100644 Source/JavaScript/components/editors/index.ts create mode 100644 Source/JavaScript/components/editors/timeMachineVersions.ts create mode 100644 Source/JavaScript/components/editors/useEditableCopy.ts create mode 100644 Source/JavaScript/components/for_cratisComponents/when_rendering_a_screen_through_the_real_renderer.tsx create mode 100644 Source/JavaScript/components/for_cratisComponentsPackage/when_a_profile_ranks_it_above_the_packages_it_layers_on.ts create mode 100644 Source/JavaScript/components/for_cratisComponentsPackage/when_resolving_its_dependencies.ts create mode 100644 Source/JavaScript/components/for_cratisComponentsPackage/when_validating_the_bundle.ts create mode 100644 Source/JavaScript/components/for_properties/when_a_property_has_the_wrong_type.ts create mode 100644 Source/JavaScript/components/for_properties/when_a_property_is_missing.ts create mode 100644 Source/JavaScript/components/for_properties/when_a_property_is_present.ts create mode 100644 Source/JavaScript/components/forms/SceneCommandForm.tsx create mode 100644 Source/JavaScript/components/forms/fields/CommandFormField.tsx create mode 100644 Source/JavaScript/components/forms/fields/FieldBinding.ts create mode 100644 Source/JavaScript/components/forms/fields/SceneCalendarField.tsx create mode 100644 Source/JavaScript/components/forms/fields/SceneCheckboxField.tsx create mode 100644 Source/JavaScript/components/forms/fields/SceneChipsField.tsx create mode 100644 Source/JavaScript/components/forms/fields/SceneColorPickerField.tsx create mode 100644 Source/JavaScript/components/forms/fields/SceneDropdownField.tsx create mode 100644 Source/JavaScript/components/forms/fields/SceneInputTextField.tsx create mode 100644 Source/JavaScript/components/forms/fields/SceneMultiSelectField.tsx create mode 100644 Source/JavaScript/components/forms/fields/SceneNumberField.tsx create mode 100644 Source/JavaScript/components/forms/fields/SceneRadioButtonField.tsx create mode 100644 Source/JavaScript/components/forms/fields/SceneRadioGroupField.tsx create mode 100644 Source/JavaScript/components/forms/fields/SceneSliderField.tsx create mode 100644 Source/JavaScript/components/forms/fields/SceneTextAreaField.tsx create mode 100644 Source/JavaScript/components/forms/fields/for_CommandFormField/when_the_field_names_no_property.tsx create mode 100644 Source/JavaScript/components/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.ts create mode 100644 Source/JavaScript/components/forms/fields/index.ts create mode 100644 Source/JavaScript/components/forms/fields/resolveFieldBinding.ts create mode 100644 Source/JavaScript/components/forms/index.ts create mode 100644 Source/JavaScript/components/given.ts create mode 100644 Source/JavaScript/components/index.ts create mode 100644 Source/JavaScript/components/package.json create mode 100644 Source/JavaScript/components/pages/SceneDataPage.tsx create mode 100644 Source/JavaScript/components/pages/SceneFormElement.tsx create mode 100644 Source/JavaScript/components/pages/ScenePage.tsx create mode 100644 Source/JavaScript/components/pages/for_ScenePage/when_rendering_a_page.tsx create mode 100644 Source/JavaScript/components/pages/index.ts create mode 100644 Source/JavaScript/components/properties.ts create mode 100644 Source/JavaScript/components/rollup.config.mjs create mode 100644 Source/JavaScript/components/theme/sceneTokenBridge.css create mode 100644 Source/JavaScript/components/toolbar/SceneToolbar.tsx create mode 100644 Source/JavaScript/components/toolbar/SceneToolbarButton.tsx create mode 100644 Source/JavaScript/components/toolbar/SceneToolbarGroup.tsx create mode 100644 Source/JavaScript/components/toolbar/SceneToolbarSeparator.tsx create mode 100644 Source/JavaScript/components/toolbar/index.ts create mode 100644 Source/JavaScript/components/tsconfig.json create mode 100644 Source/JavaScript/components/vite.config.mts create mode 100644 Source/JavaScript/engine/for_resolveScreenTemplates/when_resolving_against_the_shared_fixture_corpus.ts create mode 100644 Source/JavaScript/engine/resolveScreenTemplates.ts create mode 100644 Source/JavaScript/layout.default/.storybook/main.ts create mode 100644 Source/JavaScript/layout.default/ComponentName.ts create mode 100644 Source/JavaScript/layout.default/configuration/ColorScheme.ts create mode 100644 Source/JavaScript/layout.default/configuration/LayoutConfigProvider.tsx create mode 100644 Source/JavaScript/layout.default/configuration/LayoutConfigState.ts create mode 100644 Source/JavaScript/layout.default/configuration/LayoutMode.ts create mode 100644 Source/JavaScript/layout.default/configuration/MenuTheme.ts create mode 100644 Source/JavaScript/layout.default/configuration/index.ts create mode 100644 Source/JavaScript/layout.default/configuration/layoutConfigStorage.ts create mode 100644 Source/JavaScript/layout.default/configuration/layoutConfigTransitions.ts create mode 100644 Source/JavaScript/layout.default/configuration/layoutWrapperClasses.ts create mode 100644 Source/JavaScript/layout.default/defaultBlueprint.ts create mode 100644 Source/JavaScript/layout.default/defaultBlueprintComponents.ts create mode 100644 Source/JavaScript/layout.default/gallery/NavigationEntry.ts create mode 100644 Source/JavaScript/layout.default/gallery/TemplateSlotName.ts create mode 100644 Source/JavaScript/layout.default/gallery/applicationChrome.ts create mode 100644 Source/JavaScript/layout.default/gallery/assumedComponentNames.ts create mode 100644 Source/JavaScript/layout.default/gallery/authTemplates.ts create mode 100644 Source/JavaScript/layout.default/gallery/composeScreen.ts create mode 100644 Source/JavaScript/layout.default/gallery/dialogTemplates.ts create mode 100644 Source/JavaScript/layout.default/gallery/elements.ts create mode 100644 Source/JavaScript/layout.default/gallery/index.ts create mode 100644 Source/JavaScript/layout.default/gallery/navigation.ts create mode 100644 Source/JavaScript/layout.default/gallery/nesting.ts create mode 100644 Source/JavaScript/layout.default/gallery/screens.ts create mode 100644 Source/JavaScript/layout.default/gallery/statusTemplates.ts create mode 100644 Source/JavaScript/layout.default/gallery/supportTemplates.ts create mode 100644 Source/JavaScript/layout.default/gallery/widgets.ts create mode 100644 Source/JavaScript/layout.default/gallery/workspaceTemplates.ts create mode 100644 Source/JavaScript/layout.default/index.ts create mode 100644 Source/JavaScript/layout.default/layouts/LayoutName.ts create mode 100644 Source/JavaScript/layout.default/layouts/SlotName.ts create mode 100644 Source/JavaScript/layout.default/layouts/appShell.ts create mode 100644 Source/JavaScript/layout.default/layouts/defaultLayouts.ts create mode 100644 Source/JavaScript/layout.default/layouts/flowBuilders.ts create mode 100644 Source/JavaScript/layout.default/layouts/fullPage.ts create mode 100644 Source/JavaScript/layout.default/layouts/index.ts create mode 100644 Source/JavaScript/layout.default/layouts/shellComponents.ts create mode 100644 Source/JavaScript/layout.default/package.json create mode 100644 Source/JavaScript/layout.default/packageName.ts create mode 100644 Source/JavaScript/layout.default/rollup.config.mjs create mode 100644 Source/JavaScript/layout.default/shell/AppShell.tsx create mode 100644 Source/JavaScript/layout.default/shell/Breadcrumb.tsx create mode 100644 Source/JavaScript/layout.default/shell/ConfigPanel.tsx create mode 100644 Source/JavaScript/layout.default/shell/Footer.tsx create mode 100644 Source/JavaScript/layout.default/shell/FullPageShell.tsx create mode 100644 Source/JavaScript/layout.default/shell/LayoutModeSwitcher.tsx create mode 100644 Source/JavaScript/layout.default/shell/Logo.tsx create mode 100644 Source/JavaScript/layout.default/shell/Mask.tsx create mode 100644 Source/JavaScript/layout.default/shell/Menu.tsx create mode 100644 Source/JavaScript/layout.default/shell/MenuItem.tsx create mode 100644 Source/JavaScript/layout.default/shell/PageHeader.tsx create mode 100644 Source/JavaScript/layout.default/shell/RightPanel.tsx create mode 100644 Source/JavaScript/layout.default/shell/Sidebar.tsx create mode 100644 Source/JavaScript/layout.default/shell/ThemeOption.ts create mode 100644 Source/JavaScript/layout.default/shell/ThemeSwitcher.tsx create mode 100644 Source/JavaScript/layout.default/shell/Topbar.tsx create mode 100644 Source/JavaScript/layout.default/shell/UserMenu.tsx create mode 100644 Source/JavaScript/layout.default/shell/elementProperties.ts create mode 100644 Source/JavaScript/layout.default/shell/index.ts create mode 100644 Source/JavaScript/layout.default/shell/layout.css create mode 100644 Source/JavaScript/layout.default/themes/LayoutThemeProvider.tsx create mode 100644 Source/JavaScript/layout.default/themes/blueprintThemeCompatibility.ts create mode 100644 Source/JavaScript/layout.default/themes/dark.ts create mode 100644 Source/JavaScript/layout.default/themes/defaultBlueprintThemes.ts create mode 100644 Source/JavaScript/layout.default/themes/index.ts create mode 100644 Source/JavaScript/layout.default/themes/light.ts create mode 100644 Source/JavaScript/layout.default/tsconfig.json create mode 100644 Source/JavaScript/layout.default/vite.config.mts create mode 100644 Source/JavaScript/model/screens/DialogTemplate.ts create mode 100644 Source/JavaScript/model/screens/ScreenTemplate.ts create mode 100644 Source/JavaScript/primereact/.storybook/main.ts create mode 100644 Source/JavaScript/primereact/SelectOption.ts create mode 100644 Source/JavaScript/primereact/button/PrimeButton.tsx create mode 100644 Source/JavaScript/primereact/button/PrimeButtonGroup.tsx create mode 100644 Source/JavaScript/primereact/button/PrimeSpeedDial.tsx create mode 100644 Source/JavaScript/primereact/button/PrimeSplitButton.tsx create mode 100644 Source/JavaScript/primereact/button/index.ts create mode 100644 Source/JavaScript/primereact/data/ColumnDefinition.ts create mode 100644 Source/JavaScript/primereact/data/PrimeColumn.tsx create mode 100644 Source/JavaScript/primereact/data/PrimeDataTable.tsx create mode 100644 Source/JavaScript/primereact/data/PrimeDataView.tsx create mode 100644 Source/JavaScript/primereact/data/PrimeOrderList.tsx create mode 100644 Source/JavaScript/primereact/data/PrimeOrganizationChart.tsx create mode 100644 Source/JavaScript/primereact/data/PrimePaginator.tsx create mode 100644 Source/JavaScript/primereact/data/PrimePickList.tsx create mode 100644 Source/JavaScript/primereact/data/PrimeTimeline.tsx create mode 100644 Source/JavaScript/primereact/data/PrimeTree.tsx create mode 100644 Source/JavaScript/primereact/data/PrimeTreeTable.tsx create mode 100644 Source/JavaScript/primereact/data/PrimeVirtualScroller.tsx create mode 100644 Source/JavaScript/primereact/data/columnDefinitions.ts create mode 100644 Source/JavaScript/primereact/data/index.ts create mode 100644 Source/JavaScript/primereact/for_applyPrimeReactTheme/when_swapping_the_theme_stylesheet.ts create mode 100644 Source/JavaScript/primereact/for_columnDefinitions/when_deriving_columns.ts create mode 100644 Source/JavaScript/primereact/for_menuItems/when_converting_entries.ts create mode 100644 Source/JavaScript/primereact/for_primeReactPackage/when_inspecting_the_manifest.ts create mode 100644 Source/JavaScript/primereact/for_primeReactPackage/when_inspecting_the_registry.ts create mode 100644 Source/JavaScript/primereact/for_primeReactPackage/when_resolving_a_name_core_also_declares.ts create mode 100644 Source/JavaScript/primereact/for_primeReactPackage/when_validating_the_bundle.ts create mode 100644 Source/JavaScript/primereact/for_primeReactThemes/when_checking_attribution.ts create mode 100644 Source/JavaScript/primereact/for_primeReactThemes/when_checking_compatibility.ts create mode 100644 Source/JavaScript/primereact/for_primeReactThemes/when_checking_the_token_vocabulary.ts create mode 100644 Source/JavaScript/primereact/for_primeReactThemes/when_resolving_a_theme_stylesheet.ts create mode 100644 Source/JavaScript/primereact/for_properties/when_reading_a_boolean_property.ts create mode 100644 Source/JavaScript/primereact/for_properties/when_reading_a_number_property.ts create mode 100644 Source/JavaScript/primereact/for_properties/when_reading_a_string_property.ts create mode 100644 Source/JavaScript/primereact/for_properties/when_reading_an_array_property.ts create mode 100644 Source/JavaScript/primereact/for_properties/when_reading_options.ts create mode 100644 Source/JavaScript/primereact/for_treeNodes/when_converting_entries.ts create mode 100644 Source/JavaScript/primereact/form/PrimeAutoComplete.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeCalendar.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeCascadeSelect.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeCheckbox.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeChips.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeColorPicker.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeDropdown.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeFloatLabel.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeIconField.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeInputMask.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeInputNumber.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeInputText.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeInputTextarea.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeKnob.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeListBox.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeMultiSelect.tsx create mode 100644 Source/JavaScript/primereact/form/PrimePassword.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeRadioButton.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeRating.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeSelectButton.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeSlider.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeToggleSwitch.tsx create mode 100644 Source/JavaScript/primereact/form/PrimeTreeSelect.tsx create mode 100644 Source/JavaScript/primereact/form/index.ts create mode 100644 Source/JavaScript/primereact/index.ts create mode 100644 Source/JavaScript/primereact/media/PrimeCarousel.tsx create mode 100644 Source/JavaScript/primereact/media/PrimeGalleria.tsx create mode 100644 Source/JavaScript/primereact/media/PrimeImage.tsx create mode 100644 Source/JavaScript/primereact/media/index.ts create mode 100644 Source/JavaScript/primereact/menu/PrimeBreadcrumb.tsx create mode 100644 Source/JavaScript/primereact/menu/PrimeContextMenu.tsx create mode 100644 Source/JavaScript/primereact/menu/PrimeDock.tsx create mode 100644 Source/JavaScript/primereact/menu/PrimeMegaMenu.tsx create mode 100644 Source/JavaScript/primereact/menu/PrimeMenu.tsx create mode 100644 Source/JavaScript/primereact/menu/PrimeMenubar.tsx create mode 100644 Source/JavaScript/primereact/menu/PrimePanelMenu.tsx create mode 100644 Source/JavaScript/primereact/menu/PrimeSteps.tsx create mode 100644 Source/JavaScript/primereact/menu/PrimeTabMenu.tsx create mode 100644 Source/JavaScript/primereact/menu/PrimeTieredMenu.tsx create mode 100644 Source/JavaScript/primereact/menu/index.ts create mode 100644 Source/JavaScript/primereact/menuItems.ts create mode 100644 Source/JavaScript/primereact/messages/PrimeInlineMessage.tsx create mode 100644 Source/JavaScript/primereact/messages/PrimeMessage.tsx create mode 100644 Source/JavaScript/primereact/messages/PrimeToast.tsx create mode 100644 Source/JavaScript/primereact/messages/index.ts create mode 100644 Source/JavaScript/primereact/misc/PrimeAvatar.tsx create mode 100644 Source/JavaScript/primereact/misc/PrimeBadge.tsx create mode 100644 Source/JavaScript/primereact/misc/PrimeBlockUI.tsx create mode 100644 Source/JavaScript/primereact/misc/PrimeChip.tsx create mode 100644 Source/JavaScript/primereact/misc/PrimeInplace.tsx create mode 100644 Source/JavaScript/primereact/misc/PrimeProgressBar.tsx create mode 100644 Source/JavaScript/primereact/misc/PrimeProgressSpinner.tsx create mode 100644 Source/JavaScript/primereact/misc/PrimeScrollTop.tsx create mode 100644 Source/JavaScript/primereact/misc/PrimeSkeleton.tsx create mode 100644 Source/JavaScript/primereact/misc/PrimeTag.tsx create mode 100644 Source/JavaScript/primereact/misc/PrimeTerminal.tsx create mode 100644 Source/JavaScript/primereact/misc/index.ts create mode 100644 Source/JavaScript/primereact/overlay/PrimeConfirmDialog.tsx create mode 100644 Source/JavaScript/primereact/overlay/PrimeDialog.tsx create mode 100644 Source/JavaScript/primereact/overlay/PrimeOverlayPanel.tsx create mode 100644 Source/JavaScript/primereact/overlay/PrimeSidebar.tsx create mode 100644 Source/JavaScript/primereact/overlay/PrimeTooltip.tsx create mode 100644 Source/JavaScript/primereact/overlay/index.ts create mode 100644 Source/JavaScript/primereact/package.json create mode 100644 Source/JavaScript/primereact/panel/PrimeAccordion.tsx create mode 100644 Source/JavaScript/primereact/panel/PrimeCard.tsx create mode 100644 Source/JavaScript/primereact/panel/PrimeDivider.tsx create mode 100644 Source/JavaScript/primereact/panel/PrimeFieldset.tsx create mode 100644 Source/JavaScript/primereact/panel/PrimePanel.tsx create mode 100644 Source/JavaScript/primereact/panel/PrimeScrollPanel.tsx create mode 100644 Source/JavaScript/primereact/panel/PrimeSplitter.tsx create mode 100644 Source/JavaScript/primereact/panel/PrimeStepper.tsx create mode 100644 Source/JavaScript/primereact/panel/PrimeTabView.tsx create mode 100644 Source/JavaScript/primereact/panel/PrimeToolbar.tsx create mode 100644 Source/JavaScript/primereact/panel/index.ts create mode 100644 Source/JavaScript/primereact/primeReactComponents.ts create mode 100644 Source/JavaScript/primereact/primeReactPackage.ts create mode 100644 Source/JavaScript/primereact/primeReactTheme.css create mode 100644 Source/JavaScript/primereact/properties.ts create mode 100644 Source/JavaScript/primereact/rollup.config.mjs create mode 100644 Source/JavaScript/primereact/screen/PrimeAction.tsx create mode 100644 Source/JavaScript/primereact/screen/PrimeField.tsx create mode 100644 Source/JavaScript/primereact/screen/PrimeSection.tsx create mode 100644 Source/JavaScript/primereact/screen/PrimeSummary.tsx create mode 100644 Source/JavaScript/primereact/screen/PrimeText.tsx create mode 100644 Source/JavaScript/primereact/screen/PrimeTitle.tsx create mode 100644 Source/JavaScript/primereact/screen/index.ts create mode 100644 Source/JavaScript/primereact/theme/applyPrimeReactTheme.ts create mode 100644 Source/JavaScript/primereact/theme/index.ts create mode 100644 Source/JavaScript/primereact/theme/primeReactThemeStylesheet.ts create mode 100644 Source/JavaScript/primereact/theme/primeReactThemes.ts create mode 100644 Source/JavaScript/primereact/theme/themePresets.ts create mode 100644 Source/JavaScript/primereact/theme/usePrimeReactTheme.ts create mode 100644 Source/JavaScript/primereact/treeNodes.ts create mode 100644 Source/JavaScript/primereact/tsconfig.json create mode 100644 Source/JavaScript/primereact/vite.config.mts create mode 100644 Source/JavaScript/react/core/corePackage.ts create mode 100644 Source/JavaScript/react/for_applyThemeTokens/when_switching_themes.ts create mode 100644 Source/JavaScript/react/for_themeTokens/when_converting_a_token_name.ts create mode 100644 Source/JavaScript/react/for_validatePackageBundle/when_the_bundle_and_manifest_disagree.ts create mode 100644 Source/JavaScript/react/for_validatePackageBundle/when_the_bundle_matches_its_manifest.ts create mode 100644 Source/JavaScript/react/theme/SceneThemeProvider.tsx create mode 100644 Source/JavaScript/react/theme/index.ts create mode 100644 Source/JavaScript/react/theme/themeTokens.ts create mode 100644 Source/JavaScript/tailwind/index.ts create mode 100644 Source/JavaScript/tailwind/package.json create mode 100644 Source/JavaScript/tailwind/rollup.config.mjs create mode 100644 Source/JavaScript/tailwind/tailwindPackage.ts create mode 100644 Source/JavaScript/tailwind/tsconfig.json create mode 100644 Source/JavaScript/tailwind/vite.config.mts create mode 100644 screen-template-fixtures.json diff --git a/Documentation/blueprints/index.md b/Documentation/blueprints/index.md new file mode 100644 index 0000000..58d48cf --- /dev/null +++ b/Documentation/blueprints/index.md @@ -0,0 +1,52 @@ +# Blueprints + +A blueprint is a package that ships the shape of an application: its layouts, the screen and dialog templates +built on them, and the components that fill their slots. + +An application selects **one** blueprint. That is the point — a blueprint is a coherent set, designed +together, rather than a layout from one place and templates from another that happen not to clash. In the +application's settings you pick a blueprint, and everything it holds becomes available. + +A blueprint declares the component libraries it is built from, like any other package: + +```csharp +new ScenePackage( + Name: "Cratis.Blueprint.Default", + Version: "1.0.0", + Kind: PackageKind.Blueprint, + Dependencies: [new PackageDependency("PrimeReact"), new PackageDependency("Cratis.Components")], + Components: ["appShell", "topbar", "sidebar", /* ... */], + Layouts: ["AppShell", "FullPage"], + ScreenTemplates: ["ModuleWorkspace", "FeatureList", /* ... */], + DialogTemplates: ["Confirm", /* ... */], + Themes: ["Daylight", "Midnight"]); +``` + +Because those dependencies are declared, "which blueprints can I use" is answerable from the packages a +profile already has, rather than being something you find out by trying one. + +## Layout, template, screen + +These three are easy to run together and mean different things. + +A **[layout](layouts.md)** is the application's base navigational look — the shell, with its top bar, +navigation and content region. An application has one. + +A **[screen template](screen-templates.md)** is a reusable shape that goes *inside* that shell, at module, +feature or slice level. An application has many. Each declares which of its parent's slots it fills, so a +module's template fits the layout, a feature's template fits the module's, and a slice's fits the feature's — +the same rule at every level, nesting arbitrarily deep without a second mechanism. + +A **dialog template** is the same idea for content that opens *over* the application. It declares no parent +slot, because it occupies none: a dialog is summoned, not placed. + +A **screen** is an instance. It names the structure it fills and supplies the content. + +Layouts and screen templates are structurally alike on purpose — both are slots plus an arrangement, +evaluated by the same engine. They differ in role, and a screen template additionally says where it belongs. + +## What a blueprint is not + +A blueprint is a **packaged artifact, not a language construct**. It never appears in a `.play` file. A +`ui profile` lists it by name in `packages` like anything else; the layouts and templates it provides are +then resolvable by name, exactly as its components are. diff --git a/Documentation/blueprints/layouts.md b/Documentation/blueprints/layouts.md new file mode 100644 index 0000000..3cf1adc --- /dev/null +++ b/Documentation/blueprints/layouts.md @@ -0,0 +1,79 @@ +# Layouts + +A layout is an application's base navigational look: the shell everything else renders inside. + +```csharp +public record Layout(string Name, IReadOnlyList Slots, Arrangement? Arrangement = null); +``` + +Three things, and the third is optional. `Slots` are the named regions the shell offers — a top bar, a +navigation area, a content region, a footer. `Arrangement` says how those slots sit relative to each other. +With no arrangement, they are simply in declaration order. + +An application has **one** layout in force, and selects it — usually from a [blueprint](index.md). + +## Slots + +```csharp +public record Slot(string Name, Arrangement? Arrangement = null); +``` + +A slot's own `Arrangement` is a different thing from the layout's, and the distinction matters: + +- The **layout's** arrangement positions the slots relative to each other. Its flow leaves are + `FlowSlotLeaf`, which reference a slot by name. +- A **slot's** arrangement positions the content filling that one slot. Its flow leaves are `FlowLeaf`, which + carry a real element. + +A layout is not uniformly one arrangement mode. Flow for most slots and freeform for one is a valid +combination, and the engine evaluates each independently. + +## Arrangement + +Two modes, both evaluated by `Cratis.Scene.Engine.Layouts` and its TypeScript twin. + +**Flow** is a tree of rows, columns and grids, with size-class overrides: + +```csharp +new FlowArrangement( + Root: new FlowColumn + { + Children = + [ + new FlowSlotLeaf("topbar"), + new FlowRow { Children = [new FlowSlotLeaf("sidebar"), new FlowSlotLeaf("content") { Grow = 1 }] }, + new FlowSlotLeaf("footer") + ] + }, + Overrides: + [ + new FlowOverride( + Width: WidthSizeClass.Compact, + Height: null, + Root: new FlowColumn { Children = [new FlowSlotLeaf("topbar"), new FlowSlotLeaf("content"), new FlowSlotLeaf("footer")] }) + ]); +``` + +The override drops the sidebar out of the flow on a compact width. `Width` and `Height` are independently +nullable, so an override can key on either axis or both. When more than one override matches a concrete size +class, the most specific wins — both dimensions beats one — and among equally specific matches, the last +declared wins. + +**Freeform** is one variant per size-class combination, each placing slots at explicit coordinates. Selection +is exact-match only: a size class with no matching variant returns nothing rather than falling back to a +variant that was never designed for it. That is deliberate — "warn, don't silently pick". + +## Size classes + +Size classes are named, not pixel breakpoints: `Compact` or `Regular` on each of width and height. A narrow +browser window and a phone in portrait are the *same* class, which is what lets one layout describe both. + +`SizeClassCalculator.Compute` (C#) and `computeSizeClass` (TypeScript) own the conversion from a real size, +with a 600dip default breakpoint per axis. It lives in the engine, shared by every renderer, so a React +renderer and a future native one agree exactly on when a boundary is crossed. + +## Layouts and screen templates + +A [screen template](screen-templates.md) has the same shape — slots plus an arrangement — and is evaluated by +the same engine. The difference is role and one field: a screen template also declares which of its parent's +slots it fills, and an application has many of them, where it has one layout. diff --git a/Documentation/blueprints/screen-templates.md b/Documentation/blueprints/screen-templates.md new file mode 100644 index 0000000..9e233c4 --- /dev/null +++ b/Documentation/blueprints/screen-templates.md @@ -0,0 +1,90 @@ +# Screen and dialog templates + +A [layout](layouts.md) is the shell. A screen template is a reusable shape that goes *inside* it. + +```csharp +public record ScreenTemplate( + string Name, + string? FitsSlot, + IReadOnlyList Slots, + Arrangement? Arrangement = null, + IReadOnlyDictionary>? Content = null, + string? DisplayName = null, + string? Description = null); +``` + +An application has one layout and many screen templates — typically one per module, feature or slice that +needs a shape of its own. + +## `FitsSlot` is what makes them nest + +A template states where it belongs. It is not told by whatever happens to host it: + +``` +AppShell (layout) + slots: topbar, sidebar, content, footer + │ + └── ModuleWorkspace (screen template) + fitsSlot: "content" + slots: moduleNav, moduleContent + │ + └── FeatureList (screen template) + fitsSlot: "moduleContent" + slots: list, details + │ + └── SliceDetail (screen template) + fitsSlot: "details" +``` + +A module's template fits a slot on the application layout. A feature's template fits a slot the module's +template declares. A slice's fits one the feature's declares. **The same rule at every level** — there is no +separate mechanism for "module-level" versus "feature-level" nesting, and no depth limit falls out of the +design. + +`FitsSlot` is nullable for a template placed explicitly rather than by declaration. + +## Slots and content + +`Slots` are what this template offers to whatever it contains — the next level down. `Content` is what the +template brings with it: the chrome that is part of the template rather than part of any screen based on it. +A template with an empty `Content` is purely structural. + +That split is what makes a template reusable. Two features can share `FeatureList` and get the same +structure, header and toolbar, while each supplies its own list and detail content. + +## Screens + +A screen is the instance: + +```csharp +public record Screen( + string Name, + string Layout, + IReadOnlyDictionary> SlotContent, + IReadOnlyList
Forms, + IReadOnlyList Contributions, + string? ScreenTemplate = null); +``` + +`Layout` names the application shell the screen ultimately renders inside. `ScreenTemplate` names the +template it fills, or is null when the screen fills the layout's slots directly. The template's `FitsSlot` is +what decides *where* it lands — so a screen never has to state its own position, and moving a template moves +every screen based on it. + +## Dialog templates + +```csharp +public record DialogTemplate( + string Name, + IReadOnlyList Slots, + Arrangement? Arrangement = null, + IReadOnlyDictionary>? Content = null, + string? DisplayName = null, + string? Description = null); +``` + +Identical, minus `FitsSlot`. A dialog occupies no slot: it opens over the application, summoned by something, +rather than being placed by a containing layout. + +Everything else is the same on purpose. A confirmation dialog and a detail screen are both "slots with an +arrangement, filled with content", and there is no reason for an author to learn that twice. diff --git a/Documentation/blueprints/toc.yml b/Documentation/blueprints/toc.yml new file mode 100644 index 0000000..9f3a87d --- /dev/null +++ b/Documentation/blueprints/toc.yml @@ -0,0 +1,6 @@ +- name: Overview + href: index.md +- name: Layouts + href: layouts.md +- name: Screen and dialog templates + href: screen-templates.md diff --git a/Documentation/index.md b/Documentation/index.md new file mode 100644 index 0000000..d06f029 --- /dev/null +++ b/Documentation/index.md @@ -0,0 +1,51 @@ +# Scene + +Scene is the object model and runtime for describing a user interface without describing a platform. + +A `.play` document says an application has an invoice list screen with a table of invoices and an action that +opens a form. It does not say whether that renders as PrimeReact on the web, SwiftUI on a phone, or something +that does not exist yet. Scene is where that description becomes a structure a renderer can execute — and +where the decisions that make it concrete (which component library, which visual theme, which application +shell) are made explicitly rather than assumed. + +## The three halves + +Scene is deliberately split so that nothing platform-specific can leak into the model: + +| Part | Stack | Responsibility | +|---|---|---| +| `Cratis.Scene.Model` / `@cratis/scene.model` | C# (source of truth) + a TypeScript mirror | The object model. Records only — screens, layouts, templates, forms, contribution points, profiles, themes, packages. No React, no DOM, no CSS vocabulary anywhere. | +| `Cratis.Scene.Engine` / `@cratis/scene.engine` | C# + TypeScript | The algorithms over that model — package and component-name resolution, dependency ordering, layout evaluation, size classes, contribution aggregation, theme compatibility. Both implementations assert against a shared fixture corpus so they cannot drift. | +| `@cratis/scene.react` | TypeScript | One renderer. It implements the engine's renderer contract against real React and DOM, so `Scene.Native` and `Scene.Desktop` are sibling positions rather than special cases. | + +Stage executes the model to ship an application. Studio edits it. Neither is part of Scene. + +## Vocabulary + +Four words carry most of the weight, and they are easy to confuse. They are not interchangeable: + +- **[Layout](blueprints/layouts.md)** — an application's base navigational look: the shell with its top bar, + navigation and content region. An application has **one**, and selects it. +- **[Screen template](blueprints/screen-templates.md)** — a reusable shape that goes *inside* that layout, at + module, feature or slice level. An application has **many**. Each declares which of its parent's slots it + fills, so templates nest arbitrarily deep by one rule rather than several. +- **Dialog template** — the same idea for content that opens *over* an application rather than sitting inside + it. It fills no slot, because it occupies none. +- **Screen** — an instance. It names the structure it fills and provides the content that fills it. + +## Packages + +A `ui profile` lists packages by name and resolves component names against them in priority order. A +[package](packages/index.md) is the declaration behind such a name: what it contributes, and what else has to +be active for it to work. + +- A **component library** declares component names — PrimeReact, Cratis Components, and the built-in `core` + fallback. +- A **styling** package contributes a CSS system rather than components; component libraries depend on it to + say what they are written against. +- A **blueprint** ships the shape of an application: its layouts, the screen and dialog templates built on + them, and the components that fill their slots. An application selects one blueprint and gets a coherent + set, rather than assembling parts from unrelated sources. + +Dependencies between packages are declared, resolved and ordered, so "Cratis Components needs PrimeReact and +Tailwind" is a fact the tooling can check rather than a note in a README. diff --git a/Documentation/toc.yml b/Documentation/toc.yml new file mode 100644 index 0000000..bde22a7 --- /dev/null +++ b/Documentation/toc.yml @@ -0,0 +1,7 @@ +- name: Documentation + href: index.md + items: + - name: Packages + href: packages/toc.yml + - name: Blueprints + href: blueprints/toc.yml diff --git a/Source/DotNET/Engine.Specs/for_PackageDependencyResolver/PackageFixtures.cs b/Source/DotNET/Engine.Specs/for_PackageDependencyResolver/PackageFixtures.cs index eee2b08..c559956 100644 --- a/Source/DotNET/Engine.Specs/for_PackageDependencyResolver/PackageFixtures.cs +++ b/Source/DotNET/Engine.Specs/for_PackageDependencyResolver/PackageFixtures.cs @@ -51,6 +51,8 @@ static ScenePackage ToPackage(JsonElement element) => [.. element.GetProperty("dependencies").EnumerateArray().Select(ToDependency)], Strings(element, "components"), Strings(element, "layouts"), + Strings(element, "screenTemplates"), + Strings(element, "dialogTemplates"), Strings(element, "themes")); static PackageDependency ToDependency(JsonElement element) => diff --git a/Source/DotNET/Engine.Specs/for_ScreenTemplateResolver/when_resolving_against_the_shared_fixture_corpus.cs b/Source/DotNET/Engine.Specs/for_ScreenTemplateResolver/when_resolving_against_the_shared_fixture_corpus.cs new file mode 100644 index 0000000..b59fa8c --- /dev/null +++ b/Source/DotNET/Engine.Specs/for_ScreenTemplateResolver/when_resolving_against_the_shared_fixture_corpus.cs @@ -0,0 +1,101 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using Cratis.Scene.Engine.Screens; +using Cratis.Scene.Model.Layouts; +using Cratis.Scene.Model.Screens; + +namespace Cratis.Scene.Engine.for_ScreenTemplateResolver; + +public class when_resolving_against_the_shared_fixture_corpus : Specification +{ + record FixtureCase( + string Name, + Layout Layout, + IReadOnlyList Templates, + IReadOnlyList ExpectedPlacements, + IReadOnlyList ExpectedUnplaced, + IReadOnlyList ExpectedCycles); + + List _cases = null!; + List<(FixtureCase Case, ScreenTemplateResolution Resolution)> _results = null!; + + void Establish() + { + using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(FindRepositoryRoot(), "screen-template-fixtures.json"))); + _cases = [.. document.RootElement.GetProperty("cases").EnumerateArray().Select(ToFixtureCase)]; + } + + void Because() => _results = [.. _cases.Select(fixtureCase => (fixtureCase, ScreenTemplateResolver.Resolve(fixtureCase.Layout, fixtureCase.Templates)))]; + + [Fact] + void should_place_every_template_where_the_corpus_expects() => + AssertEach( + resolution => [.. resolution.Placements.Select(placement => $"{placement.Template}@{placement.Slot}->{placement.Container}#{placement.Depth}")], + fixtureCase => fixtureCase.ExpectedPlacements); + + [Fact] + void should_report_the_expected_unplaced_templates() => + AssertEach( + resolution => [.. resolution.Unplaced.Select(unplaced => $"{unplaced.Template}@{unplaced.Slot}?{string.Join('|', unplaced.Candidates)}")], + fixtureCase => fixtureCase.ExpectedUnplaced); + + [Fact] + void should_report_the_expected_cycles() => + AssertEach( + resolution => [.. resolution.Cycles.Select(cycle => string.Join('>', cycle))], + fixtureCase => fixtureCase.ExpectedCycles); + + [Fact] + void should_consider_a_case_with_nothing_wrong_valid() + { + foreach (var (fixtureCase, resolution) in _results) + { + var expectedValid = fixtureCase.ExpectedUnplaced.Count == 0 && fixtureCase.ExpectedCycles.Count == 0; + (fixtureCase.Name, resolution.IsValid).ShouldEqual((fixtureCase.Name, expectedValid)); + } + } + + void AssertEach(Func> actual, Func> expected) + { + foreach (var (fixtureCase, resolution) in _results) + { + (fixtureCase.Name, string.Join(',', actual(resolution))).ShouldEqual((fixtureCase.Name, string.Join(',', expected(fixtureCase)))); + } + } + + static FixtureCase ToFixtureCase(JsonElement element) => + new( + element.GetProperty("name").GetString()!, + ToLayout(element.GetProperty("layout")), + [.. element.GetProperty("templates").EnumerateArray().Select(ToTemplate)], + [.. element.GetProperty("expectedPlacements").EnumerateArray().Select(placement => + $"{placement.GetProperty("template").GetString()}@{placement.GetProperty("slot").GetString()}->{placement.GetProperty("container").GetString()}#{placement.GetProperty("depth").GetInt32()}")], + [.. element.GetProperty("expectedUnplaced").EnumerateArray().Select(unplaced => + $"{unplaced.GetProperty("template").GetString()}@{unplaced.GetProperty("slot").GetString()}?{string.Join('|', unplaced.GetProperty("candidates").EnumerateArray().Select(candidate => candidate.GetString()))}")], + [.. element.GetProperty("expectedCycles").EnumerateArray().Select(cycle => string.Join('>', cycle.EnumerateArray().Select(name => name.GetString()!)))]); + + static Layout ToLayout(JsonElement element) => + new(element.GetProperty("name").GetString()!, [.. Slots(element)]); + + static ScreenTemplate ToTemplate(JsonElement element) => + new( + element.GetProperty("name").GetString()!, + element.GetProperty("fitsSlot").ValueKind == JsonValueKind.Null ? null : element.GetProperty("fitsSlot").GetString(), + [.. Slots(element)]); + + static IEnumerable Slots(JsonElement element) => + element.GetProperty("slots").EnumerateArray().Select(slot => new Slot(slot.GetString()!)); + + static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Scene.slnx"))) + { + directory = directory.Parent; + } + + return directory?.FullName ?? throw new DirectoryNotFoundException("Could not locate the repository root (Scene.slnx) above " + AppContext.BaseDirectory); + } +} diff --git a/Source/DotNET/Engine/Screens/ScreenTemplatePlacement.cs b/Source/DotNET/Engine/Screens/ScreenTemplatePlacement.cs new file mode 100644 index 0000000..7d909ba --- /dev/null +++ b/Source/DotNET/Engine/Screens/ScreenTemplatePlacement.cs @@ -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. + +namespace Cratis.Scene.Engine.Screens; + +/// +/// Where one ended up: the slot it declared, and the layout or +/// template that turned out to declare that slot. +/// +/// The template being placed. +/// The slot name the template declared it fits. +/// The name of the or declaring that slot. +/// How far below the layout the template sits - 1 directly inside the layout, 2 inside a template that is, and so on. +public record ScreenTemplatePlacement(string Template, string Slot, string Container, int Depth); diff --git a/Source/DotNET/Engine/Screens/ScreenTemplateResolution.cs b/Source/DotNET/Engine/Screens/ScreenTemplateResolution.cs new file mode 100644 index 0000000..8c73046 --- /dev/null +++ b/Source/DotNET/Engine/Screens/ScreenTemplateResolution.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.Engine.Screens; + +/// +/// The outcome of working out how a blueprint's templates nest inside its layout. +/// +/// Every template that found exactly one home, ordered shallowest first so a caller can build the tree top-down. +/// +/// Templates that found none or several. Reported rather than guessed: placing a template in the wrong parent +/// renders content in the wrong region, which is far harder to diagnose than being told the slot name is +/// ambiguous. +/// +/// +/// Template nesting cycles - a template that transitively contains itself. Its members are reported and left +/// out of , since no depth can be assigned to them. +/// +public record ScreenTemplateResolution( + IReadOnlyList Placements, + IReadOnlyList Unplaced, + IReadOnlyList> Cycles) +{ + /// + /// Whether every template found exactly one home and nothing nests inside itself. + /// + public bool IsValid => Unplaced.Count == 0 && Cycles.Count == 0; +} diff --git a/Source/DotNET/Engine/Screens/ScreenTemplateResolver.cs b/Source/DotNET/Engine/Screens/ScreenTemplateResolver.cs new file mode 100644 index 0000000..d969dd3 --- /dev/null +++ b/Source/DotNET/Engine/Screens/ScreenTemplateResolver.cs @@ -0,0 +1,185 @@ +// 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.Layouts; +using Cratis.Scene.Model.Screens; + +namespace Cratis.Scene.Engine.Screens; + +/// +/// Works out how a blueprint's s nest inside its . +/// +/// +/// +/// A template declares only the *name* of the slot it fits, never which container owns that slot. That is +/// what makes templates reusable - a feature's template says "I go in the module content area", not "I go +/// inside this specific module". Resolution is the step that turns those names into a tree, by finding which +/// layout or template declares each name. +/// +/// +/// The same rule applies at every level, so nesting has no depth limit and no separate mechanism per level. +/// The TypeScript twin in @cratis/scene.engine implements the same algorithm; both are asserted +/// against the same shared fixture corpus so they cannot drift apart. +/// +/// +public static class ScreenTemplateResolver +{ + /// + /// Resolves a set of templates against the layout they live in. + /// + /// The application layout the templates ultimately sit inside. + /// The templates to place. + /// The , carrying the placements and everything that could not be placed. + public static ScreenTemplateResolution Resolve(Layout layout, IReadOnlyList templates) + { + var containersBySlot = BuildSlotIndex(layout, templates); + var parents = new Dictionary(StringComparer.Ordinal); + var slots = new Dictionary(StringComparer.Ordinal); + var unplaced = new List(); + + foreach (var template in templates) + { + if (template.FitsSlot is null) + { + continue; + } + + var candidates = containersBySlot.TryGetValue(template.FitsSlot, out var found) + ? found.Where(container => container != template.Name).ToList() + : []; + + if (candidates.Count == 1) + { + parents[template.Name] = candidates[0]; + slots[template.Name] = template.FitsSlot; + } + else + { + unplaced.Add(new UnplacedScreenTemplate(template.Name, template.FitsSlot, candidates)); + } + } + + var cycles = FindCycles(parents); + var inCycle = new HashSet(cycles.SelectMany(cycle => cycle), StringComparer.Ordinal); + + var placements = new List(); + foreach (var template in templates) + { + if (!parents.TryGetValue(template.Name, out var container) || inCycle.Contains(template.Name)) + { + continue; + } + + placements.Add(new ScreenTemplatePlacement(template.Name, slots[template.Name], container, DepthOf(template.Name, parents, layout.Name))); + } + + return new ScreenTemplateResolution( + [.. placements.OrderBy(placement => placement.Depth).ThenBy(placement => placement.Template, StringComparer.Ordinal)], + unplaced, + cycles); + } + + /// + /// Indexes every slot name to the layout and templates declaring it, so a template's can be looked up. + /// + /// The application layout. + /// The templates in scope. + /// The declaring container names, keyed by slot name, in layout-then-template order. + static Dictionary> BuildSlotIndex(Layout layout, IReadOnlyList templates) + { + var index = new Dictionary>(StringComparer.Ordinal); + + void Declare(string slot, string container) + { + if (!index.TryGetValue(slot, out var containers)) + { + containers = []; + index[slot] = containers; + } + + if (!containers.Contains(container)) + { + containers.Add(container); + } + } + + foreach (var slot in layout.Slots) + { + Declare(slot.Name, layout.Name); + } + + foreach (var template in templates) + { + foreach (var slot in template.Slots) + { + Declare(slot.Name, template.Name); + } + } + + return index; + } + + /// + /// Finds template nesting cycles - a template that transitively contains itself. + /// + /// Each template's resolved container, keyed by template name. + /// Each cycle found, as the templates taking part, recorded once. + static List> FindCycles(Dictionary parents) + { + var cycles = new List>(); + var recorded = new HashSet(StringComparer.Ordinal); + + foreach (var start in parents.Keys.Order(StringComparer.Ordinal)) + { + var path = new List(); + var onPath = new HashSet(StringComparer.Ordinal); + var current = start; + + while (parents.TryGetValue(current, out var parent)) + { + path.Add(current); + onPath.Add(current); + if (!onPath.Add(parent)) + { + var cycle = path[path.IndexOf(parent)..]; + var key = string.Join(' ', cycle.Order(StringComparer.Ordinal)); + if (recorded.Add(key)) + { + cycles.Add(cycle); + } + + break; + } + + current = parent; + } + } + + return cycles; + } + + /// + /// Counts how far below the layout a template sits. + /// + /// The template to measure. + /// Each template's resolved container, keyed by template name. + /// The layout's name, which terminates the walk. + /// 1 for a template directly inside the layout, 2 for one inside such a template, and so on. + static int DepthOf(string template, Dictionary parents, string layoutName) + { + var depth = 0; + var current = template; + while (parents.TryGetValue(current, out var parent) && depth <= parents.Count) + { + depth++; + if (parent == layoutName) + { + break; + } + + current = parent; + } + + return depth; + } +} diff --git a/Source/DotNET/Engine/Screens/UnplacedScreenTemplate.cs b/Source/DotNET/Engine/Screens/UnplacedScreenTemplate.cs new file mode 100644 index 0000000..7f02059 --- /dev/null +++ b/Source/DotNET/Engine/Screens/UnplacedScreenTemplate.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.Engine.Screens; + +/// +/// A template that names a slot, where the set of containers in scope does not agree on exactly one home +/// for it. +/// +/// The template that could not be placed. +/// The slot name it declared it fits. +/// +/// The containers declaring a slot of that name. Empty when nothing declares it; more than one when the name +/// is ambiguous. Never exactly one - that case is a instead. +/// +public record UnplacedScreenTemplate(string Template, string Slot, IReadOnlyList Candidates); diff --git a/Source/DotNET/Model/Layouts/Layout.cs b/Source/DotNET/Model/Layouts/Layout.cs index ad44a57..1c17312 100644 --- a/Source/DotNET/Model/Layouts/Layout.cs +++ b/Source/DotNET/Model/Layouts/Layout.cs @@ -4,10 +4,17 @@ namespace Cratis.Scene.Model.Layouts; /// -/// A named template of slots that a fills with content. Mirrors Screenplay's +/// An application's base navigational look - the shell its screens render inside. Mirrors Screenplay's /// layout construct: a bare layout with plain slots is a special case of a layout whose slots all /// use the default arrangement. /// +/// +/// A layout is application-level and there is one in force: it is what an application *selects*, usually +/// from a package. What goes inside it - the shapes a module, +/// feature or slice brings - are s, and an application has many. The +/// two are structurally alike on purpose (both are slots plus an arrangement, evaluated by the same +/// engine); they differ in role, and a screen template additionally declares which slot it fits into. +/// /// The layout's name. /// The slots the layout declares, in declaration order. /// diff --git a/Source/DotNET/Model/Packages/PackageKind.cs b/Source/DotNET/Model/Packages/PackageKind.cs index b981d7d..c3209eb 100644 --- a/Source/DotNET/Model/Packages/PackageKind.cs +++ b/Source/DotNET/Model/Packages/PackageKind.cs @@ -25,8 +25,15 @@ public enum PackageKind Styling = 1, /// - /// Provides ready-made s and the shell components that fill their slots. - /// A layout package depends on the component libraries its shell is built from. + /// Provides the shape of an application: its s, the + /// s and s built on them, and + /// the components that fill their slots. An application selects one blueprint, and gets a coherent + /// set rather than assembling layouts and templates from unrelated sources. /// - Layout = 2 + /// + /// A blueprint depends on the component libraries it is built from - the default blueprint is written + /// against PrimeReact and Cratis Components, and says so. That is what makes "which blueprints can I + /// use" answerable from the packages a profile already has. + /// + Blueprint = 2 } diff --git a/Source/DotNET/Model/Packages/ScenePackage.cs b/Source/DotNET/Model/Packages/ScenePackage.cs index 33208d4..a86db6a 100644 --- a/Source/DotNET/Model/Packages/ScenePackage.cs +++ b/Source/DotNET/Model/Packages/ScenePackage.cs @@ -13,7 +13,9 @@ namespace Cratis.Scene.Model.Packages; /// What the package contributes. /// Other packages that must be active in the same profile for this one to work. /// The component names this package declares - the catalog entry resolution walks. -/// The names of the s this package provides, empty for a package that provides none. +/// The names of the s this package provides - an application's base navigational shells. Empty for anything that is not a . +/// The names of the s this package provides - the shapes that go inside a layout, at module, feature and slice level. +/// The names of the s this package provides. /// The names of the s this package ships, empty for a package that ships none. /// A human-readable name for a package picker, falling back to when absent. /// A one-line description for a package picker. @@ -28,6 +30,8 @@ public record ScenePackage( IReadOnlyList Dependencies, IReadOnlyList Components, IReadOnlyList Layouts, + IReadOnlyList ScreenTemplates, + IReadOnlyList DialogTemplates, IReadOnlyList Themes, string? DisplayName = null, string? Description = null, diff --git a/Source/DotNET/Model/Screens/DialogTemplate.cs b/Source/DotNET/Model/Screens/DialogTemplate.cs new file mode 100644 index 0000000..bd3c45b --- /dev/null +++ b/Source/DotNET/Model/Screens/DialogTemplate.cs @@ -0,0 +1,34 @@ +// 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.Elements; +using Cratis.Scene.Model.Layouts; + +namespace Cratis.Scene.Model.Screens; + +/// +/// A reusable dialog structure - the same idea as a , for content that opens +/// over an application rather than sitting inside it. +/// +/// +/// A dialog has no because it occupies no slot: it is an overlay, +/// summoned by something rather than placed by a containing layout. Everything else is the same, which is +/// deliberate - a confirmation dialog and a detail screen are both "slots with an arrangement, filled with +/// content", and there is no reason for an author to learn that twice. +/// +/// The template's name. +/// The slots this template offers to whatever it contains, in declaration order. +/// +/// How this template's own position relative to each other, or +/// for declaration order with no further positioning. +/// +/// Content the template itself provides, keyed by slot name - a dialog's own chrome, such as its header and button bar. +/// A human-readable name for a template picker, falling back to . +/// A one-line description for a template picker. +public record DialogTemplate( + string Name, + IReadOnlyList Slots, + Arrangement? Arrangement = null, + IReadOnlyDictionary>? Content = null, + string? DisplayName = null, + string? Description = null); diff --git a/Source/DotNET/Model/Screens/Screen.cs b/Source/DotNET/Model/Screens/Screen.cs index 1ffc9b2..05dd492 100644 --- a/Source/DotNET/Model/Screens/Screen.cs +++ b/Source/DotNET/Model/Screens/Screen.cs @@ -8,17 +8,30 @@ namespace Cratis.Scene.Model.Screens; /// -/// A named screen: a layout, the content that fills its slots, the forms it hosts, and whatever it +/// A named screen: the structure it fills, the content that fills it, the forms it hosts, and whatever it /// contributes to contribution points elsewhere in the tree. /// +/// +/// A screen is an instance, not a shape. The shape comes from either the application's +/// - for a screen that sits directly in the application shell - or a +/// , for one nested inside a module, feature or slice. Both declare slots; a +/// screen only ever fills them. +/// /// The screen's name. -/// The resolved name of the this screen uses. -/// The content filling each of the layout's slots, keyed by slot name. +/// The resolved name of the application this screen ultimately renders inside. +/// The content filling each slot, keyed by slot name. /// The forms this screen hosts. /// What this screen contributes to contribution points elsewhere in the tree. +/// +/// The resolved name of the this screen fills, or +/// when it fills the 's own slots directly. The template's +/// is what decides where it lands, so a screen never has to +/// state its own position. +/// public record Screen( string Name, string Layout, IReadOnlyDictionary> SlotContent, IReadOnlyList Forms, - IReadOnlyList Contributions); + IReadOnlyList Contributions, + string? ScreenTemplate = null); diff --git a/Source/DotNET/Model/Screens/ScreenTemplate.cs b/Source/DotNET/Model/Screens/ScreenTemplate.cs new file mode 100644 index 0000000..f5a0fb1 --- /dev/null +++ b/Source/DotNET/Model/Screens/ScreenTemplate.cs @@ -0,0 +1,54 @@ +// 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.Elements; +using Cratis.Scene.Model.Layouts; + +namespace Cratis.Scene.Model.Screens; + +/// +/// A reusable screen structure that fills a named slot on whatever contains it, and offers slots of its +/// own for what it contains in turn. +/// +/// +/// +/// A and a screen template are deliberately different things. A layout is the +/// application's base navigational look - the shell with its top bar, navigation and content region - +/// and an application has one. A screen template is what goes *inside* that shell, and an application has +/// many: one per module, feature or slice that needs a shape of its own. +/// +/// +/// is what makes them compose. A module's screen template fits the application +/// layout's content slot; a feature's screen template fits a slot the module's template declares; a +/// slice's fits one the feature's declares. The same rule at every level, so nesting is arbitrarily deep +/// without a second mechanism - and a template always states where it belongs rather than being told by +/// whatever happens to host it. +/// +/// +/// The template's name, which a refers to. +/// +/// The name of the slot on the containing or screen template this one fills, or +/// for a template that is placed explicitly rather than by declaration. +/// +/// The slots this template offers to whatever it contains, in declaration order. +/// +/// How this template's own position relative to each other - a +/// (leaves are ) or a +/// , or for declaration order with no further +/// positioning. The same shape a uses, evaluated by the same engine. +/// +/// +/// Content the template itself provides, keyed by slot name - the chrome a template brings with it, as +/// opposed to what a based on it fills in. Empty for a template that is purely +/// structural. +/// +/// A human-readable name for a template picker, falling back to . +/// A one-line description for a template picker. +public record ScreenTemplate( + string Name, + string? FitsSlot, + IReadOnlyList Slots, + Arrangement? Arrangement = null, + IReadOnlyDictionary>? Content = null, + string? DisplayName = null, + string? Description = null); diff --git a/Source/JavaScript/components/.storybook/main.experiment.txt b/Source/JavaScript/components/.storybook/main.experiment.txt new file mode 100644 index 0000000..e69de29 diff --git a/Source/JavaScript/components/.storybook/main.ts b/Source/JavaScript/components/.storybook/main.ts new file mode 100644 index 0000000..51a0974 --- /dev/null +++ b/Source/JavaScript/components/.storybook/main.ts @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { StorybookConfig } from "@storybook/react-vite"; +import { dirname, join, resolve } from "path"; +import { fileURLToPath } from "url"; +import type { InlineConfig } from 'vite'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const config: StorybookConfig = { + // Scoped away from `dist/` so a story is never indexed twice - once as source and once as the + // tsc-emitted copy beside it. + stories: ["../*.stories.@(js|jsx|mjs|ts|tsx)", "../!(dist|node_modules)/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + addons: [getAbsolutePath("@storybook/addon-links")], + framework: { name: getAbsolutePath("@storybook/react-vite"), options: {} }, + async viteFinal(config: InlineConfig) { + config.resolve = config.resolve || {}; + config.resolve.alias = { + ...config.resolve.alias, + '@cratis/scene.engine': resolve(__dirname, '../../engine/index.ts'), + '@cratis/scene.model': resolve(__dirname, '../../model/index.ts'), + '@cratis/scene.react': resolve(__dirname, '../../react/index.ts'), + }; + config.build = config.build || {}; + config.build.rollupOptions = { ...(config.build.rollupOptions ?? {}), external: [/^@cratis\/arc/, '@cratis/fundamentals'] }; + return config; + }, +}; +export default config; + +function getAbsolutePath(value: string): string { + return dirname(fileURLToPath(import.meta.resolve(join(value, "package.json")))); +} diff --git a/Source/JavaScript/components/bindings/ArcRuntimeBoundary.tsx b/Source/JavaScript/components/bindings/ArcRuntimeBoundary.tsx new file mode 100644 index 0000000..ec8d80f --- /dev/null +++ b/Source/JavaScript/components/bindings/ArcRuntimeBoundary.tsx @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ReactNode, Suspense } from 'react'; +import { ErrorBoundary } from '@cratis/components/Common'; + +export interface ArcRuntimeBoundaryProps { + /** The lazily loaded, Arc-bound content this boundary isolates. */ + children: ReactNode; +} + +/** + * Isolates one Arc-bound component so that neither loading it nor failing to load it can take the + * surrounding screen down with it. + * + * The Arc-bound half of `@cratis/components` - `DataPage`, the data tables, `CommandForm` and its + * fields, every dialog - reaches `@cratis/arc` and `@cratis/arc.react` at import time. Those are peer + * dependencies the *host* supplies, and a design surface is not a host: Studio previews a screen without + * an Arc client, without a backend, and often without a single binding registered. This package + * therefore imports every Arc-bound component through a dynamic `import()` rather than a static one, so + * that a screen made only of the library's Arc-free components never pulls the Arc runtime in at all. + * + * That deferral has two visible states, and this covers both: `Suspense` while the chunk is in flight, + * and the library's own `ErrorBoundary` when it cannot be loaded - which is what a host without Arc + * installed will see. One dashed-out region, not a blank screen and not a thrown render. + * + * `ErrorBoundary` is used by composition rather than by writing another one: an error boundary is the + * one thing React still requires a class for, and `@cratis/components` already ships that class. + */ +export function ArcRuntimeBoundary({ children }: ArcRuntimeBoundaryProps) { + return ( + + Loading}>{children} + + ); +} diff --git a/Source/JavaScript/components/bindings/BindingKind.ts b/Source/JavaScript/components/bindings/BindingKind.ts new file mode 100644 index 0000000..c6585b5 --- /dev/null +++ b/Source/JavaScript/components/bindings/BindingKind.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * What a name in a screen is expected to resolve to when it is looked up in the binding registry. + * + * Queries and commands are kept apart rather than sharing one namespace because they are opposite halves + * of CQRS and a screen means exactly one of them at each site: `data Invoices via query AllInvoices` + * cannot be satisfied by a command of the same name, and letting it be satisfied would turn a modeling + * mistake into a runtime one. + */ +export enum BindingKind { + /** An Arc query proxy - what a data table or data page reads its rows from. */ + Query = 'query', + + /** An Arc command proxy - what a command form or command dialog submits. */ + Command = 'command', +} diff --git a/Source/JavaScript/components/bindings/BoundConstructor.ts b/Source/JavaScript/components/bindings/BoundConstructor.ts new file mode 100644 index 0000000..e97c1c8 --- /dev/null +++ b/Source/JavaScript/components/bindings/BoundConstructor.ts @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * A class a host registers under a name - an Arc query or command proxy, as generated from the backend's + * `[ReadModel]` and `[Command]` types. + * + * `@cratis/components` types these as `Constructor` from `@cratis/fundamentals`, constrained to + * `IQueryFor` / a command shape. This package cannot use those types: `@cratis/arc` and + * `@cratis/fundamentals` are peer dependencies of `@cratis/components` that the *host* provides, and + * Scene deliberately does not depend on Arc - a Scene screen is a UI model, and the whole point of the + * registry is that Scene never needs to know what an Arc proxy is. + * + * So the registry stores the widest honest shape: something that can be constructed. Whether a + * registered class really is a query or a command is the host's responsibility, and it is checkable + * where the host registers it, with the real Arc types in scope - which is exactly where it should be. + * + * `never[]` rather than `unknown[]` for the parameters so that any constructor is assignable, regardless + * of what it takes; `unknown[]` would only accept constructors whose parameters accept anything. + */ +export type BoundConstructor = new (...args: never[]) => object; diff --git a/Source/JavaScript/components/bindings/ElementBinding.ts b/Source/JavaScript/components/bindings/ElementBinding.ts new file mode 100644 index 0000000..28e0012 --- /dev/null +++ b/Source/JavaScript/components/bindings/ElementBinding.ts @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { BoundConstructor } from './BoundConstructor'; + +/** + * What a screen asked for at a binding site, and what the registry could actually produce for it. + * + * The name is kept even when nothing resolved, because the two failure modes an adapter has to + * distinguish - "the screen named something that is not registered" and "the screen named nothing" - + * are only tellable apart by whether a name is present. + */ +export interface ElementBinding { + /** The name the screen wrote, or `undefined` when the screen set no binding property at all. */ + name?: string; + + /** The registered class, or `undefined` when nothing is registered under {@link name}. */ + target?: BoundConstructor; +} diff --git a/Source/JavaScript/components/bindings/MissingBinding.tsx b/Source/JavaScript/components/bindings/MissingBinding.tsx new file mode 100644 index 0000000..b1fad65 --- /dev/null +++ b/Source/JavaScript/components/bindings/MissingBinding.tsx @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ExternalComponent } from '@cratis/scene.model'; +import { BindingKind } from './BindingKind'; +import { Placeholder } from './Placeholder'; + +export interface MissingBindingProps { + /** The element whose binding could not be satisfied. */ + element: ExternalComponent; + + /** Whether a query or a command was wanted. */ + kind: BindingKind; + + /** + * The name the screen asked for, or `undefined` when the screen never named one. The two are + * different mistakes and get different messages - a screen that names `AllInvoices` needs the host + * to register it, while a screen that names nothing needs editing. + */ + name?: string; +} + +/** + * The placeholder an Arc-bound adapter renders instead of its real component when the binding it needs + * is not available. + * + * It names the binding, because that is the only actionable part: the fix is either to register that + * name in the host or to correct the name in the screen, and the message has to be enough to tell those + * two apart without opening a debugger. + */ +export function MissingBinding({ element, kind, name }: MissingBindingProps) { + const problem = name === undefined ? `Missing ${kind} binding` : `Unresolved ${kind} binding '${name}'`; + return ; +} diff --git a/Source/JavaScript/components/bindings/Placeholder.tsx b/Source/JavaScript/components/bindings/Placeholder.tsx new file mode 100644 index 0000000..a7cd610 --- /dev/null +++ b/Source/JavaScript/components/bindings/Placeholder.tsx @@ -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 { ExternalComponent } from '@cratis/scene.model'; + +export interface PlaceholderProps { + /** + * The element that could not be rendered as configured. Its `componentName` names what was being + * rendered, which is most of what makes the placeholder actionable. + */ + element: ExternalComponent; + + /** + * What is wrong, phrased as the problem rather than the remedy - `Unresolved query binding 'AllInvoices'`. + */ + problem: string; +} + +/** + * The stand-in an adapter renders when it has been given a screen it cannot honor - a binding name + * nothing is registered under, or a required property the screen never set. + * + * Deliberately the same presentation as `UnresolvedComponent` in `@cratis/scene.react`: a dashed red box + * that states the problem in monospace. They are the same class of failure seen at two different depths - + * the renderer could not find the component, or the component could not find what it needs - and a + * designer scanning a preview should recognize both instantly as "something here is not wired up". + * + * Rendering this rather than throwing is the whole point. Studio's design-time preview usually has no + * bindings registered at all, and it still has to show a usable layout: one unbound table must cost one + * dashed box, not the entire screen. + */ +export function Placeholder({ element, problem }: PlaceholderProps) { + return ( +
+ {problem} on {element.componentName} +
+ ); +} diff --git a/Source/JavaScript/components/bindings/bindingRegistry.ts b/Source/JavaScript/components/bindings/bindingRegistry.ts new file mode 100644 index 0000000..c332bd6 --- /dev/null +++ b/Source/JavaScript/components/bindings/bindingRegistry.ts @@ -0,0 +1,105 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { BoundConstructor } from './BoundConstructor'; + +const queries = new Map(); +const commands = new Map(); + +/** + * Registers an Arc query proxy under the name screens refer to it by. + * + * This is the seam that makes the Arc-bound half of `@cratis/components` usable from Scene at all. A + * screen says `data Invoices via query AllInvoices`, and by the time that reaches a renderer it is an + * `ExternalComponent` whose `properties` bag holds the *string* `'AllInvoices'` - a property bag carries + * plain values and named slots, and there is no way to put a TypeScript class into one. `DataTableForQuery` + * needs the class. The name is the only thing that survives the trip, so the name is what the lookup has + * to be keyed on, and a host that owns the generated proxies is the only party that can supply the class + * behind it. + * + * A host - Stage's generated application, or Studio's preview when it is wired to a real backend - + * registers every proxy a screen can name, once, during startup. Registering the same name twice + * replaces the earlier registration, so a host can re-register on hot reload without having to unwind + * the previous run. + */ +export function registerQuery(name: string, queryClass: BoundConstructor): void { + queries.set(name, queryClass); +} + +/** + * Registers several query proxies at once, keyed by the name screens refer to each by. + * + * Stage generates a module that exports every proxy it produced; handing that module's exports straight + * to this is the whole of a generated host's registration step, and it stays correct as proxies are + * added and removed without anyone editing a list. + */ +export function registerQueries(bindings: Record): void { + for (const [name, queryClass] of Object.entries(bindings)) { + registerQuery(name, queryClass); + } +} + +/** + * The query proxy registered under a name, or `undefined` when nothing is registered under it. + * + * `undefined` rather than a throw: design-time preview in Studio normally has nothing registered at all, + * and a screen still has to render so its layout can be worked on. Every adapter turns `undefined` into + * a visible placeholder naming the binding it wanted. + */ +export function resolveQuery(name: string): BoundConstructor | undefined { + return queries.get(name); +} + +/** + * Registers an Arc command proxy under the name screens refer to it by. The command half of + * {@link registerQuery}, with the same contract. + */ +export function registerCommand(name: string, commandClass: BoundConstructor): void { + commands.set(name, commandClass); +} + +/** + * Registers several command proxies at once, keyed by the name screens refer to each by. + */ +export function registerCommands(bindings: Record): void { + for (const [name, commandClass] of Object.entries(bindings)) { + registerCommand(name, commandClass); + } +} + +/** + * The command proxy registered under a name, or `undefined` when nothing is registered under it. + */ +export function resolveCommand(name: string): BoundConstructor | undefined { + return commands.get(name); +} + +/** + * Every registered query name, sorted. + * + * A design-time tool uses this to offer the names a screen can actually bind to, and a diagnostics + * surface uses it to explain a placeholder - "this screen wants `AllInvoices`, and here is what is + * registered" is a far more useful message than the placeholder alone. + */ +export function registeredQueryNames(): string[] { + return [...queries.keys()].sort(); +} + +/** + * Every registered command name, sorted. + */ +export function registeredCommandNames(): string[] { + return [...commands.keys()].sort(); +} + +/** + * Forgets every registered query and command. + * + * The registry is module-level state, which is right for a host that registers once at startup but wrong + * for anything that switches between applications - Studio previewing a different project, or a spec + * that must not inherit what the previous one registered. Both need a way back to empty. + */ +export function clearBindings(): void { + queries.clear(); + commands.clear(); +} diff --git a/Source/JavaScript/components/bindings/for_bindingRegistry/when_a_command_is_registered.ts b/Source/JavaScript/components/bindings/for_bindingRegistry/when_a_command_is_registered.ts new file mode 100644 index 0000000..64e2df8 --- /dev/null +++ b/Source/JavaScript/components/bindings/for_bindingRegistry/when_a_command_is_registered.ts @@ -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. + +import { clearBindings, registerCommand, registerCommands, registeredCommandNames, resolveCommand, resolveQuery } from '../bindingRegistry'; + +class RegisterInvoice {} +class ApproveInvoice {} +class RegisterInvoiceV2 {} + +describe('when a command is registered', () => { + beforeEach(() => { + clearBindings(); + registerCommand('RegisterInvoice', RegisterInvoice); + registerCommands({ ApproveInvoice }); + }); + + afterEach(() => clearBindings()); + + it('should resolve the class registered under the name', () => resolveCommand('RegisterInvoice')!.should.equal(RegisterInvoice)); + it('should resolve every class registered in bulk', () => resolveCommand('ApproveInvoice')!.should.equal(ApproveInvoice)); + it('should list every registered name in sorted order', () => registeredCommandNames().should.deep.equal(['ApproveInvoice', 'RegisterInvoice'])); + it('should not resolve the same name as a query', () => (resolveQuery('RegisterInvoice') === undefined).should.be.true); + + describe('and the same name is registered again', () => { + beforeEach(() => registerCommand('RegisterInvoice', RegisterInvoiceV2)); + + it('should resolve to the class registered last', () => resolveCommand('RegisterInvoice')!.should.equal(RegisterInvoiceV2)); + it('should not list the name twice', () => registeredCommandNames().should.deep.equal(['ApproveInvoice', 'RegisterInvoice'])); + }); +}); diff --git a/Source/JavaScript/components/bindings/for_bindingRegistry/when_a_query_is_not_registered.ts b/Source/JavaScript/components/bindings/for_bindingRegistry/when_a_query_is_not_registered.ts new file mode 100644 index 0000000..74b1be2 --- /dev/null +++ b/Source/JavaScript/components/bindings/for_bindingRegistry/when_a_query_is_not_registered.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 { clearBindings, registerQuery, resolveQuery } from '../bindingRegistry'; + +class AllInvoices {} + +describe('when a query is not registered', () => { + beforeEach(() => { + clearBindings(); + registerQuery('AllInvoices', AllInvoices); + }); + + afterEach(() => clearBindings()); + + it('should resolve to undefined rather than throwing', () => (resolveQuery('AllCustomers') === undefined).should.be.true); +}); diff --git a/Source/JavaScript/components/bindings/for_bindingRegistry/when_a_query_is_registered.ts b/Source/JavaScript/components/bindings/for_bindingRegistry/when_a_query_is_registered.ts new file mode 100644 index 0000000..4714603 --- /dev/null +++ b/Source/JavaScript/components/bindings/for_bindingRegistry/when_a_query_is_registered.ts @@ -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. + +import { clearBindings, registerQueries, registerQuery, registeredQueryNames, resolveCommand, resolveQuery } from '../bindingRegistry'; + +class AllInvoices {} +class InvoiceById {} +class OverdueInvoices {} + +describe('when a query is registered', () => { + beforeEach(() => { + clearBindings(); + registerQuery('AllInvoices', AllInvoices); + registerQueries({ InvoiceById, OverdueInvoices }); + }); + + afterEach(() => clearBindings()); + + it('should resolve the class registered under the name', () => resolveQuery('AllInvoices')!.should.equal(AllInvoices)); + + it('should resolve every class registered in bulk', () => { + resolveQuery('InvoiceById')!.should.equal(InvoiceById); + resolveQuery('OverdueInvoices')!.should.equal(OverdueInvoices); + }); + + it('should list every registered name in sorted order', () => + registeredQueryNames().should.deep.equal(['AllInvoices', 'InvoiceById', 'OverdueInvoices'])); + + it('should not resolve the same name as a command', () => (resolveCommand('AllInvoices') === undefined).should.be.true); +}); diff --git a/Source/JavaScript/components/bindings/for_bindingRegistry/when_bindings_are_cleared.ts b/Source/JavaScript/components/bindings/for_bindingRegistry/when_bindings_are_cleared.ts new file mode 100644 index 0000000..c41d2ef --- /dev/null +++ b/Source/JavaScript/components/bindings/for_bindingRegistry/when_bindings_are_cleared.ts @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { + clearBindings, + registerCommand, + registerQuery, + registeredCommandNames, + registeredQueryNames, + resolveCommand, + resolveQuery, +} from '../bindingRegistry'; + +class AllInvoices {} +class RegisterInvoice {} + +describe('when bindings are cleared', () => { + beforeEach(() => { + clearBindings(); + registerQuery('AllInvoices', AllInvoices); + registerCommand('RegisterInvoice', RegisterInvoice); + clearBindings(); + }); + + it('should no longer resolve the query', () => (resolveQuery('AllInvoices') === undefined).should.be.true); + it('should no longer resolve the command', () => (resolveCommand('RegisterInvoice') === undefined).should.be.true); + it('should list no query names', () => registeredQueryNames().should.be.empty); + it('should list no command names', () => registeredCommandNames().should.be.empty); +}); diff --git a/Source/JavaScript/components/bindings/for_resolveElementBinding/when_resolving_a_command_binding.ts b/Source/JavaScript/components/bindings/for_resolveElementBinding/when_resolving_a_command_binding.ts new file mode 100644 index 0000000..b733235 --- /dev/null +++ b/Source/JavaScript/components/bindings/for_resolveElementBinding/when_resolving_a_command_binding.ts @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { externalComponent } from '../../given'; +import { BindingKind } from '../BindingKind'; +import { clearBindings, registerCommand, registerQuery } from '../bindingRegistry'; +import { resolveElementBinding } from '../resolveElementBinding'; + +class RegisterInvoice {} +class SameNameQuery {} + +describe('when resolving a command binding', () => { + beforeEach(() => { + clearBindings(); + registerCommand('RegisterInvoice', RegisterInvoice); + registerQuery('ApproveInvoice', SameNameQuery); + }); + + afterEach(() => clearBindings()); + + describe('and the element names a registered command', () => { + const binding = () => + resolveElementBinding(externalComponent('Cratis.Components:commandForm', { command: 'RegisterInvoice' }), BindingKind.Command); + + it('should carry the registered class', () => binding().target!.should.equal(RegisterInvoice)); + }); + + describe('and a query happens to be registered under the same name', () => { + const binding = () => + resolveElementBinding(externalComponent('Cratis.Components:commandForm', { command: 'ApproveInvoice' }), BindingKind.Command); + + it('should not satisfy a command binding from the query registry', () => (binding().target === undefined).should.be.true); + }); +}); diff --git a/Source/JavaScript/components/bindings/for_resolveElementBinding/when_resolving_a_query_binding.ts b/Source/JavaScript/components/bindings/for_resolveElementBinding/when_resolving_a_query_binding.ts new file mode 100644 index 0000000..581e169 --- /dev/null +++ b/Source/JavaScript/components/bindings/for_resolveElementBinding/when_resolving_a_query_binding.ts @@ -0,0 +1,45 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { externalComponent } from '../../given'; +import { BindingKind } from '../BindingKind'; +import { clearBindings, registerQuery } from '../bindingRegistry'; +import { resolveElementBinding } from '../resolveElementBinding'; + +class AllInvoices {} + +describe('when resolving a query binding', () => { + beforeEach(() => { + clearBindings(); + registerQuery('AllInvoices', AllInvoices); + }); + + afterEach(() => clearBindings()); + + describe('and the element names a registered query', () => { + const binding = () => resolveElementBinding(externalComponent('Cratis.Components:dataTable', { query: 'AllInvoices' }), BindingKind.Query); + + it('should carry the name the element asked for', () => binding().name!.should.equal('AllInvoices')); + it('should carry the registered class', () => binding().target!.should.equal(AllInvoices)); + }); + + describe('and the element names a query nothing is registered under', () => { + const binding = () => resolveElementBinding(externalComponent('Cratis.Components:dataTable', { query: 'AllCustomers' }), BindingKind.Query); + + it('should still carry the name, so the placeholder can report it', () => binding().name!.should.equal('AllCustomers')); + it('should carry no class', () => (binding().target === undefined).should.be.true); + }); + + describe('and the element names no query at all', () => { + const binding = () => resolveElementBinding(externalComponent('Cratis.Components:dataTable'), BindingKind.Query); + + it('should carry no name', () => (binding().name === undefined).should.be.true); + it('should carry no class', () => (binding().target === undefined).should.be.true); + }); + + describe('and the element carries the name under the command property instead', () => { + const binding = () => resolveElementBinding(externalComponent('Cratis.Components:dataTable', { command: 'AllInvoices' }), BindingKind.Query); + + it('should not read a command name as a query name', () => (binding().name === undefined).should.be.true); + }); +}); diff --git a/Source/JavaScript/components/bindings/index.ts b/Source/JavaScript/components/bindings/index.ts new file mode 100644 index 0000000..7030928 --- /dev/null +++ b/Source/JavaScript/components/bindings/index.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. + +export * from './BindingKind'; +export * from './BoundConstructor'; +export * from './ElementBinding'; +export * from './bindingRegistry'; +export * from './resolveElementBinding'; +export * from './Placeholder'; +export * from './MissingBinding'; +export * from './ArcRuntimeBoundary'; diff --git a/Source/JavaScript/components/bindings/resolveElementBinding.ts b/Source/JavaScript/components/bindings/resolveElementBinding.ts new file mode 100644 index 0000000..3f00159 --- /dev/null +++ b/Source/JavaScript/components/bindings/resolveElementBinding.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 { ExternalComponent } from '@cratis/scene.model'; +import { stringProperty } from '../properties'; +import { BindingKind } from './BindingKind'; +import { ElementBinding } from './ElementBinding'; +import { resolveCommand, resolveQuery } from './bindingRegistry'; + +/** + * The property an element carries its binding name in, per kind. Queries and commands use different + * property names so a single element could in principle carry both - a command dialog launched from a + * data page, say - without the two names colliding. + */ +const bindingPropertyNames: Record = { + [BindingKind.Query]: 'query', + [BindingKind.Command]: 'command', +}; + +/** + * Reads the binding name an element carries for the given kind and looks it up in the binding registry. + * + * Every Arc-bound adapter starts here, so the "name in, class out" step happens in exactly one place and + * every adapter reports a missing binding the same way. Nothing throws: an absent property and an + * unregistered name both come back as an {@link ElementBinding} with no `target`, which the adapter + * turns into a visible placeholder. + */ +export function resolveElementBinding(element: ExternalComponent, kind: BindingKind): ElementBinding { + const name = stringProperty(element.properties, bindingPropertyNames[kind]); + if (name === undefined) return {}; + + return { name, target: kind === BindingKind.Query ? resolveQuery(name) : resolveCommand(name) }; +} diff --git a/Source/JavaScript/components/common/SceneDropdown.tsx b/Source/JavaScript/components/common/SceneDropdown.tsx new file mode 100644 index 0000000..dd4e2b3 --- /dev/null +++ b/Source/JavaScript/components/common/SceneDropdown.tsx @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Dropdown } from '@cratis/components/Dropdown'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { booleanProperty, objectArrayProperty, stringProperty } from '../properties'; + +/** + * The `Cratis.Components:dropdown` component - `Dropdown` from `@cratis/components/Dropdown`. + * + * The library's own `Dropdown` rather than PrimeReact's, because it carries the overlay z-index fix the + * library applies across every overlay it owns - a dropdown inside a dialog renders above the dialog + * instead of behind it, which is the single most common overlay bug in a PrimeReact application. + * + * This is the standalone dropdown, not the command-form one: it takes its options from the screen and is + * not bound to a command property. Use `dropdownField` inside a `commandForm`. + */ +export function SceneDropdown({ element }: RegisteredComponentProps) { + return ( + + ); +} diff --git a/Source/JavaScript/components/common/SceneErrorBoundary.tsx b/Source/JavaScript/components/common/SceneErrorBoundary.tsx new file mode 100644 index 0000000..047c370 --- /dev/null +++ b/Source/JavaScript/components/common/SceneErrorBoundary.tsx @@ -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 { ErrorBoundary } from '@cratis/components/Common'; +import { RegisteredComponentProps } from '@cratis/scene.react'; + +/** + * The `Cratis.Components:errorBoundary` component - `ErrorBoundary` from `@cratis/components/Common`. + * + * Lets a screen decide where a failure stops. React's default is that a throw anywhere unmounts the + * whole tree, which for a composed screen means one broken region takes the navigation with it; placing + * a boundary around a region scopes that to the region. Which regions deserve one is a design decision + * about the screen, which is precisely why it belongs in the screen rather than in the renderer. + */ +export function SceneErrorBoundary({ slots }: RegisteredComponentProps) { + return {slots.content}; +} diff --git a/Source/JavaScript/components/common/SceneIcon.tsx b/Source/JavaScript/components/common/SceneIcon.tsx new file mode 100644 index 0000000..f9fb441 --- /dev/null +++ b/Source/JavaScript/components/common/SceneIcon.tsx @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { IconDisplay } from '@cratis/components/Common'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { stringProperty } from '../properties'; + +/** + * The `Cratis.Components:icon` component - `IconDisplay` from `@cratis/components/Common`. + * + * Takes an icon class name (`pi pi-check`) rather than an image, and normalizes the shorthand forms + * people actually write - `pi-check` on its own, or a bare name - into the class PrimeIcons expects. + * That normalization is the whole reason to route a screen's icons through this rather than emitting an + * ``: a screen author writes what they remember and it still renders. + */ +export function SceneIcon({ element }: RegisteredComponentProps) { + return ; +} diff --git a/Source/JavaScript/components/common/SceneTooltip.tsx b/Source/JavaScript/components/common/SceneTooltip.tsx new file mode 100644 index 0000000..12fef10 --- /dev/null +++ b/Source/JavaScript/components/common/SceneTooltip.tsx @@ -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. + +import { Tooltip } from '@cratis/components/Common'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { booleanProperty, stringProperty, unionProperty } from '../properties'; + +/** Which side of the wrapped content the tooltip appears on. */ +const positions = ['top', 'right', 'bottom', 'left'] as const; + +/** + * The `Cratis.Components:tooltip` component - `Tooltip` from `@cratis/components/Common`. + * + * Wraps whatever is in its `content` slot and explains it on hover. `disabled` exists so a screen can + * keep the wrapper in place while turning the hint off, rather than restructuring the element tree to + * remove it - the tree is authored, and a conditional wrapper would be a worse thing to model. + */ +export function SceneTooltip({ element, slots }: RegisteredComponentProps) { + return ( + + {slots.content} + + ); +} diff --git a/Source/JavaScript/components/common/index.ts b/Source/JavaScript/components/common/index.ts new file mode 100644 index 0000000..74f168f --- /dev/null +++ b/Source/JavaScript/components/common/index.ts @@ -0,0 +1,7 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export * from './SceneIcon'; +export * from './SceneTooltip'; +export * from './SceneErrorBoundary'; +export * from './SceneDropdown'; diff --git a/Source/JavaScript/components/cratisComponents.ts b/Source/JavaScript/components/cratisComponents.ts new file mode 100644 index 0000000..5702ec7 --- /dev/null +++ b/Source/JavaScript/components/cratisComponents.ts @@ -0,0 +1,91 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ComponentRegistry, componentRegistryKey } from '@cratis/scene.react'; +import { SceneDataPage, SceneFormElement, ScenePage } from './pages'; +import { SceneDataTable, SceneObservableDataTable } from './data'; +import { + SceneCalendarField, + SceneCheckboxField, + SceneChipsField, + SceneColorPickerField, + SceneCommandForm, + SceneDropdownField, + SceneInputTextField, + SceneMultiSelectField, + SceneNumberField, + SceneRadioButtonField, + SceneRadioGroupField, + SceneSliderField, + SceneTextAreaField, +} from './forms'; +import { SceneBusyIndicatorDialog, SceneCommandDialog, SceneConfirmationDialog, SceneDialog, SceneStepperCommandDialog } from './dialogs'; +import { SceneDropdown, SceneErrorBoundary, SceneIcon, SceneTooltip } from './common'; +import { SceneFilterPanel, SceneObjectContentEditor, SceneObjectNavigationalBar, SceneSchemaEditor, SceneTimeMachine } from './editors'; +import { SceneToolbar, SceneToolbarButton, SceneToolbarGroup, SceneToolbarSeparator } from './toolbar'; + +/** + * The package name every component here is registered under, and the one a screen writes when it + * qualifies a name explicitly (`Cratis.Components.table`). + */ +export const cratisComponentsPackageName = 'Cratis.Components'; + +/** + * The `Cratis.Components` package's component registry - the abstract names a screen can resolve to a + * real `@cratis/components` component. + * + * Names are `lowerCamelCase` and deliberately *abstract*: a screen names `dataTable`, not + * `DataTableForQuery`, so the same screen resolves against whichever package a profile ranks highest. + * Two of them - `table` and `dialog` - are names `core` and `PrimeReact` also declare, and that overlap + * is the point: a profile listing `core`, `PrimeReact`, `Cratis.Components` in that order resolves both + * here and records the others as shadowed, which is override priority doing exactly what it exists for. + * + * `table` and `dataTable` are the same component under two names. `dataTable` says what it is; `table` + * is what a screen written against the base vocabulary already says, and both should land on the + * query-aware implementation. + */ +export const cratisComponents: ComponentRegistry = { + [componentRegistryKey(cratisComponentsPackageName, 'page')]: ScenePage, + [componentRegistryKey(cratisComponentsPackageName, 'dataPage')]: SceneDataPage, + [componentRegistryKey(cratisComponentsPackageName, 'formElement')]: SceneFormElement, + + [componentRegistryKey(cratisComponentsPackageName, 'dataTable')]: SceneDataTable, + [componentRegistryKey(cratisComponentsPackageName, 'table')]: SceneDataTable, + [componentRegistryKey(cratisComponentsPackageName, 'observableDataTable')]: SceneObservableDataTable, + + [componentRegistryKey(cratisComponentsPackageName, 'commandForm')]: SceneCommandForm, + [componentRegistryKey(cratisComponentsPackageName, 'inputTextField')]: SceneInputTextField, + [componentRegistryKey(cratisComponentsPackageName, 'numberField')]: SceneNumberField, + [componentRegistryKey(cratisComponentsPackageName, 'checkboxField')]: SceneCheckboxField, + [componentRegistryKey(cratisComponentsPackageName, 'textAreaField')]: SceneTextAreaField, + [componentRegistryKey(cratisComponentsPackageName, 'dropdownField')]: SceneDropdownField, + [componentRegistryKey(cratisComponentsPackageName, 'sliderField')]: SceneSliderField, + [componentRegistryKey(cratisComponentsPackageName, 'calendarField')]: SceneCalendarField, + [componentRegistryKey(cratisComponentsPackageName, 'colorPickerField')]: SceneColorPickerField, + [componentRegistryKey(cratisComponentsPackageName, 'multiSelectField')]: SceneMultiSelectField, + [componentRegistryKey(cratisComponentsPackageName, 'chipsField')]: SceneChipsField, + [componentRegistryKey(cratisComponentsPackageName, 'radioButtonField')]: SceneRadioButtonField, + [componentRegistryKey(cratisComponentsPackageName, 'radioGroupField')]: SceneRadioGroupField, + + [componentRegistryKey(cratisComponentsPackageName, 'dialog')]: SceneDialog, + [componentRegistryKey(cratisComponentsPackageName, 'confirmationDialog')]: SceneConfirmationDialog, + [componentRegistryKey(cratisComponentsPackageName, 'busyIndicatorDialog')]: SceneBusyIndicatorDialog, + [componentRegistryKey(cratisComponentsPackageName, 'commandDialog')]: SceneCommandDialog, + [componentRegistryKey(cratisComponentsPackageName, 'stepperCommandDialog')]: SceneStepperCommandDialog, + + [componentRegistryKey(cratisComponentsPackageName, 'icon')]: SceneIcon, + [componentRegistryKey(cratisComponentsPackageName, 'tooltip')]: SceneTooltip, + [componentRegistryKey(cratisComponentsPackageName, 'dropdown')]: SceneDropdown, + [componentRegistryKey(cratisComponentsPackageName, 'errorBoundary')]: SceneErrorBoundary, + + [componentRegistryKey(cratisComponentsPackageName, 'objectContentEditor')]: SceneObjectContentEditor, + [componentRegistryKey(cratisComponentsPackageName, 'objectNavigationalBar')]: SceneObjectNavigationalBar, + [componentRegistryKey(cratisComponentsPackageName, 'schemaEditor')]: SceneSchemaEditor, + [componentRegistryKey(cratisComponentsPackageName, 'timeMachine')]: SceneTimeMachine, + [componentRegistryKey(cratisComponentsPackageName, 'filterPanel')]: SceneFilterPanel, + + [componentRegistryKey(cratisComponentsPackageName, 'toolbar')]: SceneToolbar, + [componentRegistryKey(cratisComponentsPackageName, 'toolbarButton')]: SceneToolbarButton, + [componentRegistryKey(cratisComponentsPackageName, 'toolbarGroup')]: SceneToolbarGroup, + [componentRegistryKey(cratisComponentsPackageName, 'toolbarSeparator')]: SceneToolbarSeparator, +}; diff --git a/Source/JavaScript/components/cratisComponentsPackage.ts b/Source/JavaScript/components/cratisComponentsPackage.ts new file mode 100644 index 0000000..00f140e --- /dev/null +++ b/Source/JavaScript/components/cratisComponentsPackage.ts @@ -0,0 +1,101 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PackageKind, ScenePackage } from '@cratis/scene.model'; +import { ScenePackageBundle } from '@cratis/scene.react'; +import { cratisComponents, cratisComponentsPackageName } from './cratisComponents'; + +/** + * The `Cratis.Components` package's declaration - what a profile gets when it lists this package. + * + * This is the concrete case the whole package-dependency mechanism exists for. `@cratis/components` is + * not a self-contained library: every component in it is a wrapper over a PrimeReact widget, and its + * own styling is a compiled Tailwind utility sheet (`@cratis/components/styles`) plus a `--cratis-*` + * token layer. Activate it in a profile without PrimeReact and nothing renders; activate it without + * Tailwind and everything renders unstyled. Both are stated as dependencies so a package picker can say + * so while the profile is being configured, instead of leaving it to be discovered when the page opens. + * + * The version ranges are the real ones the library carries: `>=10.9.0` for PrimeReact because its + * `--cratis-*` tokens resolve PrimeReact 11's design tokens first and fall back to the version 10 theme + * variables, so it spans the upgrade window; `^4.0.0` for Tailwind because the utility sheet is compiled + * against Tailwind 4. + */ +export const cratisComponentsPackageManifest: ScenePackage = { + name: cratisComponentsPackageName, + version: '2.8.1', + kind: PackageKind.ComponentLibrary, + dependencies: [ + { name: 'PrimeReact', versionRange: '>=10.9.0' }, + { name: 'Tailwind', versionRange: '^4.0.0' }, + ], + components: [ + 'page', + 'dataPage', + 'formElement', + 'dataTable', + 'table', + 'observableDataTable', + 'commandForm', + 'inputTextField', + 'numberField', + 'checkboxField', + 'textAreaField', + 'dropdownField', + 'sliderField', + 'calendarField', + 'colorPickerField', + 'multiSelectField', + 'chipsField', + 'radioButtonField', + 'radioGroupField', + 'dialog', + 'confirmationDialog', + 'busyIndicatorDialog', + 'commandDialog', + 'stepperCommandDialog', + 'icon', + 'tooltip', + 'dropdown', + 'errorBoundary', + 'objectContentEditor', + 'objectNavigationalBar', + 'schemaEditor', + 'timeMachine', + 'filterPanel', + 'toolbar', + 'toolbarButton', + 'toolbarGroup', + 'toolbarSeparator', + ], + + // A component library ships components, and nothing else. Layouts, screen templates and dialog + // templates all belong to a Blueprint: they are decisions about what an application looks like as a + // whole, and this package deliberately makes none of them - it provides the Arc-bound composites a + // template is *built from*, so that a blueprint can place a `dataPage` in a slot and configure it, + // rather than shipping one opinionated data page nobody can rearrange. + layouts: [], + screenTemplates: [], + dialogTemplates: [], + + // No themes either, for a different reason: `@cratis/components` has no palette of its own. It reads + // a `--cratis-*` variable layer that resolves whatever PrimeReact theme - or Scene theme, through + // this package's token bridge - happens to be active. Shipping a theme here would be this library + // asserting a look it was specifically built not to have. + themes: [], + + displayName: 'Cratis Components', + description: "Cratis' Arc-bound data, form and dialog composites, built on PrimeReact and Tailwind.", + module: '@cratis/scene.components', +}; + +/** + * The `Cratis.Components` package as a loadable bundle - the manifest above, plus the React components + * behind the names it declares. + * + * No `layouts`, `screenTemplates`, `dialogTemplates` or `themes` are provided, matching a manifest that + * declares none of them; `validatePackageBundle` is what proves the two halves agree. + */ +export const cratisComponentsPackage: ScenePackageBundle = { + manifest: cratisComponentsPackageManifest, + components: cratisComponents, +}; diff --git a/Source/JavaScript/components/data/SceneDataTable.tsx b/Source/JavaScript/components/data/SceneDataTable.tsx new file mode 100644 index 0000000..99412a6 --- /dev/null +++ b/Source/JavaScript/components/data/SceneDataTable.tsx @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding } from '../bindings'; +import { booleanProperty, stringArrayProperty, stringProperty } from '../properties'; + +const DataTableForQuery = lazy(async () => ({ default: (await import('@cratis/components/DataTables')).DataTableForQuery })); + +/** + * The `Cratis.Components:dataTable` component, also registered as `table` - `DataTableForQuery` from + * `@cratis/components/DataTables`. + * + * Registering it under the bare name `table` as well is override priority working as designed: a profile + * listing `core`, `PrimeReact` and `Cratis.Components` in that order resolves `table` here and records + * the other two as shadowed. That is the right outcome, because this is a strictly better `table` for a + * Cratis application - it performs the query, pages against the server, and wires filtering and sorting + * back into it, where PrimeReact's `DataTable` is handed rows and knows nothing about where they came from. + * + * The `query` property names an Arc query proxy; the columns come from the `content` slot. + */ +export function SceneDataTable({ element, slots }: RegisteredComponentProps) { + const { name, target } = resolveElementBinding(element, BindingKind.Query); + if (!target) return ; + + return ( + + + {slots.content} + + + ); +} diff --git a/Source/JavaScript/components/data/SceneObservableDataTable.tsx b/Source/JavaScript/components/data/SceneObservableDataTable.tsx new file mode 100644 index 0000000..c797c1e --- /dev/null +++ b/Source/JavaScript/components/data/SceneObservableDataTable.tsx @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding } from '../bindings'; +import { booleanProperty, stringArrayProperty, stringProperty } from '../properties'; + +const DataTableForObservableQuery = lazy(async () => ({ + default: (await import('@cratis/components/DataTables')).DataTableForObservableQuery, +})); + +/** + * The `Cratis.Components:observableDataTable` component - `DataTableForObservableQuery` from + * `@cratis/components/DataTables`. + * + * A separate name rather than a flag on `dataTable`, because the distinction is in the *proxy* a host + * registers, not in how the table is configured: an observable query opens a WebSocket subscription and + * re-renders when the server's read model changes, and a plain query does not. A screen that names an + * observable query here is stating that its data is live, which is a design decision worth being able to + * read off the screen. + */ +export function SceneObservableDataTable({ element, slots }: RegisteredComponentProps) { + const { name, target } = resolveElementBinding(element, BindingKind.Query); + if (!target) return ; + + return ( + + + {slots.content} + + + ); +} diff --git a/Source/JavaScript/components/data/for_SceneDataTable/when_the_query_binding_is_missing.tsx b/Source/JavaScript/components/data/for_SceneDataTable/when_the_query_binding_is_missing.tsx new file mode 100644 index 0000000..ecd0d14 --- /dev/null +++ b/Source/JavaScript/components/data/for_SceneDataTable/when_the_query_binding_is_missing.tsx @@ -0,0 +1,47 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { render, screen } from '@testing-library/react'; +import { externalComponent } from '../../given'; +import { clearBindings, registerQuery } from '../../bindings'; +import { SceneDataTable } from '../SceneDataTable'; + +class AllInvoices {} + +describe('when the query binding is missing', () => { + beforeEach(() => clearBindings()); + afterEach(() => clearBindings()); + + describe('and the screen names a query nothing is registered under', () => { + beforeEach(() => { + const element = externalComponent('Cratis.Components:dataTable', { query: 'AllInvoices', emptyMessage: 'No invoices' }); + render(); + }); + + it('should render a placeholder naming the binding rather than throwing', () => + screen.getByText("Unresolved query binding 'AllInvoices' on Cratis.Components:dataTable").should.exist); + }); + + describe('and the screen names no query at all', () => { + beforeEach(() => { + const element = externalComponent('Cratis.Components:dataTable', { emptyMessage: 'No invoices' }); + render(); + }); + + it('should render a placeholder saying the binding is missing', () => + screen.getByText('Missing query binding on Cratis.Components:dataTable').should.exist); + }); + + describe('and the named query is registered', () => { + beforeEach(() => { + registerQuery('AllInvoices', AllInvoices); + const element = externalComponent('Cratis.Components:dataTable', { query: 'AllInvoices', emptyMessage: 'No invoices' }); + render(); + }); + + it('should render no placeholder', () => (screen.queryByText(/query binding/) === null).should.be.true); + + it('should hand off to the lazily loaded Arc-bound table', () => + document.querySelector('[data-scene-arc-loading]')!.should.exist); + }); +}); diff --git a/Source/JavaScript/components/data/index.ts b/Source/JavaScript/components/data/index.ts new file mode 100644 index 0000000..fef4eca --- /dev/null +++ b/Source/JavaScript/components/data/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export * from './SceneDataTable'; +export * from './SceneObservableDataTable'; diff --git a/Source/JavaScript/components/dialogs/SceneBusyIndicatorDialog.tsx b/Source/JavaScript/components/dialogs/SceneBusyIndicatorDialog.tsx new file mode 100644 index 0000000..da5e52b --- /dev/null +++ b/Source/JavaScript/components/dialogs/SceneBusyIndicatorDialog.tsx @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { ArcRuntimeBoundary } from '../bindings'; +import { stringProperty } from '../properties'; + +const BusyIndicatorDialog = lazy(async () => ({ default: (await import('@cratis/components/Dialogs')).BusyIndicatorDialog })); + +/** + * The `Cratis.Components:busyIndicatorDialog` component - `BusyIndicatorDialog` from + * `@cratis/components/Dialogs`. + * + * The blocking spinner shown while a long-running command is in flight. In a running application it is + * the Arc dialog host that renders it, threading its own `title` and `message` through; exposing it as a + * Scene component is what lets a screen show the same chrome at design time, so its wording and + * placement can be designed rather than discovered the first time an operation takes a while. + */ +export function SceneBusyIndicatorDialog({ element }: RegisteredComponentProps) { + return ( + + + + ); +} diff --git a/Source/JavaScript/components/dialogs/SceneCommandDialog.tsx b/Source/JavaScript/components/dialogs/SceneCommandDialog.tsx new file mode 100644 index 0000000..c0af2a1 --- /dev/null +++ b/Source/JavaScript/components/dialogs/SceneCommandDialog.tsx @@ -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 { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding } from '../bindings'; +import { booleanProperty, stringProperty } from '../properties'; + +const CommandDialog = lazy(async () => ({ default: (await import('@cratis/components/CommandDialog')).CommandDialog })); + +/** + * The `Cratis.Components:commandDialog` component - `CommandDialog` from `@cratis/components/CommandDialog`. + * + * A dialog whose confirm button *is* the command's execution: it binds the form to the command, submits + * on confirm, feeds the backend's validation results back onto the fields, and only closes when the + * command succeeded. That last part is the reason to use it rather than composing `dialog` with + * `commandForm` - a hand-composed pair has no way to keep the dialog open on a rejected command without + * reimplementing the protocol. + * + * The `command` property names an Arc command proxy; the fields go in the `content` slot. + */ +export function SceneCommandDialog({ element, slots }: RegisteredComponentProps) { + const { name, target } = resolveElementBinding(element, BindingKind.Command); + if (!target) return ; + + return ( + + + {slots.content} + + + ); +} diff --git a/Source/JavaScript/components/dialogs/SceneConfirmationDialog.tsx b/Source/JavaScript/components/dialogs/SceneConfirmationDialog.tsx new file mode 100644 index 0000000..05cecbe --- /dev/null +++ b/Source/JavaScript/components/dialogs/SceneConfirmationDialog.tsx @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { ArcRuntimeBoundary } from '../bindings'; + +const ConfirmationDialog = lazy(async () => ({ default: (await import('@cratis/components/Dialogs')).ConfirmationDialog })); + +/** + * The `Cratis.Components:confirmationDialog` component - `ConfirmationDialog` from + * `@cratis/components/Dialogs`. + * + * Takes no properties by design: it is not a dialog a screen configures, it is the host that renders + * whatever confirmation the running application has asked for through Arc's dialog service. A screen + * places it once, near the root, and every `Are you sure?` in the application appears there. + */ +export function SceneConfirmationDialog() { + return ( + + + + ); +} diff --git a/Source/JavaScript/components/dialogs/SceneDialog.tsx b/Source/JavaScript/components/dialogs/SceneDialog.tsx new file mode 100644 index 0000000..0d06139 --- /dev/null +++ b/Source/JavaScript/components/dialogs/SceneDialog.tsx @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { ArcRuntimeBoundary } from '../bindings'; +import { booleanProperty, stringProperty } from '../properties'; + +const Dialog = lazy(async () => ({ default: (await import('@cratis/components/Dialogs')).Dialog })); + +/** + * The `Cratis.Components:dialog` component, registered under the bare name `dialog` - `Dialog` from + * `@cratis/components/Dialogs`. + * + * Shadowing PrimeReact's `dialog` deliberately. PrimeReact's is a modal frame and nothing more; this one + * resolves its result through Arc's dialog context, so a screen's dialog participates in the same + * request/response protocol as the rest of the application - a caller awaits a `DialogResult` instead of + * threading `visible` state and callbacks by hand. + * + * `visible` defaults to `true` because a dialog placed on a screen is being placed to be seen; a host + * that controls visibility sets the property explicitly. + */ +export function SceneDialog({ element, slots }: RegisteredComponentProps) { + return ( + + + {slots.content} + + + ); +} diff --git a/Source/JavaScript/components/dialogs/SceneStepperCommandDialog.tsx b/Source/JavaScript/components/dialogs/SceneStepperCommandDialog.tsx new file mode 100644 index 0000000..dccee75 --- /dev/null +++ b/Source/JavaScript/components/dialogs/SceneStepperCommandDialog.tsx @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding } from '../bindings'; +import { booleanProperty, stringProperty, unionProperty } from '../properties'; + +const StepperCommandDialog = lazy(async () => ({ + default: (await import('@cratis/components/CommandDialog')).StepperCommandDialog, +})); + +/** Whether the step headers run across the top or down the side. */ +const orientations = ['horizontal', 'vertical'] as const; + +/** + * The `Cratis.Components:stepperCommandDialog` component - `StepperCommandDialog` from + * `@cratis/components/CommandDialog`. + * + * `commandDialog` split across steps, for a command with more fields than fit one screen. It stays a + * single command: the steps partition the *fields*, not the intent, and nothing is submitted until the + * last step - so a half-finished wizard leaves no trace, which is exactly what a command should do. + * + * `linear` decides whether a step can be skipped ahead of the one before it. The step panels go in the + * `content` slot. + */ +export function SceneStepperCommandDialog({ element, slots }: RegisteredComponentProps) { + const { name, target } = resolveElementBinding(element, BindingKind.Command); + if (!target) return ; + + return ( + + + {slots.content} + + + ); +} diff --git a/Source/JavaScript/components/dialogs/index.ts b/Source/JavaScript/components/dialogs/index.ts new file mode 100644 index 0000000..5dba401 --- /dev/null +++ b/Source/JavaScript/components/dialogs/index.ts @@ -0,0 +1,8 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export * from './SceneDialog'; +export * from './SceneConfirmationDialog'; +export * from './SceneBusyIndicatorDialog'; +export * from './SceneCommandDialog'; +export * from './SceneStepperCommandDialog'; diff --git a/Source/JavaScript/components/editors/SceneFilterPanel.tsx b/Source/JavaScript/components/editors/SceneFilterPanel.tsx new file mode 100644 index 0000000..c6d4445 --- /dev/null +++ b/Source/JavaScript/components/editors/SceneFilterPanel.tsx @@ -0,0 +1,52 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useMemo, useRef, useState } from 'react'; +import { FilterPanel, useFilterState } from '@cratis/components/Filter'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { stringProperty } from '../properties'; +import { filterDefinitions } from './filterDefinitions'; + +/** + * The `Cratis.Components:filterPanel` component - `FilterPanel` from `@cratis/components/Filter`. + * + * `FilterPanel` is a portal anchored to a button: it renders next to whatever opened it, which means it + * cannot be placed on a screen on its own. So this adapter is the whole control - the toggle button and + * the panel it anchors - and a screen places one element instead of having to model an anchor + * relationship the element tree has no way to express. + * + * Selection state comes from the library's own `useFilterState`, so the toggling, clearing and range + * behavior is the library's rather than a second implementation of it here. + */ +export function SceneFilterPanel({ element, slots }: RegisteredComponentProps) { + const filters = useMemo(() => filterDefinitions(element.properties), [element.properties]); + const anchorRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const state = useFilterState(filters); + + return ( + <> + + setIsOpen(false)} + onFilterToggle={state.handleToggleFilter} + onFilterClear={state.handleClearFilter} + onRangeChange={state.handleRangeChange} + onExpandedFilterChange={state.setExpandedFilterKey} + onCustomValueChange={state.handleCustomValueChange} + > + {slots.content} + + + ); +} diff --git a/Source/JavaScript/components/editors/SceneObjectContentEditor.tsx b/Source/JavaScript/components/editors/SceneObjectContentEditor.tsx new file mode 100644 index 0000000..8a0ddab --- /dev/null +++ b/Source/JavaScript/components/editors/SceneObjectContentEditor.tsx @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useMemo } from 'react'; +import { ObjectContentEditor } from '@cratis/components/ObjectContentEditor'; +import { Json, JsonSchema } from '@cratis/components/types'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { booleanProperty, objectProperty, stringProperty } from '../properties'; +import { useEditableCopy } from './useEditableCopy'; + +/** + * The `Cratis.Components:objectContentEditor` component - `ObjectContentEditor` from + * `@cratis/components/ObjectContentEditor`. + * + * Renders an arbitrary JSON document against its schema, so nested objects and arrays are navigable and + * each value is edited with a control that matches its declared type. That is what makes it worth + * exposing to a screen at all: the alternative is a textarea full of JSON, which is not an editor. + * + * `object` and `schema` come through the property bag as plain JSON, which is exactly what they are at + * runtime - the conversions state that, since the property bag is typed as unknown values by definition. + */ +export function SceneObjectContentEditor({ element }: RegisteredComponentProps) { + const declared = useMemo(() => (objectProperty(element.properties, 'object') ?? {}) as unknown as Json, [element.properties]); + const schema = useMemo(() => (objectProperty(element.properties, 'schema') ?? {}) as unknown as JsonSchema, [element.properties]); + const [object, setObject] = useEditableCopy(declared); + + return ( + + ); +} diff --git a/Source/JavaScript/components/editors/SceneObjectNavigationalBar.tsx b/Source/JavaScript/components/editors/SceneObjectNavigationalBar.tsx new file mode 100644 index 0000000..d3efd21 --- /dev/null +++ b/Source/JavaScript/components/editors/SceneObjectNavigationalBar.tsx @@ -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. + +import { useMemo } from 'react'; +import { ObjectNavigationalBar } from '@cratis/components/ObjectNavigationalBar'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { stringArrayProperty, stringProperty } from '../properties'; +import { useEditableCopy } from './useEditableCopy'; + +/** + * The `Cratis.Components:objectNavigationalBar` component - `ObjectNavigationalBar` from + * `@cratis/components/ObjectNavigationalBar`. + * + * The breadcrumb trail that says where you are inside a nested document, and lets you climb back out. + * Clicking a crumb truncates the trail to it, which is behavior the component reports rather than + * performs - so this adapter holds the trail and applies the truncation, and the bar is a working + * breadcrumb on a screen instead of a row of inert labels. + */ +export function SceneObjectNavigationalBar({ element }: RegisteredComponentProps) { + const declared = useMemo(() => stringArrayProperty(element.properties, 'navigationPath') ?? [], [element.properties]); + const [navigationPath, setNavigationPath] = useEditableCopy(declared); + + return ( + setNavigationPath(navigationPath.slice(0, index + 1))} + className={stringProperty(element.properties, 'className')} + /> + ); +} diff --git a/Source/JavaScript/components/editors/SceneSchemaEditor.tsx b/Source/JavaScript/components/editors/SceneSchemaEditor.tsx new file mode 100644 index 0000000..d5d3ee9 --- /dev/null +++ b/Source/JavaScript/components/editors/SceneSchemaEditor.tsx @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useMemo } from 'react'; +import { SchemaEditor } from '@cratis/components/SchemaEditor'; +import { JsonSchema } from '@cratis/components/types'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { booleanProperty, objectProperty, stringProperty } from '../properties'; +import { useEditableCopy } from './useEditableCopy'; + +/** + * The `Cratis.Components:schemaEditor` component - `SchemaEditor` from `@cratis/components/SchemaEditor`. + * + * Edits a JSON schema as a typed property tree rather than as text. In a Cratis application this is how + * an event type's shape is inspected and evolved, so `canEdit` and `canNotEditReason` are a pair worth + * setting together - a schema is often deliberately read-only, and saying *why* in the same place is + * what keeps that from looking like a bug. + */ +export function SceneSchemaEditor({ element }: RegisteredComponentProps) { + const declared = useMemo(() => (objectProperty(element.properties, 'schema') ?? {}) as unknown as JsonSchema, [element.properties]); + const [schema, setSchema] = useEditableCopy(declared); + + return ( + + ); +} diff --git a/Source/JavaScript/components/editors/SceneTimeMachine.tsx b/Source/JavaScript/components/editors/SceneTimeMachine.tsx new file mode 100644 index 0000000..6a5ff71 --- /dev/null +++ b/Source/JavaScript/components/editors/SceneTimeMachine.tsx @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useMemo } from 'react'; +import { TimeMachine } from '@cratis/components/TimeMachine'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { numberProperty } from '../properties'; +import { timeMachineVersions } from './timeMachineVersions'; +import { useEditableCopy } from './useEditableCopy'; + +/** + * The `Cratis.Components:timeMachine` component - `TimeMachine` from `@cratis/components/TimeMachine`. + * + * Scrubs through successive versions of something on a timeline - which in an event-sourced application + * is the most natural way to look at anything, since every read model *has* a history rather than only a + * current value. The selected version is held here so the timeline scrubs on a screen; a host that wants + * to drive the selection renders the component itself. + */ +export function SceneTimeMachine({ element }: RegisteredComponentProps) { + const versions = useMemo(() => timeMachineVersions(element.properties), [element.properties]); + const declaredIndex = numberProperty(element.properties, 'currentVersionIndex') ?? 0; + const [currentVersionIndex, setCurrentVersionIndex] = useEditableCopy(declaredIndex); + + return ( + + ); +} diff --git a/Source/JavaScript/components/editors/filterDefinitions.ts b/Source/JavaScript/components/editors/filterDefinitions.ts new file mode 100644 index 0000000..bd1b64f --- /dev/null +++ b/Source/JavaScript/components/editors/filterDefinitions.ts @@ -0,0 +1,59 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { FilterDefinition, FilterOption, FilterValue } from '@cratis/components/Filter'; +import { booleanProperty, numberProperty, objectArrayProperty, stringProperty, unionProperty } from '../properties'; + +/** The value kinds a filter can be declared over. */ +const filterTypes = ['string', 'number', 'date', 'custom'] as const; + +/** + * Reads a `filters` property into the `FilterDefinition` list `FilterPanel` renders. + * + * A definition needs a `key` and a `label`; an entry without both is dropped, since a filter with no key + * has nothing to filter on and one with no label cannot be shown. Everything else is optional and falls + * through to the panel's own defaults. + */ +export function filterDefinitions(properties: Record): FilterDefinition[] { + const entries = objectArrayProperty(properties, 'filters') ?? []; + + return entries + .map((entry): FilterDefinition | undefined => { + const key = stringProperty(entry, 'key'); + const label = stringProperty(entry, 'label'); + if (key === undefined || label === undefined) return undefined; + + return { + key, + label, + type: unionProperty(entry, 'type', filterTypes), + multi: booleanProperty(entry, 'multi'), + options: filterOptions(entry), + searchable: booleanProperty(entry, 'searchable'), + searchPlaceholder: stringProperty(entry, 'searchPlaceholder'), + buckets: numberProperty(entry, 'buckets'), + }; + }) + .filter((definition): definition is FilterDefinition => definition !== undefined); +} + +/** + * Reads one filter's selectable options. An option's `value` is what gets applied and its `key` is what + * the panel tracks selection by; `value` falls back to the key so the common case of a plain string + * choice needs only `key` and `label`. + */ +function filterOptions(entry: Record): FilterOption[] | undefined { + const options = objectArrayProperty(entry, 'options'); + if (options === undefined) return undefined; + + return options + .map((option): FilterOption | undefined => { + const key = stringProperty(option, 'key'); + const label = stringProperty(option, 'label'); + if (key === undefined || label === undefined) return undefined; + + const value: FilterValue = stringProperty(option, 'value') ?? numberProperty(option, 'value') ?? booleanProperty(option, 'value') ?? key; + return { key, label, value, count: numberProperty(option, 'count') }; + }) + .filter((option): option is FilterOption => option !== undefined); +} diff --git a/Source/JavaScript/components/editors/for_filterDefinitions/when_reading_filter_definitions.ts b/Source/JavaScript/components/editors/for_filterDefinitions/when_reading_filter_definitions.ts new file mode 100644 index 0000000..d73573d --- /dev/null +++ b/Source/JavaScript/components/editors/for_filterDefinitions/when_reading_filter_definitions.ts @@ -0,0 +1,49 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { filterDefinitions } from '../filterDefinitions'; + +describe('when reading filter definitions', () => { + describe('and a filter declares options', () => { + const definitions = filterDefinitions({ + filters: [ + { + key: 'status', + label: 'Status', + type: 'string', + multi: true, + options: [ + { key: 'draft', label: 'Draft', count: 12 }, + { key: 'approved', label: 'Approved', value: 'Approved' }, + ], + }, + ], + }); + + it('should read the filter', () => definitions.should.have.lengthOf(1)); + it('should read the declared type', () => definitions[0].type!.should.equal('string')); + it('should read the multi flag', () => definitions[0].multi!.should.equal(true)); + it('should fall back to the option key as its value', () => definitions[0].options![0].value!.should.equal('draft')); + it('should use a declared option value when there is one', () => definitions[0].options![1].value!.should.equal('Approved')); + it('should read the option count', () => definitions[0].options![0].count!.should.equal(12)); + }); + + describe('and an entry is missing a key or a label', () => { + const definitions = filterDefinitions({ + filters: [{ key: 'status', label: 'Status' }, { label: 'No key' }, { key: 'noLabel' }], + }); + + it('should keep only the filters that can be shown and applied', () => + definitions.map(definition => definition.key).should.deep.equal(['status'])); + }); + + describe('and a filter declares a type outside the allowed set', () => { + const definitions = filterDefinitions({ filters: [{ key: 'status', label: 'Status', type: 'colour' }] }); + + it('should leave the type unset rather than passing an unknown one through', () => (definitions[0].type === undefined).should.be.true); + }); + + describe('and no filters property is set', () => { + it('should read no filters', () => filterDefinitions({}).should.deep.equal([])); + }); +}); diff --git a/Source/JavaScript/components/editors/for_timeMachineVersions/when_reading_versions.ts b/Source/JavaScript/components/editors/for_timeMachineVersions/when_reading_versions.ts new file mode 100644 index 0000000..ae691fb --- /dev/null +++ b/Source/JavaScript/components/editors/for_timeMachineVersions/when_reading_versions.ts @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { timeMachineVersions } from '../timeMachineVersions'; + +describe('when reading versions', () => { + describe('and every entry is complete', () => { + const versions = timeMachineVersions({ + versions: [ + { id: 'v1', label: 'Registered', timestamp: '2026-01-05T10:00:00.000Z', content: 'First' }, + { id: 'v2', label: 'Approved', timestamp: 1767700800000, content: 'Second' }, + ], + }); + + it('should read every version', () => versions.should.have.lengthOf(2)); + it('should read an ISO timestamp', () => versions[0].timestamp.toISOString().should.equal('2026-01-05T10:00:00.000Z')); + it('should read an epoch timestamp', () => versions[1].timestamp.getTime().should.equal(1767700800000)); + it('should read the content as text', () => versions[0].content!.should.equal('First')); + }); + + describe('and an entry is incomplete or unparseable', () => { + const versions = timeMachineVersions({ + versions: [ + { id: 'v1', label: 'Registered', timestamp: '2026-01-05T10:00:00.000Z' }, + { label: 'No id', timestamp: '2026-01-05T10:00:00.000Z' }, + { id: 'v3', timestamp: '2026-01-05T10:00:00.000Z' }, + { id: 'v4', label: 'No timestamp' }, + { id: 'v5', label: 'Unparseable', timestamp: 'the fifth of January' }, + ], + }); + + it('should keep only the entries that can be placed on a timeline', () => versions.map(version => version.id).should.deep.equal(['v1'])); + it('should default missing content to empty text', () => versions[0].content!.should.equal('')); + }); + + describe('and no versions property is set', () => { + it('should read no versions', () => timeMachineVersions({}).should.deep.equal([])); + }); +}); diff --git a/Source/JavaScript/components/editors/index.ts b/Source/JavaScript/components/editors/index.ts new file mode 100644 index 0000000..9ab5e65 --- /dev/null +++ b/Source/JavaScript/components/editors/index.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. + +export * from './useEditableCopy'; +export * from './timeMachineVersions'; +export * from './filterDefinitions'; +export * from './SceneObjectContentEditor'; +export * from './SceneObjectNavigationalBar'; +export * from './SceneSchemaEditor'; +export * from './SceneTimeMachine'; +export * from './SceneFilterPanel'; diff --git a/Source/JavaScript/components/editors/timeMachineVersions.ts b/Source/JavaScript/components/editors/timeMachineVersions.ts new file mode 100644 index 0000000..a6c8644 --- /dev/null +++ b/Source/JavaScript/components/editors/timeMachineVersions.ts @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Version } from '@cratis/components/TimeMachine'; +import { numberProperty, objectArrayProperty, stringProperty } from '../properties'; + +/** + * Reads a `versions` property into the `Version` list `TimeMachine` renders. + * + * An entry without an `id`, a `label` and a parseable `timestamp` is dropped rather than defaulted, + * because `TimeMachine`'s whole job is to place versions on a timeline: a version invented at the epoch + * would not be a slightly wrong entry, it would silently reorder every real one around it. A dropped + * entry is visibly one item short; a fabricated one is a timeline that lies. + * + * `content` is rendered as text. A version whose content is a whole element tree is not something a + * property bag can carry, and pretending otherwise would be the wrong seam - that belongs in a screen + * template, not in a property. + */ +export function timeMachineVersions(properties: Record): Version[] { + const entries = objectArrayProperty(properties, 'versions') ?? []; + + return entries + .map((entry): Version | undefined => { + const id = stringProperty(entry, 'id'); + const label = stringProperty(entry, 'label'); + const timestamp = timestampOf(entry); + if (id === undefined || label === undefined || timestamp === undefined) return undefined; + + return { id, label, timestamp, content: stringProperty(entry, 'content') ?? '' }; + }) + .filter((version): version is Version => version !== undefined); +} + +/** + * Reads an entry's `timestamp`, accepting both the ISO string a `.play` screen writes and the epoch + * number a serializer may produce, and rejecting anything that does not parse into a real date. + */ +function timestampOf(entry: Record): Date | undefined { + const value = stringProperty(entry, 'timestamp') ?? numberProperty(entry, 'timestamp'); + if (value === undefined) return undefined; + + const timestamp = new Date(value); + return Number.isNaN(timestamp.getTime()) ? undefined : timestamp; +} diff --git a/Source/JavaScript/components/editors/useEditableCopy.ts b/Source/JavaScript/components/editors/useEditableCopy.ts new file mode 100644 index 0000000..6848439 --- /dev/null +++ b/Source/JavaScript/components/editors/useEditableCopy.ts @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useState } from 'react'; + +/** + * Keeps a locally editable copy of a value a screen declares, resetting it whenever the declared value + * itself changes. + * + * The editors in `@cratis/components` are controlled components: `ObjectContentEditor`, `SchemaEditor` + * and `TimeMachine` all render what they are given and report changes through a callback. A Scene screen + * can only *declare* the starting value - a property bag has no way to receive a callback - so without + * somewhere to put the result, every one of these would render as a dead surface that discards every + * keystroke. Local state is what makes them behave like the editors they are while a screen is being + * designed; a host that needs the edits for real reads them off its own model, not off the screen. + * + * The reset is deliberately done during render rather than in an effect: Studio rewrites an element's + * properties as the designer types, and an effect-based reset would show one frame of the previous + * value every time. + */ +export function useEditableCopy(source: T): [T, (value: T) => void] { + const [edited, setEdited] = useState(source); + const [lastSource, setLastSource] = useState(source); + + if (lastSource !== source) { + setLastSource(source); + setEdited(source); + return [source, setEdited]; + } + + return [edited, setEdited]; +} diff --git a/Source/JavaScript/components/for_cratisComponents/when_rendering_a_screen_through_the_real_renderer.tsx b/Source/JavaScript/components/for_cratisComponents/when_rendering_a_screen_through_the_real_renderer.tsx new file mode 100644 index 0000000..3a945c6 --- /dev/null +++ b/Source/JavaScript/components/for_cratisComponents/when_rendering_a_screen_through_the_real_renderer.tsx @@ -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. + +import { render, screen } from '@testing-library/react'; +import { ExternalComponent } from '@cratis/scene.model'; +import { SceneElementView } from '@cratis/scene.react'; +import { externalComponent } from '../given'; +import { cratisComponents } from '../cratisComponents'; +import { clearBindings } from '../bindings'; + +function withContent(element: ExternalComponent, content: ExternalComponent[]): ExternalComponent { + return { ...element, slots: { content } }; +} + +const heading = withContent(externalComponent('Cratis.Components:page', { title: 'Invoices', showTitle: true }), [ + externalComponent('Cratis.Components:toolbar', {}), + externalComponent('Cratis.Components:dataTable', { query: 'AllInvoices', emptyMessage: 'No invoices' }), +]); + +describe('when rendering a screen through the real renderer', () => { + beforeEach(() => { + clearBindings(); + render( undefined} />); + }); + + afterEach(() => clearBindings()); + + it('should resolve every component name against this package registry', () => screen.getByRole('heading', { name: 'Invoices' }).should.exist); + + it('should render the unbound table as a placeholder without taking the rest of the screen with it', () => { + screen.getByText("Unresolved query binding 'AllInvoices' on Cratis.Components:dataTable").should.exist; + screen.getByRole('heading', { name: 'Invoices' }).should.exist; + }); + + it('should render no unresolved-component fallback', () => + (document.querySelector('[data-scene-unresolved-component]') === null).should.be.true); +}); diff --git a/Source/JavaScript/components/for_cratisComponentsPackage/when_a_profile_ranks_it_above_the_packages_it_layers_on.ts b/Source/JavaScript/components/for_cratisComponentsPackage/when_a_profile_ranks_it_above_the_packages_it_layers_on.ts new file mode 100644 index 0000000..7a0242e --- /dev/null +++ b/Source/JavaScript/components/for_cratisComponentsPackage/when_a_profile_ranks_it_above_the_packages_it_layers_on.ts @@ -0,0 +1,49 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { UiProfile } from '@cratis/scene.model'; +import { PackageCatalog, resolveComponentName } from '@cratis/scene.engine'; +import { cratisComponentsPackageManifest } from '../cratisComponentsPackage'; + +const profile: UiProfile = { name: 'test', targetPlatform: 'web', packages: ['core', 'PrimeReact', 'Cratis.Components'] }; + +const catalog: PackageCatalog = { + core: ['text', 'button', 'card'], + PrimeReact: ['button', 'table', 'dialog'], + 'Cratis.Components': cratisComponentsPackageManifest.components, +}; + +describe('when a profile ranks it above the packages it layers on', () => { + describe('and it declares a name PrimeReact also declares', () => { + const resolution = resolveComponentName('table', profile, catalog)!; + + it('should resolve to this package', () => resolution.package.should.equal('Cratis.Components')); + it('should record PrimeReact as shadowed rather than discarding it', () => resolution.shadows.should.deep.equal(['PrimeReact'])); + }); + + describe('and it declares the dialog name PrimeReact also declares', () => { + const resolution = resolveComponentName('dialog', profile, catalog)!; + + it('should resolve to the Arc-aware dialog rather than the bare PrimeReact one', () => resolution.package.should.equal('Cratis.Components')); + it('should record PrimeReact as shadowed', () => resolution.shadows.should.deep.equal(['PrimeReact'])); + }); + + describe('and it declares a name nothing else does', () => { + const resolution = resolveComponentName('dataPage', profile, catalog)!; + + it('should resolve to this package', () => resolution.package.should.equal('Cratis.Components')); + it('should shadow nothing', () => resolution.shadows.should.deep.equal([])); + }); + + describe('and a name it does not declare is asked for', () => { + const resolution = resolveComponentName('card', profile, catalog)!; + + it('should fall through to the highest-priority package that does', () => resolution.package.should.equal('core')); + }); + + describe('and a screen qualifies the name with the package explicitly', () => { + const resolution = resolveComponentName('PrimeReact.table', profile, catalog)!; + + it('should resolve to the named package even though this one outranks it', () => resolution.package.should.equal('PrimeReact')); + }); +}); diff --git a/Source/JavaScript/components/for_cratisComponentsPackage/when_resolving_its_dependencies.ts b/Source/JavaScript/components/for_cratisComponentsPackage/when_resolving_its_dependencies.ts new file mode 100644 index 0000000..2daa762 --- /dev/null +++ b/Source/JavaScript/components/for_cratisComponentsPackage/when_resolving_its_dependencies.ts @@ -0,0 +1,68 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PackageKind, ScenePackage } from '@cratis/scene.model'; +import { isPackageSelectionValid, resolvePackageDependencies } from '@cratis/scene.engine'; +import { cratisComponentsPackageManifest } from '../cratisComponentsPackage'; + +/** + * A styling package declaring nothing but itself - enough for `Cratis.Components` to depend on, and no + * more, so this spec fails only when *this* package's declaration is wrong. + */ +const tailwind: ScenePackage = { + name: 'Tailwind', + version: '4.3.3', + kind: PackageKind.Styling, + dependencies: [], + components: [], + layouts: [], + screenTemplates: [], + dialogTemplates: [], + themes: [], +}; + +/** The base component library `Cratis.Components` wraps, at the version this repository pins. */ +const primeReact: ScenePackage = { + name: 'PrimeReact', + version: '10.9.8', + kind: PackageKind.ComponentLibrary, + dependencies: [{ name: 'Tailwind' }], + components: ['button', 'table', 'dialog'], + layouts: [], + screenTemplates: [], + dialogTemplates: [], + themes: ['lara-light-blue', 'lara-dark-blue'], +}; + +describe('when resolving its dependencies', () => { + const catalog = [tailwind, primeReact, cratisComponentsPackageManifest]; + const selection = resolvePackageDependencies(['Cratis.Components'], catalog); + + it('should order every package it depends on before it', () => + selection.packages.should.deep.equal(['Tailwind', 'PrimeReact', 'Cratis.Components'])); + + it('should report the packages it pulls in on the caller behalf', () => selection.added.should.deep.equal(['Tailwind', 'PrimeReact'])); + it('should report nothing missing', () => selection.missing.should.deep.equal([])); + it('should report no version conflict', () => selection.versionConflicts.should.deep.equal([])); + it('should report no cycle', () => selection.cycles.should.deep.equal([])); + it('should be a valid selection', () => isPackageSelectionValid(selection).should.be.true); + + describe('and neither dependency is in the catalog', () => { + const withoutDependencies = resolvePackageDependencies(['Cratis.Components'], [cratisComponentsPackageManifest]); + + it('should report both as missing rather than silently ordering around them', () => + withoutDependencies.missing.should.deep.equal([ + { package: 'Cratis.Components', dependsOn: 'PrimeReact' }, + { package: 'Cratis.Components', dependsOn: 'Tailwind' }, + ])); + }); + + describe('and PrimeReact is older than the range the token layer needs', () => { + const older = resolvePackageDependencies(['Cratis.Components'], [tailwind, { ...primeReact, version: '10.8.0' }, cratisComponentsPackageManifest]); + + it('should report the version conflict', () => + older.versionConflicts.should.deep.equal([ + { package: 'Cratis.Components', dependsOn: 'PrimeReact', requiredRange: '>=10.9.0', actualVersion: '10.8.0' }, + ])); + }); +}); diff --git a/Source/JavaScript/components/for_cratisComponentsPackage/when_validating_the_bundle.ts b/Source/JavaScript/components/for_cratisComponentsPackage/when_validating_the_bundle.ts new file mode 100644 index 0000000..9a58de5 --- /dev/null +++ b/Source/JavaScript/components/for_cratisComponentsPackage/when_validating_the_bundle.ts @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { componentRegistryKey, validatePackageBundle } from '@cratis/scene.react'; +import { cratisComponentsPackage, cratisComponentsPackageManifest } from '../cratisComponentsPackage'; +import { cratisComponentsPackageName } from '../cratisComponents'; + +describe('when validating the bundle', () => { + const problems = validatePackageBundle(cratisComponentsPackage); + + it('should report no problems', () => problems.should.deep.equal([])); + + it('should register an implementation for every declared component', () => { + const missing = cratisComponentsPackageManifest.components.filter( + name => !(componentRegistryKey(cratisComponentsPackageName, name) in cratisComponentsPackage.components) + ); + missing.should.deep.equal([]); + }); + + it('should key every registration under this package name', () => { + const foreign = Object.keys(cratisComponentsPackage.components).filter(key => !key.startsWith(`${cratisComponentsPackageName}:`)); + foreign.should.deep.equal([]); + }); + + it('should declare every registered component', () => { + const declared = new Set(cratisComponentsPackageManifest.components); + const undeclared = Object.keys(cratisComponentsPackage.components).filter( + key => !declared.has(key.slice(`${cratisComponentsPackageName}:`.length)) + ); + undeclared.should.deep.equal([]); + }); + + it('should declare no layouts, screen templates, dialog templates or themes', () => { + cratisComponentsPackageManifest.layouts.should.deep.equal([]); + cratisComponentsPackageManifest.screenTemplates.should.deep.equal([]); + cratisComponentsPackageManifest.dialogTemplates.should.deep.equal([]); + cratisComponentsPackageManifest.themes.should.deep.equal([]); + }); +}); diff --git a/Source/JavaScript/components/for_properties/when_a_property_has_the_wrong_type.ts b/Source/JavaScript/components/for_properties/when_a_property_has_the_wrong_type.ts new file mode 100644 index 0000000..6e30405 --- /dev/null +++ b/Source/JavaScript/components/for_properties/when_a_property_has_the_wrong_type.ts @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { + arrayProperty, + booleanProperty, + numberProperty, + objectArrayProperty, + objectProperty, + stringArrayProperty, + stringProperty, + unionProperty, +} from '../properties'; + +describe('when a property has the wrong type', () => { + const properties: Record = { + title: 42, + panel: 'yes', + rows: 'ten', + notANumber: Number.NaN, + options: 'first,second', + schema: ['not', 'an', 'object'], + orientation: 'diagonal', + fields: ['name', 7, 'number'], + entries: [{ key: 'a' }, 'not an object'], + }; + + it('should not read a number as a string', () => (stringProperty(properties, 'title') === undefined).should.be.true); + it('should not read a string as a boolean', () => (booleanProperty(properties, 'panel') === undefined).should.be.true); + it('should not read a string as a number', () => (numberProperty(properties, 'rows') === undefined).should.be.true); + it('should not read NaN as a number', () => (numberProperty(properties, 'notANumber') === undefined).should.be.true); + it('should not read a string as an array', () => (arrayProperty(properties, 'options') === undefined).should.be.true); + it('should not read an array as an object', () => (objectProperty(properties, 'schema') === undefined).should.be.true); + + it('should reject a value outside the allowed union', () => + (unionProperty(properties, 'orientation', ['vertical', 'horizontal']) === undefined).should.be.true); + + it('should keep only the string elements of a string array', () => stringArrayProperty(properties, 'fields')!.should.deep.equal(['name', 'number'])); + + it('should keep only the object elements of an object array', () => objectArrayProperty(properties, 'entries')!.should.deep.equal([{ key: 'a' }])); +}); diff --git a/Source/JavaScript/components/for_properties/when_a_property_is_missing.ts b/Source/JavaScript/components/for_properties/when_a_property_is_missing.ts new file mode 100644 index 0000000..2940f0c --- /dev/null +++ b/Source/JavaScript/components/for_properties/when_a_property_is_missing.ts @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { + arrayProperty, + booleanProperty, + numberProperty, + objectArrayProperty, + objectProperty, + stringArrayProperty, + stringProperty, + unionProperty, +} from '../properties'; + +describe('when a property is missing', () => { + const properties: Record = {}; + + it('should read no string', () => (stringProperty(properties, 'title') === undefined).should.be.true); + it('should read no boolean', () => (booleanProperty(properties, 'panel') === undefined).should.be.true); + it('should read no number', () => (numberProperty(properties, 'rows') === undefined).should.be.true); + it('should read no array', () => (arrayProperty(properties, 'options') === undefined).should.be.true); + it('should read no string array', () => (stringArrayProperty(properties, 'fields') === undefined).should.be.true); + it('should read no object array', () => (objectArrayProperty(properties, 'options') === undefined).should.be.true); + it('should read no object', () => (objectProperty(properties, 'schema') === undefined).should.be.true); + it('should read no union value', () => (unionProperty(properties, 'orientation', ['vertical', 'horizontal']) === undefined).should.be.true); +}); diff --git a/Source/JavaScript/components/for_properties/when_a_property_is_present.ts b/Source/JavaScript/components/for_properties/when_a_property_is_present.ts new file mode 100644 index 0000000..0cc2425 --- /dev/null +++ b/Source/JavaScript/components/for_properties/when_a_property_is_present.ts @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { + arrayProperty, + booleanProperty, + numberProperty, + objectArrayProperty, + objectProperty, + stringArrayProperty, + stringProperty, + unionProperty, +} from '../properties'; + +describe('when a property is present', () => { + const properties: Record = { + title: 'Invoices', + panel: false, + rows: 0, + options: [{ label: 'Draft', value: 'draft' }], + fields: ['number', 'customer'], + schema: { type: 'object' }, + orientation: 'vertical', + }; + + it('should read the string', () => stringProperty(properties, 'title')!.should.equal('Invoices')); + it('should read a false boolean rather than treating it as absent', () => booleanProperty(properties, 'panel')!.should.equal(false)); + it('should read a zero number rather than treating it as absent', () => numberProperty(properties, 'rows')!.should.equal(0)); + it('should read the array', () => arrayProperty(properties, 'options')!.should.have.lengthOf(1)); + it('should read the string array', () => stringArrayProperty(properties, 'fields')!.should.deep.equal(['number', 'customer'])); + it('should read the object array', () => objectArrayProperty(properties, 'options')!.should.deep.equal([{ label: 'Draft', value: 'draft' }])); + it('should read the object', () => objectProperty(properties, 'schema')!.should.deep.equal({ type: 'object' })); + + it('should read a value that is in the allowed union', () => + unionProperty(properties, 'orientation', ['vertical', 'horizontal'])!.should.equal('vertical')); +}); diff --git a/Source/JavaScript/components/forms/SceneCommandForm.tsx b/Source/JavaScript/components/forms/SceneCommandForm.tsx new file mode 100644 index 0000000..98cbb25 --- /dev/null +++ b/Source/JavaScript/components/forms/SceneCommandForm.tsx @@ -0,0 +1,45 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ComponentType, lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { ArcRuntimeBoundary, BindingKind, BoundConstructor, MissingBinding, resolveElementBinding } from '../bindings'; +import { stringArrayProperty } from '../properties'; + +interface AutoCommandFormElementProps { + command: BoundConstructor; + exclude?: string[]; +} + +/** + * `AutoCommandForm` types `exclude` as `(keyof TCommand)[]`, which collapses to `never[]` when the + * command type is only known at runtime - as it always is here, since the class arrives from the binding + * registry rather than from a type annotation. The conversion states the shape this adapter actually + * passes; the underlying component reads `exclude` as property names either way. + */ +const AutoCommandForm = lazy(async () => ({ + default: (await import('@cratis/components/CommandForm')).AutoCommandForm as unknown as ComponentType, +})); + +/** + * The `Cratis.Components:commandForm` component - `AutoCommandForm` from `@cratis/components/CommandForm`. + * + * `AutoCommandForm` rather than `CommandForm`, because a screen that had to list every field by hand + * would go stale the moment a property is added to the command on the backend. `AutoCommandForm` reads + * the command's own property descriptors and picks a field component per property type, so the form + * follows the command - which is the same guarantee Arc's generated proxies give the rest of the stack. + * + * A screen that does want to place fields itself puts the field components from this package in the + * `content` slot and names them individually; `exclude` keeps `AutoCommandForm` from generating a second + * copy of anything placed that way. + */ +export function SceneCommandForm({ element }: RegisteredComponentProps) { + const { name, target } = resolveElementBinding(element, BindingKind.Command); + if (!target) return ; + + return ( + + + + ); +} diff --git a/Source/JavaScript/components/forms/fields/CommandFormField.tsx b/Source/JavaScript/components/forms/fields/CommandFormField.tsx new file mode 100644 index 0000000..5acc40d --- /dev/null +++ b/Source/JavaScript/components/forms/fields/CommandFormField.tsx @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ReactNode } from 'react'; +import { ExternalComponent } from '@cratis/scene.model'; +import { ArcRuntimeBoundary, Placeholder } from '../../bindings'; +import { FieldBinding } from './FieldBinding'; +import { resolveFieldBinding } from './resolveFieldBinding'; + +export interface CommandFormFieldProps { + /** The element being adapted, read for its `property`, `title` and `description`. */ + element: ExternalComponent; + + /** Renders the real `@cratis/components` field, given the binding built from the element. */ + children: (binding: FieldBinding) => ReactNode; +} + +/** + * The part every command form field adapter does identically: resolve the element's `property` into a + * {@link FieldBinding}, place a visible placeholder when there is none, and isolate the lazily loaded + * Arc-bound field behind an {@link ArcRuntimeBoundary}. + * + * Factored out rather than repeated twelve times so that the behavior a screen author depends on - an + * unbound field shows up as an unbound field, and one broken field does not take the form with it - is + * defined once and cannot drift between field types. + */ +export function CommandFormField({ element, children }: CommandFormFieldProps) { + const binding = resolveFieldBinding(element); + if (!binding) return ; + + return {children(binding)}; +} diff --git a/Source/JavaScript/components/forms/fields/FieldBinding.ts b/Source/JavaScript/components/forms/fields/FieldBinding.ts new file mode 100644 index 0000000..248ad61 --- /dev/null +++ b/Source/JavaScript/components/forms/fields/FieldBinding.ts @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * What every `@cratis/components` command form field needs in order to bind itself to one property of + * the command the surrounding form is editing. + * + * The library's fields take an *accessor* (`value={command => command.name}`) rather than a property + * name, so that a hand-written form is typechecked end to end against the generated command proxy. A + * screen has no types to check against - it carries the property name as a string - so this is where the + * name becomes the accessor the field expects. + */ +export interface FieldBinding { + /** + * Reads the bound property off the command instance the form hands in. Built from the screen's + * `property` name, which is the only form the binding can take once it has been through a `.play` + * file. + */ + value: (instance: Record) => unknown; + + /** The label rendered with the field. */ + title: string; + + /** Helper text rendered under the field, when the screen supplies any. */ + description?: string; +} diff --git a/Source/JavaScript/components/forms/fields/SceneCalendarField.tsx b/Source/JavaScript/components/forms/fields/SceneCalendarField.tsx new file mode 100644 index 0000000..2710d54 --- /dev/null +++ b/Source/JavaScript/components/forms/fields/SceneCalendarField.tsx @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { booleanProperty, stringProperty, unionProperty } from '../../properties'; +import { CommandFormField } from './CommandFormField'; + +const CalendarField = lazy(async () => ({ default: (await import('@cratis/components/CommandForm')).CalendarField })); + +/** Whether times are shown on a 12- or 24-hour clock. */ +const hourFormats = ['12', '24'] as const; + +/** + * The `Cratis.Components:calendarField` component - `CalendarField` from `@cratis/components/CommandForm`. + * + * Binds to a `Date` property, so the command receives a real date rather than the string an + * `` produces - which matters because the backend's command record is typed and a + * string would fail model binding rather than the field. + * + * `minDate` and `maxDate` are deliberately not exposed: they would have to come out of the property bag + * as strings and be parsed here, and a bound range that silently misparses is worse than no bound range. + * A date range that has to be enforced belongs in the command's validator, where it is authoritative. + */ +export function SceneCalendarField({ element }: RegisteredComponentProps) { + return ( + + {binding => ( + + )} + + ); +} diff --git a/Source/JavaScript/components/forms/fields/SceneCheckboxField.tsx b/Source/JavaScript/components/forms/fields/SceneCheckboxField.tsx new file mode 100644 index 0000000..22f74b5 --- /dev/null +++ b/Source/JavaScript/components/forms/fields/SceneCheckboxField.tsx @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { stringProperty } from '../../properties'; +import { CommandFormField } from './CommandFormField'; + +const CheckboxField = lazy(async () => ({ default: (await import('@cratis/components/CommandForm')).CheckboxField })); + +/** + * The `Cratis.Components:checkboxField` component - `CheckboxField` from `@cratis/components/CommandForm`. + * + * `label` is the text beside the box and is distinct from `title`, which is the field's own label above + * it - a checkbox usually wants only one of the two, and which one is a layout decision the screen makes. + */ +export function SceneCheckboxField({ element }: RegisteredComponentProps) { + return ( + + {binding => ( + + )} + + ); +} diff --git a/Source/JavaScript/components/forms/fields/SceneChipsField.tsx b/Source/JavaScript/components/forms/fields/SceneChipsField.tsx new file mode 100644 index 0000000..41b011b --- /dev/null +++ b/Source/JavaScript/components/forms/fields/SceneChipsField.tsx @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { booleanProperty, numberProperty, stringProperty } from '../../properties'; +import { CommandFormField } from './CommandFormField'; + +const ChipsField = lazy(async () => ({ default: (await import('@cratis/components/CommandForm')).ChipsField })); + +/** + * The `Cratis.Components:chipsField` component - `ChipsField` from `@cratis/components/CommandForm`. + * + * A free-form list of strings - tags, labels, recipients - where the values are typed rather than picked. + * `multiSelectField` is the right choice whenever the values come from a known set; this one exists for + * when they do not. + */ +export function SceneChipsField({ element }: RegisteredComponentProps) { + return ( + + {binding => ( + + )} + + ); +} diff --git a/Source/JavaScript/components/forms/fields/SceneColorPickerField.tsx b/Source/JavaScript/components/forms/fields/SceneColorPickerField.tsx new file mode 100644 index 0000000..a3736fd --- /dev/null +++ b/Source/JavaScript/components/forms/fields/SceneColorPickerField.tsx @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { booleanProperty, stringProperty } from '../../properties'; +import { CommandFormField } from './CommandFormField'; + +const ColorPickerField = lazy(async () => ({ default: (await import('@cratis/components/CommandForm')).ColorPickerField })); + +/** + * The `Cratis.Components:colorPickerField` component - `ColorPickerField` from + * `@cratis/components/CommandForm`. + * + * Binds a color to a string property. `inline` decides whether the picker is always open or opens from a + * swatch, which is a layout decision rather than a behavioral one. + */ +export function SceneColorPickerField({ element }: RegisteredComponentProps) { + return ( + + {binding => ( + + )} + + ); +} diff --git a/Source/JavaScript/components/forms/fields/SceneDropdownField.tsx b/Source/JavaScript/components/forms/fields/SceneDropdownField.tsx new file mode 100644 index 0000000..a28020c --- /dev/null +++ b/Source/JavaScript/components/forms/fields/SceneDropdownField.tsx @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { objectArrayProperty, stringProperty } from '../../properties'; +import { CommandFormField } from './CommandFormField'; + +const DropdownField = lazy(async () => ({ default: (await import('@cratis/components/CommandForm')).DropdownField })); + +/** + * The `Cratis.Components:dropdownField` component - `DropdownField` from `@cratis/components/CommandForm`. + * + * Single selection from a fixed list the screen carries inline. `optionLabel` and `optionValue` default + * to `label` and `value`, the shape an authored list takes when nobody says otherwise, so the common + * case needs only `options`. + * + * Options that have to come from the backend are a different component: bind the list to a query with + * `dataTable`, or register the lookup as its own query. A property bag is the wrong place for data. + */ +export function SceneDropdownField({ element }: RegisteredComponentProps) { + return ( + + {binding => ( + + )} + + ); +} diff --git a/Source/JavaScript/components/forms/fields/SceneInputTextField.tsx b/Source/JavaScript/components/forms/fields/SceneInputTextField.tsx new file mode 100644 index 0000000..0aa490f --- /dev/null +++ b/Source/JavaScript/components/forms/fields/SceneInputTextField.tsx @@ -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. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { stringProperty, unionProperty } from '../../properties'; +import { CommandFormField } from './CommandFormField'; + +const InputTextField = lazy(async () => ({ default: (await import('@cratis/components/CommandForm')).InputTextField })); + +/** + * The HTML input types `InputTextField` supports, as a tuple so `unionProperty` narrows an authored + * string to exactly one of them and falls back to the component's own default for anything else. + */ +const inputTypes = ['text', 'email', 'password', 'color', 'date', 'datetime-local', 'time', 'url', 'tel', 'search'] as const; + +/** + * The `Cratis.Components:inputTextField` component - `InputTextField` from `@cratis/components/CommandForm`. + * + * The single-line text field, and the one a screen reaches for most. `type` carries all the way through + * to the HTML input, so `email`, `password` and `url` fields are this component too rather than separate + * names - the browser's own keyboard and validation behavior is what differs, not the binding. + */ +export function SceneInputTextField({ element }: RegisteredComponentProps) { + return ( + + {binding => ( + + )} + + ); +} diff --git a/Source/JavaScript/components/forms/fields/SceneMultiSelectField.tsx b/Source/JavaScript/components/forms/fields/SceneMultiSelectField.tsx new file mode 100644 index 0000000..0544cb2 --- /dev/null +++ b/Source/JavaScript/components/forms/fields/SceneMultiSelectField.tsx @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { booleanProperty, numberProperty, objectArrayProperty, stringProperty, unionProperty } from '../../properties'; +import { CommandFormField } from './CommandFormField'; + +const MultiSelectField = lazy(async () => ({ default: (await import('@cratis/components/CommandForm')).MultiSelectField })); + +/** How the current selection is summarized in the closed control. */ +const displayModes = ['comma', 'chip'] as const; + +/** + * The `Cratis.Components:multiSelectField` component - `MultiSelectField` from + * `@cratis/components/CommandForm`. + * + * The many-valued counterpart to `dropdownField`, binding to a collection property on the command rather + * than a scalar one. + */ +export function SceneMultiSelectField({ element }: RegisteredComponentProps) { + return ( + + {binding => ( + + )} + + ); +} diff --git a/Source/JavaScript/components/forms/fields/SceneNumberField.tsx b/Source/JavaScript/components/forms/fields/SceneNumberField.tsx new file mode 100644 index 0000000..2ccfc38 --- /dev/null +++ b/Source/JavaScript/components/forms/fields/SceneNumberField.tsx @@ -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 { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { numberProperty, stringProperty } from '../../properties'; +import { CommandFormField } from './CommandFormField'; + +const NumberField = lazy(async () => ({ default: (await import('@cratis/components/CommandForm')).NumberField })); + +/** + * The `Cratis.Components:numberField` component - `NumberField` from `@cratis/components/CommandForm`. + * + * PrimeReact's `InputNumber` behind the command binding, so the bound property stays a number rather + * than the string an `` would hand back. `min`, `max` and `step` are client-side + * affordances only - the authoritative check is still the backend's validation on the command. + */ +export function SceneNumberField({ element }: RegisteredComponentProps) { + return ( + + {binding => ( + + )} + + ); +} diff --git a/Source/JavaScript/components/forms/fields/SceneRadioButtonField.tsx b/Source/JavaScript/components/forms/fields/SceneRadioButtonField.tsx new file mode 100644 index 0000000..fb0500f --- /dev/null +++ b/Source/JavaScript/components/forms/fields/SceneRadioButtonField.tsx @@ -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. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { numberProperty, stringProperty } from '../../properties'; +import { CommandFormField } from './CommandFormField'; + +const RadioButtonField = lazy(async () => ({ default: (await import('@cratis/components/CommandForm')).RadioButtonField })); + +/** + * The `Cratis.Components:radioButtonField` component - `RadioButtonField` from + * `@cratis/components/CommandForm`. + * + * One button of a mutually exclusive set, for a screen that needs the choices placed individually rather + * than as a block. `buttonValue` is the value this particular button writes to the bound property, and + * the whole set is tied together by every button naming the same `property`. + * + * `buttonValue` is read as a string first and a number second, since a choice is usually a string but a + * numeric code is common enough that forcing it through as `'1'` would bind the wrong value. + */ +export function SceneRadioButtonField({ element }: RegisteredComponentProps) { + const buttonValue = stringProperty(element.properties, 'buttonValue') ?? numberProperty(element.properties, 'buttonValue') ?? ''; + + return ( + + {binding => ( + + )} + + ); +} diff --git a/Source/JavaScript/components/forms/fields/SceneRadioGroupField.tsx b/Source/JavaScript/components/forms/fields/SceneRadioGroupField.tsx new file mode 100644 index 0000000..3d38d71 --- /dev/null +++ b/Source/JavaScript/components/forms/fields/SceneRadioGroupField.tsx @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { objectArrayProperty, stringProperty, unionProperty } from '../../properties'; +import { CommandFormField } from './CommandFormField'; + +const RadioGroupField = lazy(async () => ({ default: (await import('@cratis/components/CommandForm')).RadioGroupField })); + +/** Whether the group's buttons stack or sit in a row. */ +const layouts = ['horizontal', 'vertical'] as const; + +/** + * The `Cratis.Components:radioGroupField` component - `RadioGroupField` from + * `@cratis/components/CommandForm`. + * + * A whole mutually exclusive choice from one options list, which is what a screen almost always wants. + * Use `radioButtonField` only when the individual buttons have to be placed apart from one another. + */ +export function SceneRadioGroupField({ element }: RegisteredComponentProps) { + return ( + + {binding => ( + + )} + + ); +} diff --git a/Source/JavaScript/components/forms/fields/SceneSliderField.tsx b/Source/JavaScript/components/forms/fields/SceneSliderField.tsx new file mode 100644 index 0000000..f36ad4b --- /dev/null +++ b/Source/JavaScript/components/forms/fields/SceneSliderField.tsx @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { numberProperty, stringProperty } from '../../properties'; +import { CommandFormField } from './CommandFormField'; + +const SliderField = lazy(async () => ({ default: (await import('@cratis/components/CommandForm')).SliderField })); + +/** + * The `Cratis.Components:sliderField` component - `SliderField` from `@cratis/components/CommandForm`. + * + * A numeric field where the range itself is the point - a percentage, a threshold, a weighting. Prefer + * `numberField` whenever the exact value matters more than its position in a range; a slider trades + * precision for a sense of scale, and that is only ever a deliberate choice. + */ +export function SceneSliderField({ element }: RegisteredComponentProps) { + return ( + + {binding => ( + + )} + + ); +} diff --git a/Source/JavaScript/components/forms/fields/SceneTextAreaField.tsx b/Source/JavaScript/components/forms/fields/SceneTextAreaField.tsx new file mode 100644 index 0000000..f333378 --- /dev/null +++ b/Source/JavaScript/components/forms/fields/SceneTextAreaField.tsx @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { numberProperty, stringProperty } from '../../properties'; +import { CommandFormField } from './CommandFormField'; + +const TextAreaField = lazy(async () => ({ default: (await import('@cratis/components/CommandForm')).TextAreaField })); + +/** + * The `Cratis.Components:textAreaField` component - `TextAreaField` from `@cratis/components/CommandForm`. + * + * The multi-line counterpart to `inputTextField`. A separate name rather than a `multiline` flag on that + * one, because the two wrap different PrimeReact inputs and take different sizing props - collapsing + * them would mean a `rows` property that is meaningless half the time. + */ +export function SceneTextAreaField({ element }: RegisteredComponentProps) { + return ( + + {binding => ( + + )} + + ); +} diff --git a/Source/JavaScript/components/forms/fields/for_CommandFormField/when_the_field_names_no_property.tsx b/Source/JavaScript/components/forms/fields/for_CommandFormField/when_the_field_names_no_property.tsx new file mode 100644 index 0000000..fac494e --- /dev/null +++ b/Source/JavaScript/components/forms/fields/for_CommandFormField/when_the_field_names_no_property.tsx @@ -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 { render, screen } from '@testing-library/react'; +import { externalComponent } from '../../../given'; +import { SceneInputTextField } from '../SceneInputTextField'; + +describe('when the field names no property', () => { + beforeEach(() => { + render(); + }); + + it('should render a placeholder rather than a field bound to nothing', () => + screen.getByText("Missing 'property' on Cratis.Components:inputTextField").should.exist); +}); diff --git a/Source/JavaScript/components/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.ts b/Source/JavaScript/components/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.ts new file mode 100644 index 0000000..75af7a8 --- /dev/null +++ b/Source/JavaScript/components/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.ts @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { externalComponent } from '../../../given'; +import { resolveFieldBinding } from '../resolveFieldBinding'; + +describe('when resolving a field binding', () => { + describe('and the element names a property with a title and description', () => { + const binding = resolveFieldBinding( + externalComponent('Cratis.Components:inputTextField', { + property: 'customerName', + title: 'Customer', + description: 'Who the invoice is for', + }) + )!; + + it('should read the named property off the command instance', () => binding.value({ customerName: 'Acme' })!.should.equal('Acme')); + it('should use the declared title', () => binding.title.should.equal('Customer')); + it('should carry the description', () => binding.description!.should.equal('Who the invoice is for')); + }); + + describe('and the element names only a property', () => { + const binding = resolveFieldBinding(externalComponent('Cratis.Components:inputTextField', { property: 'customerName' }))!; + + it('should fall back to the property name as the title', () => binding.title.should.equal('customerName')); + it('should carry no description', () => (binding.description === undefined).should.be.true); + }); + + describe('and the element names no property', () => { + const binding = resolveFieldBinding(externalComponent('Cratis.Components:inputTextField', { title: 'Customer' })); + + it('should resolve to nothing rather than an accessor that reads nothing', () => (binding === undefined).should.be.true); + }); +}); diff --git a/Source/JavaScript/components/forms/fields/index.ts b/Source/JavaScript/components/forms/fields/index.ts new file mode 100644 index 0000000..8e5a1f0 --- /dev/null +++ b/Source/JavaScript/components/forms/fields/index.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export * from './FieldBinding'; +export * from './resolveFieldBinding'; +export * from './CommandFormField'; +export * from './SceneInputTextField'; +export * from './SceneNumberField'; +export * from './SceneCheckboxField'; +export * from './SceneTextAreaField'; +export * from './SceneDropdownField'; +export * from './SceneSliderField'; +export * from './SceneCalendarField'; +export * from './SceneColorPickerField'; +export * from './SceneMultiSelectField'; +export * from './SceneChipsField'; +export * from './SceneRadioButtonField'; +export * from './SceneRadioGroupField'; diff --git a/Source/JavaScript/components/forms/fields/resolveFieldBinding.ts b/Source/JavaScript/components/forms/fields/resolveFieldBinding.ts new file mode 100644 index 0000000..9fdd639 --- /dev/null +++ b/Source/JavaScript/components/forms/fields/resolveFieldBinding.ts @@ -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. + +import { ExternalComponent } from '@cratis/scene.model'; +import { stringProperty } from '../../properties'; +import { FieldBinding } from './FieldBinding'; + +/** + * Turns an element's `property` name into the {@link FieldBinding} a `@cratis/components` field takes, + * or `undefined` when the screen never named a property. + * + * `undefined` rather than a default accessor, because a field bound to nothing is not a field with an + * empty value - it is a field that would silently never read or write anything, and the screen author + * needs to see that. The caller renders a placeholder instead. + * + * The label falls back to the property name, so a field is legible before anyone has written a `title`. + */ +export function resolveFieldBinding(element: ExternalComponent): FieldBinding | undefined { + const property = stringProperty(element.properties, 'property'); + if (property === undefined) return undefined; + + return { + value: (instance: Record) => instance[property], + title: stringProperty(element.properties, 'title') ?? property, + description: stringProperty(element.properties, 'description'), + }; +} diff --git a/Source/JavaScript/components/forms/index.ts b/Source/JavaScript/components/forms/index.ts new file mode 100644 index 0000000..11432d0 --- /dev/null +++ b/Source/JavaScript/components/forms/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export * from './SceneCommandForm'; +export * from './fields'; diff --git a/Source/JavaScript/components/given.ts b/Source/JavaScript/components/given.ts new file mode 100644 index 0000000..8b77986 --- /dev/null +++ b/Source/JavaScript/components/given.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 { ExternalComponent, HorizontalAlignment, VerticalAlignment, Visibility } from '@cratis/scene.model'; + +/** + * Builds the {@link ExternalComponent} a renderer would hand an adapter, with every inherited layout + * property at its neutral value. + * + * An adapter only ever reads `componentName`, `properties` and `slots`, but the element it receives is a + * fully-formed `FrameworkElement`. Constructing one by hand in every spec would bury the two or three + * properties a scenario is actually about under twenty that are never read - so the noise lives here, + * once, and a spec says only what makes it different. + */ +export function externalComponent(componentName: string, properties: Record = {}): ExternalComponent { + return { + id: componentName, + name: componentName, + componentName, + properties, + slots: {}, + visibility: Visibility.Visible, + isEnabled: true, + opacity: 1, + size: {}, + zIndex: 0, + minimumSize: {}, + maximumSize: {}, + margin: { left: 0, top: 0, right: 0, bottom: 0 }, + horizontalAlignment: HorizontalAlignment.Stretch, + verticalAlignment: VerticalAlignment.Stretch, + }; +} diff --git a/Source/JavaScript/components/index.ts b/Source/JavaScript/components/index.ts new file mode 100644 index 0000000..c271c8e --- /dev/null +++ b/Source/JavaScript/components/index.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. + +export * from './properties'; +export * from './bindings'; +export * from './pages'; +export * from './data'; +export * from './forms'; +export * from './dialogs'; +export * from './common'; +export * from './editors'; +export * from './toolbar'; +export * from './cratisComponents'; +export * from './cratisComponentsPackage'; diff --git a/Source/JavaScript/components/package.json b/Source/JavaScript/components/package.json new file mode 100644 index 0000000..8f29115 --- /dev/null +++ b/Source/JavaScript/components/package.json @@ -0,0 +1,68 @@ +{ + "name": "@cratis/scene.components", + "version": "1.0.0", + "description": "The Cratis Components package: exposes @cratis/components' Arc-bound data, form and dialog composites as Scene components. Built on PrimeReact and Tailwind, and declares both as dependencies.", + "author": "Cratis", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/Cratis/Scene.git" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "**/*.ts", + "**/*.tsx", + "**/*.css" + ], + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/esm/index.d.ts", + "require": "./dist/cjs/index.js", + "import": "./dist/esm/index.js" + }, + "./styles": "./theme/sceneTokenBridge.css" + }, + "scripts": { + "prepare": "yarn g:build", + "clean": "yarn g:clean", + "build": "yarn g:build", + "lint": "yarn g:lint", + "lint:ci": "yarn g:lint:ci", + "test": "yarn g:test", + "ci": "yarn g:ci", + "up": "yarn g:up", + "dev": "storybook dev -p 6006", + "build-storybook": "storybook build" + }, + "dependencies": { + "@cratis/scene.engine": "1.0.0", + "@cratis/scene.model": "1.0.0", + "@cratis/scene.react": "1.0.0" + }, + "devDependencies": { + "@cratis/components": "^2.8.1", + "@cratis/scene.engine": "1.0.0", + "@cratis/scene.model": "1.0.0", + "@cratis/scene.react": "1.0.0", + "@storybook/addon-links": "^10.4.1", + "@storybook/react": "^10.4.1", + "@storybook/react-vite": "^10.4.1", + "primereact": "10.9.8", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "storybook": "^10.4.1" + }, + "peerDependencies": { + "@cratis/components": ">=2.8.1 <3", + "primereact": "^10.9.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } +} diff --git a/Source/JavaScript/components/pages/SceneDataPage.tsx b/Source/JavaScript/components/pages/SceneDataPage.tsx new file mode 100644 index 0000000..5f9dd1b --- /dev/null +++ b/Source/JavaScript/components/pages/SceneDataPage.tsx @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding } from '../bindings'; +import { booleanProperty, stringArrayProperty, stringProperty } from '../properties'; + +const DataPage = lazy(async () => ({ default: (await import('@cratis/components/DataPage')).DataPage })); + +/** + * The `Cratis.Components:dataPage` component - `DataPage` from `@cratis/components/DataPage`. + * + * The library's whole list-screen composite in one name: title bar, menubar, filterable table and + * optional details pane, all driven from a single query. It is the single highest-leverage thing this + * package exposes, because reproducing it out of `table` and `toolbar` in a screen would be pages of + * modeling for a worse result. + * + * The `query` property names an Arc query proxy; the columns and menu items come from the `content` + * slot as `DataPage.Columns` / `DataPage.MenuItems` children. + */ +export function SceneDataPage({ element, slots }: RegisteredComponentProps) { + const { name, target } = resolveElementBinding(element, BindingKind.Query); + if (!target) return ; + + return ( + + + {slots.content} + + + ); +} diff --git a/Source/JavaScript/components/pages/SceneFormElement.tsx b/Source/JavaScript/components/pages/SceneFormElement.tsx new file mode 100644 index 0000000..c185825 --- /dev/null +++ b/Source/JavaScript/components/pages/SceneFormElement.tsx @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { FormElement, IconDisplay } from '@cratis/components/Common'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { stringProperty } from '../properties'; + +/** + * The `Cratis.Components:formElement` component - `FormElement` from `@cratis/components/Common`. + * + * Pairs an input with a leading icon addon so a row of unrelated inputs still lines up. The icon can be + * given two ways, and both are worth supporting: the `icon` property for the common case of a PrimeIcons + * class name, and an `icon` slot for a screen that wants a real component there. The slot wins when both + * are present, since a slot is the more specific statement. + */ +export function SceneFormElement({ element, slots }: RegisteredComponentProps) { + const icon = stringProperty(element.properties, 'icon'); + const iconContent = slots.icon?.length ? slots.icon : icon === undefined ? undefined : ; + + return {slots.content}; +} diff --git a/Source/JavaScript/components/pages/ScenePage.tsx b/Source/JavaScript/components/pages/ScenePage.tsx new file mode 100644 index 0000000..86e5ff6 --- /dev/null +++ b/Source/JavaScript/components/pages/ScenePage.tsx @@ -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. + +import { Page } from '@cratis/components/Common'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { booleanProperty, stringProperty } from '../properties'; + +/** + * The `Cratis.Components:page` component - `Page` from `@cratis/components/Common`. + * + * The library's page primitive rather than a plain `div`, because it is what every other component in + * the library is laid out inside: a full-height flex column whose optional `panel` chrome is drawn from + * the same `--cratis-*` tokens as the tables and dialogs it contains. A screen that wraps its content in + * this gets that consistency for free; one that wraps it in a `div` has to reinvent it and will drift. + * + * Imported statically - `Page` is one of the library's Arc-free components, so it costs a screen nothing + * to use it without an Arc runtime present. + */ +export function ScenePage({ element, slots }: RegisteredComponentProps) { + return ( + + {slots.content} + + ); +} diff --git a/Source/JavaScript/components/pages/for_ScenePage/when_rendering_a_page.tsx b/Source/JavaScript/components/pages/for_ScenePage/when_rendering_a_page.tsx new file mode 100644 index 0000000..dcfd81b --- /dev/null +++ b/Source/JavaScript/components/pages/for_ScenePage/when_rendering_a_page.tsx @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { render, screen } from '@testing-library/react'; +import { externalComponent } from '../../given'; +import { ScenePage } from '../ScenePage'; + +describe('when rendering a page', () => { + describe('and the screen asks for the title to be shown', () => { + beforeEach(() => { + const element = externalComponent('Cratis.Components:page', { title: 'Invoices', showTitle: true }); + render(Body] }} />); + }); + + it('should render the title as the heading', () => screen.getByRole('heading', { name: 'Invoices' }).should.exist); + it('should render the content slot', () => screen.getByText('Body').should.exist); + }); + + describe('and the screen leaves the title hidden', () => { + beforeEach(() => { + const element = externalComponent('Cratis.Components:page', { title: 'Invoices' }); + render(Body] }} />); + }); + + it('should render no heading', () => (screen.queryByRole('heading') === null).should.be.true); + it('should still render the content slot', () => screen.getByText('Body').should.exist); + }); + + describe('and the screen sets the title to something that is not a string', () => { + beforeEach(() => { + const element = externalComponent('Cratis.Components:page', { title: 42, showTitle: true }); + render(); + }); + + it('should fall back to an empty title rather than rendering the wrong type', () => + screen.getByRole('heading').textContent!.should.equal('')); + }); +}); diff --git a/Source/JavaScript/components/pages/index.ts b/Source/JavaScript/components/pages/index.ts new file mode 100644 index 0000000..0af2866 --- /dev/null +++ b/Source/JavaScript/components/pages/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export * from './ScenePage'; +export * from './SceneDataPage'; +export * from './SceneFormElement'; diff --git a/Source/JavaScript/components/properties.ts b/Source/JavaScript/components/properties.ts new file mode 100644 index 0000000..29cf500 --- /dev/null +++ b/Source/JavaScript/components/properties.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. + +/** + * Narrow readers for an {@link ExternalComponent}'s `properties` bag. + * + * The bag is `Record` because it survives a round trip through JSON - a `.play` screen + * is authored as text, compiled by Stage, and re-read by Studio, and none of those steps carry a + * TypeScript type with it. An adapter therefore has to narrow every value it reads, and the tempting + * shortcut (`element.properties.title as string`) is a lie the compiler will happily believe: a screen + * that writes `title: 42` then reaches the wrapped component as a number and fails somewhere far away. + * + * These helpers all answer the same way - the value when it really is of the asked-for type, `undefined` + * otherwise - so an adapter reads a property and applies its own default in one expression, and a + * mistyped property degrades to the default instead of corrupting the render. + */ + +/** + * Reads a string property, or `undefined` when it is absent or not a string. + */ +export function stringProperty(properties: Record, name: string): string | undefined { + const value = properties[name]; + return typeof value === 'string' ? value : undefined; +} + +/** + * Reads a boolean property, or `undefined` when it is absent or not a boolean. + * + * Deliberately not truthiness: a screen that sets `showTitle: 0` means something different from one that + * leaves it out, and only the second should fall back to the adapter's default. + */ +export function booleanProperty(properties: Record, name: string): boolean | undefined { + const value = properties[name]; + return typeof value === 'boolean' ? value : undefined; +} + +/** + * Reads a numeric property, or `undefined` when it is absent, not a number, or `NaN`. + * + * `NaN` is excluded because it is the one number that makes every downstream comparison silently false - + * a slider with `min: NaN` renders without complaint and behaves as if it had no minimum at all. + */ +export function numberProperty(properties: Record, name: string): number | undefined { + const value = properties[name]; + return typeof value === 'number' && !Number.isNaN(value) ? value : undefined; +} + +/** + * Reads an array property, or `undefined` when it is absent or not an array. + * + * The element type is deliberately left as `unknown` - narrowing the elements is the caller's job, + * through {@link stringArrayProperty} or its own mapping - so this helper never has to guess what a + * heterogeneous list was supposed to be. + */ +export function arrayProperty(properties: Record, name: string): unknown[] | undefined { + const value = properties[name]; + return Array.isArray(value) ? value : undefined; +} + +/** + * Reads an array property and keeps only its string elements, or `undefined` when the property is absent + * or not an array. + * + * Non-string elements are dropped rather than rejecting the whole list, because these lists are + * column names, filter fields and the like: one bad entry should cost that one entry, not the entire + * table's filtering. + */ +export function stringArrayProperty(properties: Record, name: string): string[] | undefined { + return arrayProperty(properties, name)?.filter((value): value is string => typeof value === 'string'); +} + +/** + * Reads an object property as a plain record, or `undefined` when it is absent, `null`, or an array. + * + * Arrays are excluded even though `typeof [] === 'object'`, because every caller here wants a keyed + * object - a JSON schema, a document being edited - and an array reaching one of those would be a + * different bug entirely. + */ +export function objectProperty(properties: Record, name: string): Record | undefined { + const value = properties[name]; + return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : undefined; +} + +/** + * Reads an array property and keeps only its plain-object elements, or `undefined` when the property is + * absent or not an array. + * + * This is what an authored `options` list arrives as - `[{ label: 'Draft', value: 'draft' }, ...]` - and + * the same forgiving rule as {@link stringArrayProperty} applies: one malformed entry costs that entry, + * not the whole dropdown. + */ +export function objectArrayProperty(properties: Record, name: string): Record[] | undefined { + return arrayProperty(properties, name)?.filter( + (value): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) + ); +} + +/** + * Reads a string property that has to be one of a fixed set of values, or `undefined` when it is absent + * or outside the set. + * + * Several of the wrapped `@cratis/components` props are string literal unions (`'horizontal' | 'vertical'`, + * `'top' | 'right' | 'bottom' | 'left'`). This is what turns an arbitrary authored string into one of + * them without an assertion: an unrecognized value falls back to the component's own default rather than + * being forced through as a value the component never expects to see. + * + * @param allowed The permitted values, most usefully written as a `const` tuple so `T` infers as the union. + */ +export function unionProperty(properties: Record, name: string, allowed: readonly T[]): T | undefined { + const value = stringProperty(properties, name); + return value !== undefined && (allowed as readonly string[]).includes(value) ? (value as T) : undefined; +} diff --git a/Source/JavaScript/components/rollup.config.mjs b/Source/JavaScript/components/rollup.config.mjs new file mode 100644 index 0000000..95b1f95 --- /dev/null +++ b/Source/JavaScript/components/rollup.config.mjs @@ -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 { rollup } from '../../../rollup.config.mjs'; + +import pkg from './package.json' with { type: 'json' }; + +import path from "path"; + +const cjsPath = path.dirname(pkg.main); +const esmPath = path.dirname(pkg.module); +const tsconfigPath = path.join(import.meta.dirname, "tsconfig.json"); + +export default rollup(cjsPath, esmPath, tsconfigPath, pkg); diff --git a/Source/JavaScript/components/theme/sceneTokenBridge.css b/Source/JavaScript/components/theme/sceneTokenBridge.css new file mode 100644 index 0000000..bf13d3f --- /dev/null +++ b/Source/JavaScript/components/theme/sceneTokenBridge.css @@ -0,0 +1,72 @@ +/* Copyright (c) Cratis. All rights reserved. */ +/* Licensed under the MIT license. See LICENSE file in the project root for full license information. */ + +/* + * Scene design tokens -> Cratis Components CSS variables. + * + * `@cratis/components` reads a `--cratis-*` variable layer (its own `tokens.css`) rather than + * PrimeReact's variables directly, so that one build works across PrimeReact major versions: each token + * resolves the PrimeReact 11 design token first and falls back to the legacy version 10 theme variable. + * That indirection is the seam this file plugs into. + * + * Scene has its own vocabulary. A `Theme` carries semantic token names (`surface.card`, `text.color`), + * and `SceneThemeProvider` writes them onto its wrapping element as `--scene-*` custom properties. This + * stylesheet inserts those in front of the library's own chain, so a Scene theme drives + * `@cratis/components` without either side knowing the other exists: Scene never learns what + * `--cratis-surface-card` is, and the library never learns what a Scene theme is. + * + * --cratis-surface-card: var(--scene-surface-card, var(--p-content-background, var(--surface-card))); + * ^ Scene theme ^ PrimeReact v11 ^ v10 legacy + * + * Three consequences worth stating, because each one is a decision: + * + * - The Scene value is only a *first* preference. Every mapping keeps the library's original fallback + * chain behind it, so a theme that defines eight of the thirteen tokens leaves the other five exactly + * as the active PrimeReact theme had them, rather than blanking them. + * + * - The rules are scoped to the element a theme is applied to, never to `:root`. `applyThemeTokens` + * writes onto the `SceneThemeProvider`'s element, so `--scene-*` does not exist at the document root; + * a `:root` rule here would resolve every mapping to nothing and wipe out the PrimeReact fallbacks + * this file is supposed to preserve. Both attributes are matched because a host may apply a theme + * through `applyThemeTokens` directly (`data-scene-theme`) rather than through the provider + * (`data-scene-theme-root`). + * + * - Only tokens Scene actually has a name for are mapped. `--cratis-primary-500`, `--cratis-green-500` + * and the rest of the primitive palette are left untouched, so they keep resolving against the + * PrimeReact theme. Inventing Scene names for them would be asserting a vocabulary the themes on the + * other side do not have. + * + * The token names are the ones `@cratis/scene.primereact` themes are written in, and both packages + * intend them to stay one vocabulary. + */ + +[data-scene-theme-root], +[data-scene-theme] { + /* Surfaces */ + --cratis-surface-0: var(--scene-surface-card, var(--p-surface-0, var(--surface-0))); + --cratis-surface-100: var(--scene-surface-hover, var(--p-surface-100, var(--surface-100))); + --cratis-surface-ground: var(--scene-surface-background, var(--p-content-background, var(--surface-ground))); + --cratis-surface-section: var(--scene-surface-hover, var(--p-content-hover-background, var(--surface-section))); + --cratis-surface-card: var(--scene-surface-card, var(--p-content-background, var(--surface-card))); + --cratis-surface-overlay: var(--scene-surface-overlay, var(--p-overlay-modal-background, var(--surface-overlay))); + --cratis-surface-hover: var(--scene-surface-hover, var(--p-content-hover-background, var(--surface-hover))); + --cratis-surface-border: var(--scene-surface-border, var(--p-content-border-color, var(--surface-border))); + + /* Text */ + --cratis-text-color: var(--scene-text-color, var(--p-text-color, var(--text-color))); + --cratis-text-color-secondary: var(--scene-text-muted-color, var(--p-text-muted-color, var(--text-color-secondary))); + + /* Primary brand */ + --cratis-primary-color: var(--scene-primary-color, var(--p-primary-color, var(--primary-color))); + --cratis-primary-color-text: var(--scene-primary-contrast-color, var(--p-primary-contrast-color, var(--primary-color-text))); + + /* Highlight / selection */ + --cratis-highlight-bg: var(--scene-highlight-background, var(--p-highlight-background, var(--highlight-bg))); + --cratis-highlight-text-color: var(--scene-highlight-color, var(--p-highlight-color, var(--highlight-text-color))); + + /* Geometry */ + --cratis-border-radius: var(--scene-content-border-radius, var(--p-content-border-radius, var(--border-radius))); + + /* Effects */ + --cratis-focus-ring: var(--scene-focus-ring, var(--p-focus-ring-shadow, var(--focus-ring))); +} diff --git a/Source/JavaScript/components/toolbar/SceneToolbar.tsx b/Source/JavaScript/components/toolbar/SceneToolbar.tsx new file mode 100644 index 0000000..d81a781 --- /dev/null +++ b/Source/JavaScript/components/toolbar/SceneToolbar.tsx @@ -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. + +import { Toolbar } from '@cratis/components/Toolbar'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { booleanProperty, unionProperty } from '../properties'; + +/** Whether the toolbar runs down the side or across the top. */ +const orientations = ['vertical', 'horizontal'] as const; + +/** + * The `Cratis.Components:toolbar` component - `Toolbar` from `@cratis/components/Toolbar`. + * + * The container for a tool palette: it draws the rounded chrome and, more importantly, establishes the + * drag context every `toolbarButton` inside it reads. `draggable` therefore belongs here rather than on + * each button - it is a property of the palette, and setting it per button is how you end up with a + * palette that is half draggable. + */ +export function SceneToolbar({ element, slots }: RegisteredComponentProps) { + return ( + + {slots.content} + + ); +} diff --git a/Source/JavaScript/components/toolbar/SceneToolbarButton.tsx b/Source/JavaScript/components/toolbar/SceneToolbarButton.tsx new file mode 100644 index 0000000..73c31f3 --- /dev/null +++ b/Source/JavaScript/components/toolbar/SceneToolbarButton.tsx @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ToolbarButton } from '@cratis/components/Toolbar'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { booleanProperty, stringProperty, unionProperty } from '../properties'; + +/** Which side of the button its tooltip appears on. */ +const tooltipPositions = ['top', 'right', 'bottom', 'left'] as const; + +/** + * The `Cratis.Components:toolbarButton` component - `ToolbarButton` from `@cratis/components/Toolbar`. + * + * `title` is required by the underlying component and is not decoration: it is both the tooltip text and + * the accessible name, so a toolbar of icon-only buttons is still usable by anyone who cannot see the + * icons. It defaults to the `text` property rather than to an empty string, so a button that has a + * visible label is never left nameless. + */ +export function SceneToolbarButton({ element }: RegisteredComponentProps) { + const text = stringProperty(element.properties, 'text'); + + return ( + + ); +} diff --git a/Source/JavaScript/components/toolbar/SceneToolbarGroup.tsx b/Source/JavaScript/components/toolbar/SceneToolbarGroup.tsx new file mode 100644 index 0000000..36f619d --- /dev/null +++ b/Source/JavaScript/components/toolbar/SceneToolbarGroup.tsx @@ -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. + +import { ToolbarGroup } from '@cratis/components/Toolbar'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { stringProperty, unionProperty } from '../properties'; + +/** Whether the group's buttons stack or sit in a row. */ +const orientations = ['vertical', 'horizontal'] as const; + +/** + * The `Cratis.Components:toolbarGroup` component - `ToolbarGroup` from `@cratis/components/Toolbar`. + * + * Keeps related buttons together inside a toolbar. `slotName` opts the group into the library's toolbar + * slot mechanism, which lets content contributed from elsewhere in the application land in this group - + * the same idea as Scene's own contribution points, and the reason the property is worth exposing rather + * than treating the group as purely visual. + */ +export function SceneToolbarGroup({ element, slots }: RegisteredComponentProps) { + return ( + + {slots.content} + + ); +} diff --git a/Source/JavaScript/components/toolbar/SceneToolbarSeparator.tsx b/Source/JavaScript/components/toolbar/SceneToolbarSeparator.tsx new file mode 100644 index 0000000..155045b --- /dev/null +++ b/Source/JavaScript/components/toolbar/SceneToolbarSeparator.tsx @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ToolbarSeparator } from '@cratis/components/Toolbar'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { unionProperty } from '../properties'; + +/** Whether the separator is drawn as a horizontal rule or a vertical one. */ +const orientations = ['vertical', 'horizontal'] as const; + +/** + * The `Cratis.Components:toolbarSeparator` component - `ToolbarSeparator` from + * `@cratis/components/Toolbar`. + * + * The divider between groups of tools. Its `orientation` describes the *toolbar* it sits in rather than + * the line it draws - a vertical toolbar gets a horizontal rule - which matches how the toolbar's own + * `orientation` reads, so a screen sets the same value on both. + */ +export function SceneToolbarSeparator({ element }: RegisteredComponentProps) { + return ; +} diff --git a/Source/JavaScript/components/toolbar/index.ts b/Source/JavaScript/components/toolbar/index.ts new file mode 100644 index 0000000..ef71a0c --- /dev/null +++ b/Source/JavaScript/components/toolbar/index.ts @@ -0,0 +1,7 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export * from './SceneToolbar'; +export * from './SceneToolbarButton'; +export * from './SceneToolbarGroup'; +export * from './SceneToolbarSeparator'; diff --git a/Source/JavaScript/components/tsconfig.json b/Source/JavaScript/components/tsconfig.json new file mode 100644 index 0000000..298f9bd --- /dev/null +++ b/Source/JavaScript/components/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist/esm", + "paths": { + "@cratis/scene.model/*": ["../model/dist/esm/*"], + "@cratis/scene.engine/*": ["../engine/dist/esm/*"], + "@cratis/scene.react/*": ["../react/dist/esm/*"] + } + }, + "files": [ + "../../../test.d.ts", + "../../../global.d.ts" + ], + "include": [ + "**/*.ts", + "**/*.tsx" + ], + "references": [ + { "path": "../model" }, + { "path": "../engine" }, + { "path": "../react" } + ], + "exclude": [ + "dist", + "vite.config.mts", + ".storybook" + ] +} diff --git a/Source/JavaScript/components/vite.config.mts b/Source/JavaScript/components/vite.config.mts new file mode 100644 index 0000000..5f208e4 --- /dev/null +++ b/Source/JavaScript/components/vite.config.mts @@ -0,0 +1,23 @@ +/// + +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +/* @ts-ignore TypeScript complains that the imported vite.config is not under rootDir, but it works at runtime */ +import { createConfig } from '../../../vite.base'; + +const config = createConfig(); + +// Adapters are React components, so their specs render them: jsdom rather than the base `node` +// environment, the React plugin for JSX, and `.tsx` spec files alongside the base `.ts` ones - the same +// three additions `@cratis/scene.react` makes for the same reason. +config.plugins.push(react()); +config.test.environment = 'jsdom'; +config.test.include = [...config.test.include, '**/for_*/when_*/**/*.tsx', '**/for_*/**/when_*.tsx']; + +// `@cratis/components` and PrimeReact are published as ESM that still uses directory imports +// (`primereact/api`), which Node's own ESM resolver rejects. Inlining them puts both through Vite's +// resolver - the same one that serves them in a browser build - instead of leaving them to Node. +config.test.server = { deps: { inline: [/@cratis[\\/]components/, /primereact/] } }; + +export default defineConfig(config); diff --git a/Source/JavaScript/engine/for_resolveScreenTemplates/when_resolving_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_resolveScreenTemplates/when_resolving_against_the_shared_fixture_corpus.ts new file mode 100644 index 0000000..59b4789 --- /dev/null +++ b/Source/JavaScript/engine/for_resolveScreenTemplates/when_resolving_against_the_shared_fixture_corpus.ts @@ -0,0 +1,49 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { Layout, ScreenTemplate } from '@cratis/scene.model'; +import { isScreenTemplateResolutionValid, resolveScreenTemplates } from '../index'; + +interface FixtureCase { + name: string; + layout: { name: string; slots: string[] }; + templates: { name: string; fitsSlot: string | null; slots: string[] }[]; + expectedPlacements: { template: string; slot: string; container: string; depth: number }[]; + expectedUnplaced: { template: string; slot: string; candidates: string[] }[]; + expectedCycles: string[][]; +} + +const fixturePath = join(import.meta.dirname, '..', '..', '..', '..', 'screen-template-fixtures.json'); +const corpus = JSON.parse(readFileSync(fixturePath, 'utf-8')) as { cases: FixtureCase[] }; + +describe('when resolving against the shared fixture corpus', () => { + for (const fixtureCase of corpus.cases) { + const layout: Layout = { name: fixtureCase.layout.name, slots: fixtureCase.layout.slots.map((name) => ({ name })) }; + const templates: ScreenTemplate[] = fixtureCase.templates.map((template) => ({ + name: template.name, + fitsSlot: template.fitsSlot ?? undefined, + slots: template.slots.map((name) => ({ name })), + })); + + const resolution = resolveScreenTemplates(layout, templates); + + it(`should place every template where the corpus expects for "${fixtureCase.name}"`, () => { + resolution.placements.should.deep.equal(fixtureCase.expectedPlacements); + }); + + it(`should report the expected unplaced templates for "${fixtureCase.name}"`, () => { + resolution.unplaced.should.deep.equal(fixtureCase.expectedUnplaced); + }); + + it(`should report the expected cycles for "${fixtureCase.name}"`, () => { + resolution.cycles.should.deep.equal(fixtureCase.expectedCycles); + }); + + it(`should consider "${fixtureCase.name}" valid only when nothing is wrong`, () => { + const expectedValid = fixtureCase.expectedUnplaced.length === 0 && fixtureCase.expectedCycles.length === 0; + isScreenTemplateResolutionValid(resolution).should.equal(expectedValid); + }); + } +}); diff --git a/Source/JavaScript/engine/index.ts b/Source/JavaScript/engine/index.ts index 63b9b2c..2b69318 100644 --- a/Source/JavaScript/engine/index.ts +++ b/Source/JavaScript/engine/index.ts @@ -17,5 +17,6 @@ export * from './PackageSelection'; export * from './packageVersionRange'; export * from './resolvePackageDependencies'; export * from './packageCatalog'; +export * from './resolveScreenTemplates'; export * from './buildStarterProfile'; export * from './incompatibleStarterThemes'; diff --git a/Source/JavaScript/engine/resolveScreenTemplates.ts b/Source/JavaScript/engine/resolveScreenTemplates.ts new file mode 100644 index 0000000..33cf5da --- /dev/null +++ b/Source/JavaScript/engine/resolveScreenTemplates.ts @@ -0,0 +1,174 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Layout, ScreenTemplate } from '@cratis/scene.model'; + +/** + * Where one {@link ScreenTemplate} ended up: the slot it declared, and the layout or template that turned + * out to declare that slot. + */ +export interface ScreenTemplatePlacement { + /** The template being placed. */ + template: string; + /** The slot name the template declared it fits. */ + slot: string; + /** The name of the {@link Layout} or {@link ScreenTemplate} declaring that slot. */ + container: string; + /** How far below the layout the template sits — 1 directly inside the layout, 2 inside a template that is. */ + depth: number; +} + +/** + * A template that names a slot, where the set of containers in scope does not agree on exactly one home for + * it. + */ +export interface UnplacedScreenTemplate { + /** The template that could not be placed. */ + template: string; + /** The slot name it declared it fits. */ + slot: string; + /** + * The containers declaring a slot of that name. Empty when nothing declares it; more than one when the + * name is ambiguous. Never exactly one — that case is a {@link ScreenTemplatePlacement} instead. + */ + candidates: string[]; +} + +/** + * The outcome of working out how a blueprint's templates nest inside its layout. + */ +export interface ScreenTemplateResolution { + /** Every template that found exactly one home, ordered shallowest first so a caller can build the tree top-down. */ + placements: ScreenTemplatePlacement[]; + /** + * Templates that found none or several. Reported rather than guessed: placing a template in the wrong + * parent renders content in the wrong region, which is far harder to diagnose than being told the slot + * name is ambiguous. + */ + unplaced: UnplacedScreenTemplate[]; + /** Template nesting cycles — a template that transitively contains itself. */ + cycles: string[][]; +} + +/** + * Whether every template found exactly one home and nothing nests inside itself. + */ +export function isScreenTemplateResolutionValid(resolution: ScreenTemplateResolution): boolean { + return resolution.unplaced.length === 0 && resolution.cycles.length === 0; +} + +/** + * Works out how a blueprint's {@link ScreenTemplate}s nest inside its {@link Layout}. + * + * A template declares only the *name* of the slot it fits, never which container owns that slot. That is + * what makes templates reusable — a feature's template says "I go in the module content area", not "I go + * inside this specific module". Resolution is the step that turns those names into a tree, by finding which + * layout or template declares each name. + * + * The same rule applies at every level, so nesting has no depth limit and no separate mechanism per level. + * The C# twin in `Cratis.Scene.Engine` implements the same algorithm; both are asserted against the same + * shared fixture corpus so they cannot drift apart. + */ +export function resolveScreenTemplates(layout: Layout, templates: ScreenTemplate[]): ScreenTemplateResolution { + const containersBySlot = buildSlotIndex(layout, templates); + const parents = new Map(); + const slots = new Map(); + const unplaced: UnplacedScreenTemplate[] = []; + + for (const template of templates) { + if (template.fitsSlot === undefined) continue; + + const candidates = (containersBySlot.get(template.fitsSlot) ?? []).filter((container) => container !== template.name); + + if (candidates.length === 1) { + parents.set(template.name, candidates[0]); + slots.set(template.name, template.fitsSlot); + } else { + unplaced.push({ template: template.name, slot: template.fitsSlot, candidates }); + } + } + + const cycles = findCycles(parents); + const inCycle = new Set(cycles.flat()); + + const placements: ScreenTemplatePlacement[] = []; + for (const template of templates) { + const container = parents.get(template.name); + if (container === undefined || inCycle.has(template.name)) continue; + + placements.push({ + template: template.name, + slot: slots.get(template.name)!, + container, + depth: depthOf(template.name, parents, layout.name), + }); + } + + placements.sort((left, right) => left.depth - right.depth || left.template.localeCompare(right.template)); + + return { placements, unplaced, cycles }; +} + +function buildSlotIndex(layout: Layout, templates: ScreenTemplate[]): Map { + const index = new Map(); + + const declare = (slot: string, container: string) => { + const containers = index.get(slot) ?? []; + if (!containers.includes(container)) containers.push(container); + index.set(slot, containers); + }; + + for (const slot of layout.slots) declare(slot.name, layout.name); + for (const template of templates) { + for (const slot of template.slots) declare(slot.name, template.name); + } + + return index; +} + +function findCycles(parents: Map): string[][] { + const cycles: string[][] = []; + const recorded = new Set(); + + for (const start of [...parents.keys()].sort()) { + const path: string[] = []; + const onPath = new Set(); + let current = start; + + for (;;) { + const parent = parents.get(current); + if (parent === undefined) break; + + path.push(current); + onPath.add(current); + if (onPath.has(parent)) { + const cycle = path.slice(path.indexOf(parent)); + const key = [...cycle].sort().join(' '); + if (!recorded.has(key)) { + recorded.add(key); + cycles.push(cycle); + } + + break; + } + + onPath.add(parent); + current = parent; + } + } + + return cycles; +} + +function depthOf(template: string, parents: Map, layoutName: string): number { + let depth = 0; + let current = template; + while (parents.has(current) && depth <= parents.size) { + depth++; + const parent = parents.get(current)!; + if (parent === layoutName) break; + current = parent; + } + + return depth; +} diff --git a/Source/JavaScript/layout.default/.storybook/main.ts b/Source/JavaScript/layout.default/.storybook/main.ts new file mode 100644 index 0000000..8a29238 --- /dev/null +++ b/Source/JavaScript/layout.default/.storybook/main.ts @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { StorybookConfig } from "@storybook/react-vite"; +import { dirname, join, resolve } from "path"; +import { fileURLToPath } from "url"; +import type { InlineConfig } from 'vite'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const config: StorybookConfig = { + // Scoped away from `dist/` so a story is never indexed twice - once as source and once as the + // tsc-emitted copy beside it. + stories: ["../*.stories.@(js|jsx|mjs|ts|tsx)", "../!(dist|node_modules)/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + addons: [getAbsolutePath("@storybook/addon-links")], + framework: { name: getAbsolutePath("@storybook/react-vite"), options: {} }, + async viteFinal(config: InlineConfig) { + config.resolve = config.resolve || {}; + config.resolve.alias = { + ...config.resolve.alias, + '@cratis/scene.engine': resolve(__dirname, '../../engine/index.ts'), + '@cratis/scene.model': resolve(__dirname, '../../model/index.ts'), + '@cratis/scene.react': resolve(__dirname, '../../react/index.ts'), + }; + return config; + }, +}; +export default config; + +function getAbsolutePath(value: string): string { + return dirname(fileURLToPath(import.meta.resolve(join(value, "package.json")))); +} diff --git a/Source/JavaScript/layout.default/ComponentName.ts b/Source/JavaScript/layout.default/ComponentName.ts new file mode 100644 index 0000000..2c3c643 --- /dev/null +++ b/Source/JavaScript/layout.default/ComponentName.ts @@ -0,0 +1,60 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * The bare names this package declares components under. + * + * A page template writes a bare name and `resolveComponentName` decides which active package wins it, so + * these names are public API: renaming one breaks every template that referenced it, in this package and + * in anyone else's. Keeping them in one enum means the manifest, the registry and every template + * reference the same symbol, and the specs can prove all three agree rather than hoping they do. + */ +export enum ComponentName { + /** The application shell - topbar, sidebar, breadcrumb, content, footer and an optional right panel. */ + AppShell = 'appShell', + + /** The chrome-less shell - content, an optional branding aside, and the configurator. */ + FullPageShell = 'fullPageShell', + + /** The fixed strip across the top of the application shell. */ + Topbar = 'topbar', + + /** The sidebar's own chrome, inside the shell-positioned panel. */ + Sidebar = 'sidebar', + + /** A navigation list. */ + Menu = 'menu', + + /** One entry in a navigation list, optionally with a submenu. */ + MenuItem = 'menuItem', + + /** The trail above the content. */ + Breadcrumb = 'breadcrumb', + + /** The strip below the content. */ + Footer = 'footer', + + /** The inspector panel down the right-hand edge. */ + RightPanel = 'rightPanel', + + /** The floating configurator: color scheme, layout mode, menu theme and theme. */ + ConfigPanel = 'configPanel', + + /** A screen's title, subtitle and actions. */ + PageHeader = 'pageHeader', + + /** The scrim behind a floating sidebar, which also closes it. */ + Mask = 'mask', + + /** The brand mark. */ + Logo = 'logo', + + /** The signed-in user's avatar and menu. */ + UserMenu = 'userMenu', + + /** Switches between the themes a host offers. */ + ThemeSwitcher = 'themeSwitcher', + + /** Switches between the layout modes. */ + LayoutModeSwitcher = 'layoutModeSwitcher', +} diff --git a/Source/JavaScript/layout.default/configuration/ColorScheme.ts b/Source/JavaScript/layout.default/configuration/ColorScheme.ts new file mode 100644 index 0000000..bfc6305 --- /dev/null +++ b/Source/JavaScript/layout.default/configuration/ColorScheme.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. + +/** + * The light/dark axis of the configurator. + * + * The shell never paints a color for a scheme itself - it only records which one is chosen, so the host + * can hand {@link SceneThemeProvider} a matching {@link Theme}. Keeping the choice and the palette apart + * is what lets a third-party theme participate in the same switch as the two this package ships. + */ +export enum ColorScheme { + Light = 'light', + Dark = 'dark', +} + +/** Every {@link ColorScheme}, in the order a configurator should offer them. */ +export const colorSchemes: ColorScheme[] = [ColorScheme.Light, ColorScheme.Dark]; diff --git a/Source/JavaScript/layout.default/configuration/LayoutConfigProvider.tsx b/Source/JavaScript/layout.default/configuration/LayoutConfigProvider.tsx new file mode 100644 index 0000000..c3198f2 --- /dev/null +++ b/Source/JavaScript/layout.default/configuration/LayoutConfigProvider.tsx @@ -0,0 +1,182 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ReactNode, createContext, useContext, useEffect, useMemo, useState } from 'react'; +import { ColorScheme } from './ColorScheme'; +import { LayoutConfigState } from './LayoutConfigState'; +import { LayoutMode } from './LayoutMode'; +import { MenuTheme } from './MenuTheme'; +import { persistLayoutConfig, readPersistedLayoutConfig } from './layoutConfigStorage'; +import { isLayoutMaskVisible, layoutWrapperClasses } from './layoutWrapperClasses'; +import { + effectiveLayoutMode, + isMobileWidth, + mobileMediaQuery, + toggleSidebar, + toggleSidebarAnchor, + withColorScheme, + withMenuTheme, + withMobile, + withMode, + withSidebarOpen, + withSidebarRevealed, + withThemeName, +} from './layoutConfigTransitions'; + +/** + * What {@link useLayoutConfig} hands a shell component: the current state, the values every part of the + * shell derives from it, and one function per transition. + * + * The derived values live here rather than being recomputed per component so that the topbar, the + * sidebar and the mask cannot end up disagreeing about which mode is in force - the exact class of bug a + * shell with per-component state produces. + */ +export interface LayoutConfigContextValue { + /** The current state. */ + config: LayoutConfigState; + + /** The mode actually in force, with the mobile override applied. */ + effectiveMode: LayoutMode; + + /** The classes for the shell's wrapper element. */ + wrapperClasses: string[]; + + /** Whether the scrim behind a floating sidebar should be showing. */ + isMaskVisible: boolean; + + /** Chooses a different mode. Below the mobile breakpoint the choice is still recorded, but the shell keeps rendering off-canvas. */ + setMode(mode: LayoutMode): void; + + /** Chooses how the sidebar surface is tinted. */ + setMenuTheme(menuTheme: MenuTheme): void; + + /** Chooses the light/dark axis. */ + setColorScheme(colorScheme: ColorScheme): void; + + /** Records which theme is applied. */ + setThemeName(themeName: string): void; + + /** Opens or closes the sidebar. */ + setSidebarOpen(isOpen: boolean): void; + + /** Flips the sidebar between open and closed. */ + toggleSidebar(): void; + + /** Holds a `reveal`/`drawer` sidebar out, or lets it fall back. */ + setSidebarRevealed(isRevealed: boolean): void; + + /** Pins or unpins a `reveal`/`drawer` sidebar. */ + toggleSidebarAnchor(): void; +} + +const LayoutConfigContext = createContext(undefined); + +export interface LayoutConfigProviderProps { + /** Values to start from, overriding both the defaults and anything persisted - a story or a spec pinning a mode. */ + initialConfig?: Partial; + + /** + * Where preferences are read from and written to. Defaults to `window.localStorage` when there is a + * window; pass a fake in a spec, or `null` to opt out of persistence entirely. + */ + storage?: Storage | null; + + /** + * The width the shell should consider its viewport, for a host that renders it into a sized element + * rather than the window - Studio's preview surface, where the device frame is a few hundred pixels + * wide while the real window is a desktop one. When given, this replaces the `matchMedia` listener + * entirely rather than competing with it. + */ + viewportWidth?: number; + + /** The shell this configuration applies to. */ + children?: ReactNode; +} + +/** + * Owns the shell's configuration and keeps it in step with the viewport and with `localStorage`. + * + * One provider rather than state per shell component is what makes the modes work at all: the topbar's + * toggle, the sidebar's pin, the mask's click-to-close and the configurator's mode picker are four + * different components acting on one machine. It is also what makes a chosen mode survive a reload, which + * is the whole reason a mode picker is worth having. + */ +export function LayoutConfigProvider({ initialConfig, storage, viewportWidth, children }: LayoutConfigProviderProps) { + const resolvedStorage = useMemo(() => (storage !== undefined ? storage ?? undefined : defaultStorage()), [storage]); + const [config, setConfig] = useState(() => ({ + ...readPersistedLayoutConfig(resolvedStorage), + ...initialConfig, + })); + + useEffect(() => { + persistLayoutConfig(config, resolvedStorage); + }, [config, resolvedStorage]); + + const isReportedMobile = viewportWidth === undefined ? undefined : isMobileWidth(viewportWidth); + useEffect(() => { + if (isReportedMobile !== undefined) { + setConfig(current => withMobile(current, isReportedMobile)); + return; + } + + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return; + } + + const query = window.matchMedia(mobileMediaQuery); + setConfig(current => withMobile(current, query.matches)); + + const listener = (event: MediaQueryListEvent) => setConfig(current => withMobile(current, event.matches)); + query.addEventListener('change', listener); + return () => query.removeEventListener('change', listener); + }, [isReportedMobile]); + + const value = useMemo( + () => ({ + config, + effectiveMode: effectiveLayoutMode(config), + wrapperClasses: layoutWrapperClasses(config), + isMaskVisible: isLayoutMaskVisible(config), + setMode: mode => setConfig(current => withMode(current, mode)), + setMenuTheme: menuTheme => setConfig(current => withMenuTheme(current, menuTheme)), + setColorScheme: colorScheme => setConfig(current => withColorScheme(current, colorScheme)), + setThemeName: themeName => setConfig(current => withThemeName(current, themeName)), + setSidebarOpen: isOpen => setConfig(current => withSidebarOpen(current, isOpen)), + toggleSidebar: () => setConfig(toggleSidebar), + setSidebarRevealed: isRevealed => setConfig(current => withSidebarRevealed(current, isRevealed)), + toggleSidebarAnchor: () => setConfig(toggleSidebarAnchor), + }), + [config], + ); + + return {children}; +} + +/** + * The shell configuration, or `undefined` when there is no {@link LayoutConfigProvider} above. + * + * This is what lets the shell components put a provider around themselves when a host has not - a gallery + * preview drops a single `appShell` element into a renderer with no wrapper of its own, and it still has + * to work. + */ +export function useOptionalLayoutConfig(): LayoutConfigContextValue | undefined { + return useContext(LayoutConfigContext); +} + +/** + * The shell configuration. Throws when there is no {@link LayoutConfigProvider} above, because every + * alternative - a silent default, a no-op setter - produces a shell whose buttons do nothing and says + * nothing about why. + */ +export function useLayoutConfig(): LayoutConfigContextValue { + const value = useOptionalLayoutConfig(); + if (!value) { + throw new Error('useLayoutConfig() requires a above it.'); + } + + return value; +} + +function defaultStorage(): Storage | undefined { + return typeof window !== 'undefined' ? window.localStorage : undefined; +} diff --git a/Source/JavaScript/layout.default/configuration/LayoutConfigState.ts b/Source/JavaScript/layout.default/configuration/LayoutConfigState.ts new file mode 100644 index 0000000..faf99b6 --- /dev/null +++ b/Source/JavaScript/layout.default/configuration/LayoutConfigState.ts @@ -0,0 +1,46 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ColorScheme } from './ColorScheme'; +import { LayoutMode } from './LayoutMode'; +import { MenuTheme } from './MenuTheme'; + +/** + * Everything the shell needs to know about itself, in one place. + * + * The shell has a lot of visual state - eight modes, a sidebar that can be open, hovered or pinned, a + * mobile breakpoint that overrides the chosen mode, two color axes - and every one of those was, in the + * template line this follows, a separate boolean scattered across components. Holding it as one plain + * value instead means the whole state machine is pure functions over this record + * (`layoutConfigTransitions.ts`), testable without React, and rendered by exactly one function + * ({@link layoutWrapperClasses}). + */ +export interface LayoutConfigState { + /** The mode the user chose. Below the mobile breakpoint the shell renders a different one - see {@link effectiveLayoutMode}. */ + mode: LayoutMode; + + /** How the sidebar surface is tinted. */ + menuTheme: MenuTheme; + + /** The light/dark axis. The host maps this onto a {@link Theme}; the shell only records it. */ + colorScheme: ColorScheme; + + /** The name of the {@link Theme} currently applied, so a theme switcher has something to reflect. */ + themeName: string; + + /** + * Whether the sidebar currently occupies space (`static`) or floats over the content + * (`overlay`, mobile). One flag rather than the separate desktop/mobile/overlay booleans the + * template line uses - the mode already says how "open" should look. + */ + isSidebarOpen: boolean; + + /** Whether a `reveal`/`drawer` sidebar is pinned open, so it stays out when the pointer leaves. */ + isSidebarAnchored: boolean; + + /** Whether a `reveal`/`drawer` sidebar is currently held out by the pointer. */ + isSidebarRevealed: boolean; + + /** Whether the viewport is below the mobile breakpoint. Never set by a user action - only by the viewport. */ + isMobile: boolean; +} diff --git a/Source/JavaScript/layout.default/configuration/LayoutMode.ts b/Source/JavaScript/layout.default/configuration/LayoutMode.ts new file mode 100644 index 0000000..b0ece52 --- /dev/null +++ b/Source/JavaScript/layout.default/configuration/LayoutMode.ts @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * How the application shell arranges its sidebar against its content. + * + * The vocabulary - and the `layout-` wrapper class each value maps to - is deliberately the one + * PrimeTek's template line has used unchanged across Sakai, Diamond, Atlantis, Freya, Apollo, Ultima, + * Avalon and Verona. Anyone who has themed a PrimeReact application recognizes it, and a shell that + * invents its own names forces them to learn a second one for no gain. + * + * Every value here is user-selectable. Mobile deliberately is not - see {@link effectiveLayoutMode}. + */ +export enum LayoutMode { + /** Sidebar permanently docked; content is pushed by a matching margin, never covered. */ + Static = 'static', + + /** Sidebar parked off-canvas; opening floats it over the content behind a mask. */ + Overlay = 'overlay', + + /** Icon-only rail with circular buttons; submenus pop out as a floating panel. */ + Slim = 'slim', + + /** Wider rail with each icon's label stacked directly beneath it. */ + SlimPlus = 'slim-plus', + + /** The icon-only rail again, with square buttons, and the topbar shifted by the rail width. */ + Compact = 'compact', + + /** Sidebar flows into the topbar strip as a horizontal row; submenus drop down. */ + Horizontal = 'horizontal', + + /** Full panel translated off-left leaving a strip of icons; hovering slides it in over the content. */ + Reveal = 'reveal', + + /** Collapsed rail that animates its width to full on hover - it grows where reveal slides. */ + Drawer = 'drawer', +} + +/** Every {@link LayoutMode}, in the order a configurator should offer them. */ +export const layoutModes: LayoutMode[] = [ + LayoutMode.Static, + LayoutMode.Overlay, + LayoutMode.Slim, + LayoutMode.SlimPlus, + LayoutMode.Compact, + LayoutMode.Horizontal, + LayoutMode.Reveal, + LayoutMode.Drawer, +]; diff --git a/Source/JavaScript/layout.default/configuration/MenuTheme.ts b/Source/JavaScript/layout.default/configuration/MenuTheme.ts new file mode 100644 index 0000000..b46d5be --- /dev/null +++ b/Source/JavaScript/layout.default/configuration/MenuTheme.ts @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * How the sidebar surface is tinted, independently of the overall {@link ColorScheme}. + * + * A dark sidebar against a light page is the single most common brand customization in the PrimeTek + * template line, which is why menu theme is its own configurator axis rather than something implied by + * the color scheme. The shell resolves each value from the active theme's tokens, so a menu theme never + * introduces a color of its own. + */ +export enum MenuTheme { + /** The sidebar uses the theme's ordinary card surface. */ + Light = 'light', + + /** The sidebar uses an inverted surface, regardless of the page's color scheme. */ + Dark = 'dark', + + /** The sidebar uses the theme's primary color as its surface. */ + Primary = 'primary', +} + +/** Every {@link MenuTheme}, in the order a configurator should offer them. */ +export const menuThemes: MenuTheme[] = [MenuTheme.Light, MenuTheme.Dark, MenuTheme.Primary]; diff --git a/Source/JavaScript/layout.default/configuration/index.ts b/Source/JavaScript/layout.default/configuration/index.ts new file mode 100644 index 0000000..e8d719b --- /dev/null +++ b/Source/JavaScript/layout.default/configuration/index.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. + +export * from './LayoutMode'; +export * from './ColorScheme'; +export * from './MenuTheme'; +export * from './LayoutConfigState'; +export * from './layoutConfigTransitions'; +export * from './layoutWrapperClasses'; +export * from './layoutConfigStorage'; +export * from './LayoutConfigProvider'; diff --git a/Source/JavaScript/layout.default/configuration/layoutConfigStorage.ts b/Source/JavaScript/layout.default/configuration/layoutConfigStorage.ts new file mode 100644 index 0000000..629a94a --- /dev/null +++ b/Source/JavaScript/layout.default/configuration/layoutConfigStorage.ts @@ -0,0 +1,102 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ColorScheme } from './ColorScheme'; +import { LayoutConfigState } from './LayoutConfigState'; +import { LayoutMode } from './LayoutMode'; +import { MenuTheme } from './MenuTheme'; +import { defaultLayoutConfigState } from './layoutConfigTransitions'; + +/** The `localStorage` key the shell persists its configuration under. */ +export const layoutConfigStorageKey = 'cratis.scene.layout.default'; + +/** + * The parts of {@link LayoutConfigState} worth surviving a reload. + * + * Deliberately not the whole record: whether the sidebar happened to be open, whether the pointer was + * over it, and whether the viewport was narrow are all facts about the moment rather than preferences, and + * restoring them produces a shell that opens in a state the user never chose. The pin *is* a preference, + * so it stays. + */ +export type PersistedLayoutConfig = Pick; + +/** + * Reads the persisted configuration back, falling back to {@link defaultLayoutConfigState} for anything + * missing or unrecognized. + * + * Everything is validated against the enums rather than trusted, because `localStorage` is shared with + * every other script on the origin and outlives the version of this package that wrote it. An unknown + * mode left in place would put a class on the wrapper that no stylesheet rule matches - a shell with no + * sidebar at all, and no error to explain it. + * + * @param storage The storage to read from, or `undefined` when there is none (server-side rendering). + * @returns The restored state, with defaults filled in. + */ +export function readPersistedLayoutConfig(storage?: Storage): LayoutConfigState { + const defaults = defaultLayoutConfigState(); + const stored = readRecord(storage); + if (!stored) { + return defaults; + } + + return { + ...defaults, + mode: enumValue(Object.values(LayoutMode), stored.mode) ?? defaults.mode, + menuTheme: enumValue(Object.values(MenuTheme), stored.menuTheme) ?? defaults.menuTheme, + colorScheme: enumValue(Object.values(ColorScheme), stored.colorScheme) ?? defaults.colorScheme, + themeName: typeof stored.themeName === 'string' ? stored.themeName : defaults.themeName, + isSidebarAnchored: typeof stored.isSidebarAnchored === 'boolean' ? stored.isSidebarAnchored : defaults.isSidebarAnchored, + }; +} + +/** + * Writes the durable parts of the configuration. + * + * Storage can throw - Safari's private mode and a full quota both do - and a shell that cannot remember a + * preference is a far smaller problem than one that crashes on a mode switch, so a failure here is + * swallowed rather than propagated. + * + * @param state The state to persist. + * @param storage The storage to write to, or `undefined` when there is none. + */ +export function persistLayoutConfig(state: LayoutConfigState, storage?: Storage): void { + if (!storage) { + return; + } + + const persisted: PersistedLayoutConfig = { + mode: state.mode, + menuTheme: state.menuTheme, + colorScheme: state.colorScheme, + themeName: state.themeName, + isSidebarAnchored: state.isSidebarAnchored, + }; + + try { + storage.setItem(layoutConfigStorageKey, JSON.stringify(persisted)); + } catch { + // A browser that refuses to store a preference still has to render the shell. + } +} + +function readRecord(storage?: Storage): Record | undefined { + if (!storage) { + return undefined; + } + + try { + const raw = storage.getItem(layoutConfigStorageKey); + if (!raw) { + return undefined; + } + + const parsed = JSON.parse(raw) as unknown; + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? (parsed as Record) : undefined; + } catch { + return undefined; + } +} + +function enumValue(values: TValue[], candidate: unknown): TValue | undefined { + return typeof candidate === 'string' && values.includes(candidate as TValue) ? (candidate as TValue) : undefined; +} diff --git a/Source/JavaScript/layout.default/configuration/layoutConfigTransitions.ts b/Source/JavaScript/layout.default/configuration/layoutConfigTransitions.ts new file mode 100644 index 0000000..d8b62ed --- /dev/null +++ b/Source/JavaScript/layout.default/configuration/layoutConfigTransitions.ts @@ -0,0 +1,150 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ColorScheme } from './ColorScheme'; +import { LayoutConfigState } from './LayoutConfigState'; +import { LayoutMode } from './LayoutMode'; +import { MenuTheme } from './MenuTheme'; + +/** + * The viewport width, in CSS pixels, below which the shell stops honoring the chosen {@link LayoutMode} + * and forces the off-canvas one. 991 is the breakpoint the PrimeTek template line uses throughout; the + * value is exported rather than buried in a media query so the resize listener and the stylesheet cannot + * disagree about where the boundary is. + */ +export const mobileBreakpoint = 991; + +/** The media query the shell watches to know whether it is in the mobile regime. */ +export const mobileMediaQuery = `(max-width: ${mobileBreakpoint}px)`; + +/** + * Whether a viewport width falls in the mobile regime. Inclusive of the breakpoint itself, so this and + * {@link mobileMediaQuery}'s `max-width` agree on the boundary rather than disagreeing by one pixel. + */ +export function isMobileWidth(width: number): boolean { + return width <= mobileBreakpoint; +} + +/** + * The state a shell starts in when nothing has been persisted: the docked sidebar every application + * template defaults to, on a light scheme, with the theme this package ships as its light one. + */ +export function defaultLayoutConfigState(): LayoutConfigState { + return { + mode: LayoutMode.Static, + menuTheme: MenuTheme.Light, + colorScheme: ColorScheme.Light, + themeName: 'Scene Default Light', + isSidebarOpen: true, + isSidebarAnchored: false, + isSidebarRevealed: false, + isMobile: false, + }; +} + +/** + * The mode the shell actually renders, as opposed to the one the user picked. + * + * Below the mobile breakpoint every mode collapses to {@link LayoutMode.Overlay}: a docked 18rem sidebar, + * an icon rail or a horizontal menu strip are all unusable on a phone, so the template line forces + * off-canvas there and hides the mode picker. Deriving it rather than overwriting `mode` is what lets the + * chosen mode come back untouched when the viewport grows again. + */ +export function effectiveLayoutMode(state: LayoutConfigState): LayoutMode { + return state.isMobile ? LayoutMode.Overlay : state.mode; +} + +/** Whether the mode keeps the sidebar off-canvas until something opens it. */ +export function isOffCanvasMode(mode: LayoutMode): boolean { + return mode === LayoutMode.Overlay; +} + +/** Whether the mode is one of the two that react to the pointer - `reveal` slides in, `drawer` grows. */ +export function isPointerRevealMode(mode: LayoutMode): boolean { + return mode === LayoutMode.Reveal || mode === LayoutMode.Drawer; +} + +/** + * Chooses a different mode. + * + * Switching also resets the sidebar's transient state, because "open" means something different in each + * mode: a static sidebar starts docked, every other mode starts closed, and a reveal/drawer panel that + * was hovered out must not stay out under a mode that has no hover behavior. The pin survives, since it + * is a preference rather than transient state. + */ +export function withMode(state: LayoutConfigState, mode: LayoutMode): LayoutConfigState { + return { ...state, mode, isSidebarOpen: mode === LayoutMode.Static, isSidebarRevealed: false }; +} + +/** + * Records that the viewport crossed the mobile breakpoint. + * + * Entering the mobile regime closes the sidebar so the page is not covered on arrival; leaving it + * restores whatever the chosen mode considers its resting state. Both directions are recomputed rather + * than remembered, so a mode switch made while mobile still lands correctly on the way back out. + */ +export function withMobile(state: LayoutConfigState, isMobile: boolean): LayoutConfigState { + if (state.isMobile === isMobile) { + return state; + } + + return { + ...state, + isMobile, + isSidebarOpen: isMobile ? false : state.mode === LayoutMode.Static, + isSidebarRevealed: false, + }; +} + +/** Opens or closes the sidebar. */ +export function withSidebarOpen(state: LayoutConfigState, isSidebarOpen: boolean): LayoutConfigState { + return { ...state, isSidebarOpen }; +} + +/** Flips the sidebar between open and closed - what the topbar's menu button does in every mode. */ +export function toggleSidebar(state: LayoutConfigState): LayoutConfigState { + return withSidebarOpen(state, !state.isSidebarOpen); +} + +/** + * Holds a `reveal`/`drawer` sidebar out, or lets it fall back. + * + * An anchored sidebar ignores the pointer entirely - that is the whole point of pinning it - so this is a + * no-op while {@link LayoutConfigState.isSidebarAnchored} is set rather than something the pointer can + * fight with. + */ +export function withSidebarRevealed(state: LayoutConfigState, isSidebarRevealed: boolean): LayoutConfigState { + if (state.isSidebarAnchored) { + return state; + } + + return { ...state, isSidebarRevealed }; +} + +/** + * Pins or unpins a `reveal`/`drawer` sidebar. Pinning implies revealed, so the panel does not snap shut + * the moment the pointer leaves the pin button that just pinned it. + */ +export function withSidebarAnchored(state: LayoutConfigState, isSidebarAnchored: boolean): LayoutConfigState { + return { ...state, isSidebarAnchored, isSidebarRevealed: isSidebarAnchored }; +} + +/** Flips the pin - what the sidebar's anchor button does. */ +export function toggleSidebarAnchor(state: LayoutConfigState): LayoutConfigState { + return withSidebarAnchored(state, !state.isSidebarAnchored); +} + +/** Chooses the light/dark axis. */ +export function withColorScheme(state: LayoutConfigState, colorScheme: ColorScheme): LayoutConfigState { + return { ...state, colorScheme }; +} + +/** Chooses how the sidebar surface is tinted. */ +export function withMenuTheme(state: LayoutConfigState, menuTheme: MenuTheme): LayoutConfigState { + return { ...state, menuTheme }; +} + +/** Records which {@link Theme} is applied, so a theme switcher reflects the truth after a host-driven change. */ +export function withThemeName(state: LayoutConfigState, themeName: string): LayoutConfigState { + return { ...state, themeName }; +} diff --git a/Source/JavaScript/layout.default/configuration/layoutWrapperClasses.ts b/Source/JavaScript/layout.default/configuration/layoutWrapperClasses.ts new file mode 100644 index 0000000..a1541eb --- /dev/null +++ b/Source/JavaScript/layout.default/configuration/layoutWrapperClasses.ts @@ -0,0 +1,68 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { LayoutConfigState } from './LayoutConfigState'; +import { LayoutMode } from './LayoutMode'; +import { effectiveLayoutMode, isPointerRevealMode } from './layoutConfigTransitions'; + +/** + * Turns the shell's state into the wrapper class list its stylesheet keys off - the single seam between + * the state machine and the CSS. + * + * Every class name here is the one the PrimeTek template line already uses (`layout-static`, + * `layout-overlay-active`, `layout-sidebar-anchored`, `layout-mobile-active`, ...), so a stylesheet + * written against those templates keeps working and anyone who has themed one can read this shell's DOM + * without a translation table. + * + * The mode class reflects the *effective* mode, so the off-canvas rules a phone needs come from the same + * `layout-overlay` block a desktop overlay uses instead of a parallel mobile-only ruleset. The chosen mode + * is still emitted separately as `data-layout-mode`, so a configurator can show what the user picked even + * while the viewport overrides it. + * + * @param state The current shell state. + * @returns The classes to put on the shell's wrapper element, always starting with `layout-wrapper`. + */ +export function layoutWrapperClasses(state: LayoutConfigState): string[] { + const mode = effectiveLayoutMode(state); + const classes = [ + 'layout-wrapper', + `layout-${mode}`, + `layout-menu-${state.menuTheme}`, + `layout-color-scheme-${state.colorScheme}`, + ]; + + if (mode === LayoutMode.Static && !state.isSidebarOpen) { + classes.push('layout-static-inactive'); + } + + if (mode === LayoutMode.Overlay && state.isSidebarOpen && !state.isMobile) { + classes.push('layout-overlay-active'); + } + + if (isPointerRevealMode(mode) && state.isSidebarRevealed) { + classes.push('layout-sidebar-active'); + } + + if (isPointerRevealMode(mode) && state.isSidebarAnchored) { + classes.push('layout-sidebar-anchored'); + } + + if (state.isMobile) { + classes.push('layout-mobile'); + } + + if (state.isMobile && state.isSidebarOpen) { + classes.push('layout-mobile-active'); + } + + return classes; +} + +/** + * Whether the scrim that dims the content behind a floating sidebar should be showing. Overlay and mobile + * both cover the page, and both need the click-anywhere-to-close the mask provides; a docked or rail + * sidebar covers nothing and must not dim anything. + */ +export function isLayoutMaskVisible(state: LayoutConfigState): boolean { + return effectiveLayoutMode(state) === LayoutMode.Overlay && state.isSidebarOpen; +} diff --git a/Source/JavaScript/layout.default/defaultBlueprint.ts b/Source/JavaScript/layout.default/defaultBlueprint.ts new file mode 100644 index 0000000..b9f79ab --- /dev/null +++ b/Source/JavaScript/layout.default/defaultBlueprint.ts @@ -0,0 +1,66 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PackageKind, ScenePackage } from '@cratis/scene.model'; +import { ScenePackageBundle } from '@cratis/scene.react'; +import { ComponentName } from './ComponentName'; +import { defaultBlueprintComponents } from './defaultBlueprintComponents'; +import { galleryDialogTemplates, galleryScreenTemplates, galleryScreens } from './gallery'; +import { LayoutName, defaultLayouts } from './layouts'; +import { defaultBlueprintName } from './packageName'; +import { defaultBlueprintThemes } from './themes'; + +/** + * The default blueprint's declaration. + * + * A blueprint is the package an application picks once to get a coherent shape: the {@link Layout}s that + * are its base navigational look, the {@link ScreenTemplate}s and {@link DialogTemplate}s that go inside + * them, the components that fill their slots, and the themes that color all of it. Picking the parts + * separately is how an application ends up with a sidebar from one design language and a form from + * another. + * + * The dependency list is what makes a blueprint honest about what it is built from. This one declares + * PrimeReact - the shell's buttons, breadcrumb, overlay menu and drawer are PrimeReact 10 components - and + * Cratis Components, which its screen templates fill their content with. A profile that activated this + * blueprint without them would render a shell whose every control was a dashed red placeholder, and + * `resolvePackageDependencies` exists to catch that when the profile is configured rather than when + * someone opens the page. + * + * `layouts` lists only true application shells. A dashboard, a CRUD list or a sign-in screen is a screen + * template, not a layout, and is listed as one. + */ +export const defaultBlueprintManifest: ScenePackage = { + name: defaultBlueprintName, + version: '1.0.0', + kind: PackageKind.Blueprint, + dependencies: [{ name: 'PrimeReact' }, { name: 'Cratis.Components' }], + components: Object.values(ComponentName), + layouts: Object.values(LayoutName), + screenTemplates: galleryScreenTemplates.map(template => template.name), + dialogTemplates: galleryDialogTemplates.map(template => template.name), + themes: defaultBlueprintThemes.map(theme => theme.name), + displayName: 'Cratis Default Blueprint', + description: 'Application shells with eight menu modes, the components that fill their slots, and a full screen and dialog template set.', + module: '@cratis/scene.blueprint.default', +}; + +/** + * The default blueprint as a loadable bundle. + * + * The manifest names things; this provides them. `validatePackageBundle` is what proves the two agree, and + * this package's specs run it - a manifest promising a component the bundle never registered renders as a + * dashed red placeholder somewhere deep inside a screen, a long way from the declaration that caused it. + * + * The screens are the gallery: real {@link Screen} instances naming a layout, a screen template and the + * content that fills it, so a preview boots them through the real engine as a working miniature + * application rather than a set of pictures. + */ +export const defaultBlueprint: ScenePackageBundle = { + manifest: defaultBlueprintManifest, + components: defaultBlueprintComponents, + layouts: defaultLayouts, + screenTemplates: galleryScreenTemplates, + dialogTemplates: galleryDialogTemplates, + screens: galleryScreens, + themes: defaultBlueprintThemes, +}; diff --git a/Source/JavaScript/layout.default/defaultBlueprintComponents.ts b/Source/JavaScript/layout.default/defaultBlueprintComponents.ts new file mode 100644 index 0000000..d44cf11 --- /dev/null +++ b/Source/JavaScript/layout.default/defaultBlueprintComponents.ts @@ -0,0 +1,51 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ComponentRegistry, componentRegistryKey } from '@cratis/scene.react'; +import { ComponentName } from './ComponentName'; +import { defaultBlueprintName } from './packageName'; +import { + AppShell, + Breadcrumb, + ConfigPanel, + Footer, + FullPageShell, + LayoutModeSwitcher, + Logo, + Mask, + Menu, + MenuItem, + PageHeader, + RightPanel, + Sidebar, + ThemeSwitcher, + Topbar, + UserMenu, +} from './shell'; + +/** + * The components this package registers, keyed the way the renderer looks them up. + * + * Every key is built with `componentRegistryKey` rather than written out, because the separator between + * package and component name is the registry's own business - it is deliberately not the `.` a screen uses + * to qualify a name, so that a package name containing dots stays unambiguous. Building a key by hand is + * how a component ends up registered under something no lookup will ever produce. + */ +export const defaultBlueprintComponents: ComponentRegistry = { + [componentRegistryKey(defaultBlueprintName, ComponentName.AppShell)]: AppShell, + [componentRegistryKey(defaultBlueprintName, ComponentName.FullPageShell)]: FullPageShell, + [componentRegistryKey(defaultBlueprintName, ComponentName.Topbar)]: Topbar, + [componentRegistryKey(defaultBlueprintName, ComponentName.Sidebar)]: Sidebar, + [componentRegistryKey(defaultBlueprintName, ComponentName.Menu)]: Menu, + [componentRegistryKey(defaultBlueprintName, ComponentName.MenuItem)]: MenuItem, + [componentRegistryKey(defaultBlueprintName, ComponentName.Breadcrumb)]: Breadcrumb, + [componentRegistryKey(defaultBlueprintName, ComponentName.Footer)]: Footer, + [componentRegistryKey(defaultBlueprintName, ComponentName.RightPanel)]: RightPanel, + [componentRegistryKey(defaultBlueprintName, ComponentName.ConfigPanel)]: ConfigPanel, + [componentRegistryKey(defaultBlueprintName, ComponentName.PageHeader)]: PageHeader, + [componentRegistryKey(defaultBlueprintName, ComponentName.Mask)]: Mask, + [componentRegistryKey(defaultBlueprintName, ComponentName.Logo)]: Logo, + [componentRegistryKey(defaultBlueprintName, ComponentName.UserMenu)]: UserMenu, + [componentRegistryKey(defaultBlueprintName, ComponentName.ThemeSwitcher)]: ThemeSwitcher, + [componentRegistryKey(defaultBlueprintName, ComponentName.LayoutModeSwitcher)]: LayoutModeSwitcher, +}; diff --git a/Source/JavaScript/layout.default/gallery/NavigationEntry.ts b/Source/JavaScript/layout.default/gallery/NavigationEntry.ts new file mode 100644 index 0000000..0ea529b --- /dev/null +++ b/Source/JavaScript/layout.default/gallery/NavigationEntry.ts @@ -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. + +/** + * One entry in the gallery's navigation. + * + * The fields are deliberately the ones {@link NavigationItem} carries, plus an icon. That is what lets a + * single declaration become both a sidebar entry and a `Navigation` contribution: the element built from + * it carries these values in its `properties` bag, `extractNavigationItem` reads them straight back out, + * and neither side has to be kept in step with the other by hand. + */ +export interface NavigationEntry { + /** What the entry is called. */ + label: string; + + /** The screen it navigates to. */ + targetScreen: string; + + /** A PrimeIcons class for the entry's icon. */ + icon: string; + + /** The section it belongs to, so the menu and an aggregated navigation group it the same way. */ + group: string; + + /** Where it sorts within its group. */ + order: number; +} diff --git a/Source/JavaScript/layout.default/gallery/TemplateSlotName.ts b/Source/JavaScript/layout.default/gallery/TemplateSlotName.ts new file mode 100644 index 0000000..2ae8dd5 --- /dev/null +++ b/Source/JavaScript/layout.default/gallery/TemplateSlotName.ts @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * The slots the gallery's screen templates offer to whatever they contain. + * + * Distinct from {@link SlotName}, which is the *layout's* vocabulary. A screen template declares its own + * slots, and a template nested inside it names one of these in `fitsSlot` - so the same name means + * different places at different depths, and that is fine: `fitsSlot` is always resolved against the direct + * parent, never globally. + */ +export enum TemplateSlotName { + /** A template's own title area. */ + Header = 'header', + + /** The main region a nested template fits into. */ + Body = 'body', + + /** An optional column beside the body. */ + SidePanel = 'sidePanel', + + /** The action strip above a body. */ + Toolbar = 'toolbar', + + /** Buttons belonging to a header or a form. */ + Actions = 'actions', + + /** The row of figures a dashboard opens with. */ + Stats = 'stats', + + /** The larger left-hand column of a dashboard. */ + Primary = 'primary', + + /** The narrower right-hand column of a dashboard. */ + Secondary = 'secondary', +} diff --git a/Source/JavaScript/layout.default/gallery/applicationChrome.ts b/Source/JavaScript/layout.default/gallery/applicationChrome.ts new file mode 100644 index 0000000..0c34421 --- /dev/null +++ b/Source/JavaScript/layout.default/gallery/applicationChrome.ts @@ -0,0 +1,110 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { SceneElement } from '@cratis/scene.model'; +import { ComponentName } from '../ComponentName'; +import { SlotName } from '../layouts'; +import { defaultBlueprintThemes } from '../themes'; +import { externalComponent } from './elements'; +import { entriesInGroup, navigationElement, navigationGroups } from './navigation'; + +/** + * The chrome every screen in the application shell shares: the topbar, the sidebar, the menu, the footer + * and the configurator. + * + * It is built once, per screen, rather than repeated in twenty templates. A screen template describes what + * is *different* about a screen; if it also had to describe the topbar, then adding an item to the menu + * would mean editing twenty files, and nineteen of them would eventually be missed. + */ + +/** The brand mark, shared by the topbar and the sidebar header. */ +function logo(id: string): SceneElement { + return externalComponent(id, ComponentName.Logo, { label: 'Contoso', initials: 'C', targetScreen: 'Dashboard' }); +} + +/** One menu section per navigation group, each holding that group's entries. */ +function menuSections(activeScreen: string): SceneElement[] { + return navigationGroups.map(group => + externalComponent( + `menu-${group.toLowerCase()}`, + ComponentName.Menu, + { title: group, label: group }, + { items: entriesInGroup(group).map(entry => navigationElement(entry, activeScreen)) }, + ), + ); +} + +/** The configurator, offering the themes this blueprint ships. */ +function configPanel(): SceneElement { + return externalComponent(ComponentName.ConfigPanel, ComponentName.ConfigPanel, { + title: 'Settings', + themes: defaultBlueprintThemes.map(theme => ({ name: theme.name, label: theme.name, isDark: theme.isDark ?? false })), + }); +} + +/** One trail entry: a label, and the screen it goes back to. */ +export interface BreadcrumbEntry { + /** What the entry is called. */ + label: string; + + /** The screen it navigates back to, or undefined for the current page. */ + targetScreen?: string; +} + +/** + * The application shell's slot content for one screen. + * + * Only the breadcrumb and which menu entry is marked active differ from screen to screen - everything else + * is identical, which is the point of chrome. + * + * @param activeScreen The screen being rendered, so its menu entry is marked current. + * @param breadcrumb The trail above the content. + * @returns Slot content keyed by the {@link SlotName}s the `AppShell` layout declares. + */ +export function applicationChrome(activeScreen: string, breadcrumb: BreadcrumbEntry[]): Record { + return { + [SlotName.Topbar]: [ + externalComponent( + 'topbar', + ComponentName.Topbar, + {}, + { + logo: [logo('topbar-logo')], + end: [ + externalComponent('topbar-user', ComponentName.UserMenu, { + name: 'Amelia Nyquist', + role: 'Owner', + initials: 'AN', + items: [ + { label: 'Your profile', icon: 'pi pi-user', targetScreen: 'ProfileSettings' }, + { label: 'Help', icon: 'pi pi-question-circle', targetScreen: 'Help' }, + { label: 'Lock', icon: 'pi pi-lock', targetScreen: 'LockScreen' }, + ], + }), + ], + }, + ), + ], + [SlotName.Sidebar]: [externalComponent('sidebar', ComponentName.Sidebar, { title: 'Contoso' }, { logo: [logo('sidebar-logo')] })], + [SlotName.Menu]: menuSections(activeScreen), + [SlotName.Breadcrumb]: [ + externalComponent('breadcrumb', ComponentName.Breadcrumb, { + homeTargetScreen: 'Dashboard', + items: breadcrumb, + }), + ], + [SlotName.Footer]: [externalComponent('footer', ComponentName.Footer, { text: '© Contoso · Built with Cratis Scene' })], + [SlotName.ConfigPanel]: [configPanel()], + }; +} + +/** + * The full-page shell's slot content: the configurator and nothing else. + * + * A sign-in screen has no navigation to render, but it does have to honor the chosen theme - it is very + * often the first page anyone sees, and arriving at a light sign-in page before a dark application is a + * jarring way to start. + */ +export function fullPageChrome(): Record { + return { [SlotName.ConfigPanel]: [configPanel()] }; +} diff --git a/Source/JavaScript/layout.default/gallery/assumedComponentNames.ts b/Source/JavaScript/layout.default/gallery/assumedComponentNames.ts new file mode 100644 index 0000000..8e692ea --- /dev/null +++ b/Source/JavaScript/layout.default/gallery/assumedComponentNames.ts @@ -0,0 +1,47 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * The component names the gallery expects the blueprint's dependencies to declare. + * + * A screen template names components by their **bare** name and lets `resolveComponentName` pick the + * package, which is what makes a template portable across profiles. The cost is that a template can + * reference a name no active package declares, and the only symptom is a dashed red placeholder in the + * middle of a screen. + * + * These lists are that assumption, written down. The specs resolve every name a gallery template + * references against a catalog built from them plus this blueprint's own manifest, so a template that + * reaches for something outside the agreed vocabulary fails a spec rather than a preview. When PrimeReact + * and Cratis Components publish their real manifests, the specs should be pointed at those and these lists + * deleted - at which point any name that was wrong shows up immediately. + */ + +/** Names assumed to come from the `PrimeReact` package - PrimeReact 10 component names, lowerCamelCased. */ +export const assumedPrimeReactComponents: string[] = [ + 'avatar', + 'calendar', + 'chart', + 'checkbox', + 'column', + 'dataTable', + 'divider', + 'dropdown', + 'fileUpload', + 'image', + 'inputNumber', + 'inputText', + 'inputTextarea', + 'message', + 'panel', + 'password', + 'progressBar', + 'steps', + 'tag', + 'timeline', +]; + +/** Names assumed to come from the `Cratis.Components` package. */ +export const assumedCratisComponents: string[] = ['dataPage', 'dataTableForObservableQuery', 'commandDialog', 'dialog']; + +/** The names `core` guarantees, regardless of which packages a profile lists. */ +export const coreComponentNames: string[] = ['text', 'button', 'card']; diff --git a/Source/JavaScript/layout.default/gallery/authTemplates.ts b/Source/JavaScript/layout.default/gallery/authTemplates.ts new file mode 100644 index 0000000..0d42603 --- /dev/null +++ b/Source/JavaScript/layout.default/gallery/authTemplates.ts @@ -0,0 +1,161 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ScreenTemplate } from '@cratis/scene.model'; +import { ComponentName } from '../ComponentName'; +import { SlotName } from '../layouts'; +import { TemplateSlotName } from './TemplateSlotName'; +import { button, card, externalComponent, text } from './elements'; +import { field, formActions } from './widgets'; + +/** + * The six screens that stand between someone and the application: sign in, register, forgotten password, + * new password, verification and the lock screen. + * + * All six fit the full-page layout's `content` slot and fill its `aside` with the branding half. That + * split - a colored panel of brand next to a narrow column of form - is the shape the premium PrimeTek + * templates use for every one of them, and the reason it works is that it gives the form somewhere to be + * *small*, which is what a sign-in form should be. + */ + +/** The branding half every authentication screen shares. */ +function brandingAside(id: string, headline: string, supporting: string) { + return [ + externalComponent(`${id}-logo`, ComponentName.Logo, { label: 'Contoso', initials: 'C' }), + text(`${id}-headline`, headline), + text(`${id}-supporting`, supporting), + ]; +} + +/** Sign in. */ +export const loginTemplate: ScreenTemplate = { + name: 'Login', + fitsSlot: SlotName.Content, + slots: [{ name: SlotName.Aside }, { name: TemplateSlotName.Body }], + content: { + [SlotName.Aside]: brandingAside('login', 'Welcome back', 'Everything your team shipped since you were last here is waiting.'), + [TemplateSlotName.Body]: [ + card('login-card', [ + text('login-title', 'Sign in'), + field('login-email', 'Email', 'inputText', { placeholder: 'you@contoso.com' }), + field('login-password', 'Password', 'password', { feedback: false }), + field('login-remember', 'Keep me signed in', 'checkbox', {}), + formActions('login-actions', 'Sign in', 'Use a different account'), + button('login-forgot', 'I forgot my password', { link: true, targetScreen: 'ForgotPassword' }), + ]), + ], + }, + displayName: 'Sign in', + description: 'Email and password beside the branding panel.', +}; + +/** Register. */ +export const registerTemplate: ScreenTemplate = { + name: 'Register', + fitsSlot: SlotName.Content, + slots: [{ name: SlotName.Aside }, { name: TemplateSlotName.Body }], + content: { + [SlotName.Aside]: brandingAside('register', 'Start in two minutes', 'No card, no call, no sales engineer. Just an account.'), + [TemplateSlotName.Body]: [ + card('register-card', [ + text('register-title', 'Create your account'), + field('register-name', 'Full name', 'inputText', {}), + field('register-email', 'Work email', 'inputText', { placeholder: 'you@contoso.com' }), + field('register-password', 'Password', 'password', { feedback: true }), + field('register-terms', 'I accept the terms of service', 'checkbox', {}), + formActions('register-actions', 'Create account', 'I already have one'), + ]), + ], + }, + displayName: 'Register', + description: 'Account creation with a password strength meter and the terms checkbox.', +}; + +/** Forgotten password: ask for the address. */ +export const forgotPasswordTemplate: ScreenTemplate = { + name: 'ForgotPassword', + fitsSlot: SlotName.Content, + slots: [{ name: SlotName.Aside }, { name: TemplateSlotName.Body }], + content: { + [SlotName.Aside]: brandingAside('forgot', 'It happens', 'Tell us the address you signed up with and we will send a link.'), + [TemplateSlotName.Body]: [ + card('forgot-card', [ + text('forgot-title', 'Reset your password'), + field('forgot-email', 'Email', 'inputText', { placeholder: 'you@contoso.com' }), + formActions('forgot-actions', 'Send the link', 'Back to sign in'), + ]), + ], + }, + displayName: 'Forgotten password', + description: 'One field and one button - the whole point is that it asks for nothing else.', +}; + +/** New password: the other end of the link. */ +export const newPasswordTemplate: ScreenTemplate = { + name: 'NewPassword', + fitsSlot: SlotName.Content, + slots: [{ name: SlotName.Aside }, { name: TemplateSlotName.Body }], + content: { + [SlotName.Aside]: brandingAside('new-password', 'Almost there', 'Choose something you have not used anywhere else.'), + [TemplateSlotName.Body]: [ + card('new-password-card', [ + text('new-password-title', 'Choose a new password'), + field('new-password-value', 'New password', 'password', { feedback: true }), + field('new-password-confirm', 'Confirm it', 'password', { feedback: false }), + formActions('new-password-actions', 'Set the password', 'Cancel'), + ]), + ], + }, + displayName: 'New password', + description: 'Where a reset link lands: choose it, confirm it, done.', +}; + +/** Verification: the code from the email. */ +export const verificationTemplate: ScreenTemplate = { + name: 'Verification', + fitsSlot: SlotName.Content, + slots: [{ name: SlotName.Aside }, { name: TemplateSlotName.Body }], + content: { + [SlotName.Aside]: brandingAside('verification', 'Check your email', 'We sent a six-digit code. It expires in ten minutes.'), + [TemplateSlotName.Body]: [ + card('verification-card', [ + text('verification-title', 'Enter the code'), + externalComponent('verification-steps', 'steps', { model: ['Account', 'Verify', 'Done'], activeIndex: 1 }), + field('verification-code', 'Six-digit code', 'inputText', { maxLength: 6 }), + formActions('verification-actions', 'Verify', 'Send it again'), + ]), + ], + }, + displayName: 'Verification', + description: 'The code step, with the progress indicator that tells you how much is left.', +}; + +/** Lock screen: the session is still there, the person has to prove they are. */ +export const lockScreenTemplate: ScreenTemplate = { + name: 'LockScreen', + fitsSlot: SlotName.Content, + slots: [{ name: SlotName.Aside }, { name: TemplateSlotName.Body }], + content: { + [SlotName.Aside]: brandingAside('lock', 'Locked', 'Your work is exactly where you left it.'), + [TemplateSlotName.Body]: [ + card('lock-card', [ + externalComponent('lock-avatar', 'avatar', { label: 'AN', size: 'xlarge', shape: 'circle' }), + text('lock-name', 'Amelia Nyquist'), + field('lock-password', 'Password', 'password', { feedback: false }), + formActions('lock-actions', 'Unlock', 'Sign in as someone else'), + ]), + ], + }, + displayName: 'Lock screen', + description: 'One person, one password, and no way to lose what was open.', +}; + +/** The six authentication templates. */ +export const authTemplates: ScreenTemplate[] = [ + loginTemplate, + registerTemplate, + forgotPasswordTemplate, + newPasswordTemplate, + verificationTemplate, + lockScreenTemplate, +]; diff --git a/Source/JavaScript/layout.default/gallery/composeScreen.ts b/Source/JavaScript/layout.default/gallery/composeScreen.ts new file mode 100644 index 0000000..57fa8b0 --- /dev/null +++ b/Source/JavaScript/layout.default/gallery/composeScreen.ts @@ -0,0 +1,61 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ExternalComponent, Layout, SceneElement, Screen, ScreenTemplate } from '@cratis/scene.model'; +import { shellComponentForLayout } from '../layouts'; +import { externalComponent } from './elements'; + +/** + * Turns a {@link ScreenTemplate}'s content into slot content on the {@link Layout} that contains it. + * + * The rule has two halves, and both matter. Content filed under a slot the *layout* also declares stays + * under that name - which is how a sign-in template's branding half reaches the full-page layout's `aside` + * region rather than being buried inside the form column. Everything else flows into the slot the + * template's `fitsSlot` names, because that is what `fitsSlot` means: this template occupies that region. + * + * @param template The template to place. + * @param layout The layout it is placed into. + * @returns Slot content keyed by the layout's own slot names. + */ +export function templateContentInLayout(template: ScreenTemplate, layout: Layout): Record { + const layoutSlots = new Set(layout.slots.map(slot => slot.name)); + const placed: Record = {}; + const ownSlotOrder = template.slots.map(slot => slot.name); + const content = template.content ?? {}; + + for (const slotName of ownSlotOrder) { + const elements = content[slotName]; + if (!elements || elements.length === 0) { + continue; + } + + const target = layoutSlots.has(slotName) ? slotName : template.fitsSlot; + if (!target) { + continue; + } + + placed[target] = [...(placed[target] ?? []), ...elements]; + } + + return placed; +} + +/** + * Builds the element tree that renders a {@link Screen}. + * + * The screen's slot content becomes the shell component's slots one-for-one, which is the whole trick: a + * layout's slots and a shell component's slots are the same vocabulary, so nothing has to translate + * between them and a slot filled under a name the shell does not read is a spec failure rather than a + * silently missing region. + * + * @param screen The screen to render. + * @returns The shell element, ready for `SceneElementView`. + */ +export function composeScreenElement(screen: Screen): ExternalComponent { + const shell = shellComponentForLayout(screen.layout); + if (!shell) { + throw new Error(`Screen '${screen.name}' names the layout '${screen.layout}', which this blueprint does not provide.`); + } + + return externalComponent(`screen-${screen.name}`, shell, { screenName: screen.name }, screen.slotContent); +} diff --git a/Source/JavaScript/layout.default/gallery/dialogTemplates.ts b/Source/JavaScript/layout.default/gallery/dialogTemplates.ts new file mode 100644 index 0000000..2494ce6 --- /dev/null +++ b/Source/JavaScript/layout.default/gallery/dialogTemplates.ts @@ -0,0 +1,79 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { DialogTemplate } from '@cratis/scene.model'; +import { TemplateSlotName } from './TemplateSlotName'; +import { button, card, externalComponent, panel, text } from './elements'; +import { field } from './widgets'; + +/** + * The three dialog shapes an application repeats: confirm something destructive, capture a short form, and + * show a record without leaving the list behind it. + * + * A dialog template has no `fitsSlot`, and that absence is the whole distinction. A screen template is + * *placed* - it fills a slot on whatever contains it, and the containment chain is what decides where it + * appears. A dialog is *summoned*: it opens over the application from wherever the code that opened it + * happens to be, so there is no parent slot for it to name. + */ + +/** Confirm: one question, two answers, and enough context to answer it. */ +export const confirmDialogTemplate: DialogTemplate = { + name: 'ConfirmDialog', + slots: [{ name: TemplateSlotName.Header }, { name: TemplateSlotName.Body }, { name: TemplateSlotName.Actions }], + content: { + [TemplateSlotName.Header]: [text('confirm-title', 'Archive this product?')], + [TemplateSlotName.Body]: [ + text('confirm-message', 'Bamboo Watch will stop appearing in the catalog. Existing orders keep it.'), + externalComponent('confirm-note', 'message', { severity: 'warn', text: 'This can be undone from the archive.' }), + ], + [TemplateSlotName.Actions]: [ + panel('confirm-actions', [button('confirm-yes', 'Archive', { severity: 'danger' }), button('confirm-no', 'Keep it', { severity: 'secondary' })]), + ], + }, + displayName: 'Confirmation dialog', + description: 'One question with the consequence spelled out, and a way back.', +}; + +/** Form: a short capture that does not deserve a page of its own. */ +export const formDialogTemplate: DialogTemplate = { + name: 'FormDialog', + slots: [{ name: TemplateSlotName.Header }, { name: TemplateSlotName.Body }, { name: TemplateSlotName.Actions }], + content: { + [TemplateSlotName.Header]: [text('form-dialog-title', 'Invite someone')], + [TemplateSlotName.Body]: [ + field('form-dialog-email', 'Email', 'inputText', { placeholder: 'them@contoso.com' }), + field('form-dialog-role', 'Role', 'dropdown', { options: ['Viewer', 'Editor', 'Administrator'] }), + field('form-dialog-message', 'Message', 'inputTextarea', { rows: 3 }), + ], + [TemplateSlotName.Actions]: [ + panel('form-dialog-actions', [button('form-dialog-send', 'Send invitation', { severity: 'primary' }), button('form-dialog-cancel', 'Cancel', { severity: 'secondary' })]), + ], + }, + displayName: 'Form dialog', + description: 'A handful of fields captured without leaving the page underneath.', +}; + +/** Detail: a record shown over the list it came from. */ +export const detailDialogTemplate: DialogTemplate = { + name: 'DetailDialog', + slots: [{ name: TemplateSlotName.Header }, { name: TemplateSlotName.Body }, { name: TemplateSlotName.SidePanel }, { name: TemplateSlotName.Actions }], + content: { + [TemplateSlotName.Header]: [text('detail-dialog-title', 'ORD-4192')], + [TemplateSlotName.Body]: [ + card('detail-dialog-summary', [ + text('detail-dialog-customer', 'Northwind Traders'), + text('detail-dialog-total', '$1,240.00'), + externalComponent('detail-dialog-status', 'tag', { value: 'Shipped', severity: 'success' }), + ]), + ], + [TemplateSlotName.SidePanel]: [externalComponent('detail-dialog-timeline', 'timeline', { align: 'left' })], + [TemplateSlotName.Actions]: [ + panel('detail-dialog-actions', [button('detail-dialog-open', 'Open full record', { severity: 'primary', targetScreen: 'DetailView' }), button('detail-dialog-close', 'Close', { severity: 'secondary' })]), + ], + }, + displayName: 'Detail dialog', + description: 'A record over the list it came from, with a route to the full page.', +}; + +/** The dialog templates this blueprint provides. */ +export const galleryDialogTemplates: DialogTemplate[] = [confirmDialogTemplate, formDialogTemplate, detailDialogTemplate]; diff --git a/Source/JavaScript/layout.default/gallery/elements.ts b/Source/JavaScript/layout.default/gallery/elements.ts new file mode 100644 index 0000000..6127b16 --- /dev/null +++ b/Source/JavaScript/layout.default/gallery/elements.ts @@ -0,0 +1,65 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ExternalComponent, HorizontalAlignment, Panel, SceneElement, VerticalAlignment, Visibility } from '@cratis/scene.model'; + +/** + * Constructors for the element trees the gallery templates are built from. + * + * `ExternalComponent` inherits fifteen required members from `FrameworkElement` and `VisualElement` - + * visibility, opacity, z-index, both size constraints, margin and both alignments - before it gets to the + * two that matter to a template author. Written as object literals, a page of realistic content is + * ninety percent boilerplate and unreadable. These put the defaults in one place so a template reads as + * the tree it is. + */ + +const elementDefaults = { + properties: {}, + visibility: Visibility.Visible, + isEnabled: true, + opacity: 1, + size: {}, + zIndex: 0, + minimumSize: {}, + maximumSize: {}, + margin: { left: 0, top: 0, right: 0, bottom: 0 }, + horizontalAlignment: HorizontalAlignment.Stretch, + verticalAlignment: VerticalAlignment.Stretch, +}; + +/** + * An {@link ExternalComponent} naming a component by its **bare** name. + * + * Bare rather than package-qualified on purpose: `resolveComponentName` then decides which active package + * wins the name against the profile's priority order, so the same template renders with PrimeReact's + * widgets in one profile and somebody else's in another. A template that qualified its names would pin + * itself to one library and stop being a template. + */ +export function externalComponent( + id: string, + componentName: string, + properties: Record = {}, + slots: Record = {}, +): ExternalComponent { + return { ...elementDefaults, id, name: id, properties, componentName, slots }; +} + +/** A {@link Panel} grouping children, for a row or column of content inside a slot. */ +export function panel(id: string, children: SceneElement[]): Panel { + return { ...elementDefaults, id, name: id, children }; +} + +/** A `core:text` run - the one component name guaranteed to resolve in every profile. */ +export function text(id: string, value: string): ExternalComponent { + return externalComponent(id, 'text', { text: value }); +} + +/** A `core:button`. */ +export function button(id: string, label: string, properties: Record = {}): ExternalComponent { + return externalComponent(id, 'button', { label, ...properties }); +} + +/** A `core:card` wrapping content. */ +export function card(id: string, content: SceneElement[], properties: Record = {}): ExternalComponent { + return externalComponent(id, 'card', properties, { content }); +} diff --git a/Source/JavaScript/layout.default/gallery/index.ts b/Source/JavaScript/layout.default/gallery/index.ts new file mode 100644 index 0000000..3c6a090 --- /dev/null +++ b/Source/JavaScript/layout.default/gallery/index.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export * from './elements'; +export * from './widgets'; +export * from './TemplateSlotName'; +export * from './NavigationEntry'; +export * from './navigation'; +export * from './applicationChrome'; +export * from './composeScreen'; +export * from './nesting'; +export * from './workspaceTemplates'; +export * from './supportTemplates'; +export * from './authTemplates'; +export * from './statusTemplates'; +export * from './dialogTemplates'; +export * from './screens'; +export * from './assumedComponentNames'; diff --git a/Source/JavaScript/layout.default/gallery/navigation.ts b/Source/JavaScript/layout.default/gallery/navigation.ts new file mode 100644 index 0000000..96a2104 --- /dev/null +++ b/Source/JavaScript/layout.default/gallery/navigation.ts @@ -0,0 +1,71 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Contribution, SceneElement } from '@cratis/scene.model'; +import { ComponentName } from '../ComponentName'; +import { NavigationEntry } from './NavigationEntry'; +import { externalComponent } from './elements'; + +/** The contribution point the built-in navigation aggregation reads. */ +export const navigationContributionPoint = 'Navigation'; + +/** + * The gallery's navigation, declared once. + * + * One list, two consumers: the sidebar menu renders it as `menuItem` elements, and the same elements go to + * the `Navigation` contribution point where `aggregateContributions` + `extractNavigationItem` turn them + * back into navigation items. A blueprint that maintained a menu *and* a separate navigation declaration + * would have two lists that agree right up until someone adds a screen to one of them. + */ +export const navigationEntries: NavigationEntry[] = [ + { label: 'Dashboard', targetScreen: 'Dashboard', icon: 'pi pi-home', group: 'Workspace', order: 10 }, + { label: 'Products', targetScreen: 'CrudList', icon: 'pi pi-box', group: 'Workspace', order: 20 }, + { label: 'Product detail', targetScreen: 'DetailView', icon: 'pi pi-file', group: 'Workspace', order: 30 }, + { label: 'New product', targetScreen: 'FormPage', icon: 'pi pi-plus-circle', group: 'Workspace', order: 40 }, + { label: 'Invoices', targetScreen: 'Invoice', icon: 'pi pi-receipt', group: 'Workspace', order: 50 }, + { label: 'Nothing yet', targetScreen: 'Empty', icon: 'pi pi-inbox', group: 'Workspace', order: 60 }, + { label: 'Users', targetScreen: 'UserManagement', icon: 'pi pi-users', group: 'Administration', order: 10 }, + { label: 'Your profile', targetScreen: 'ProfileSettings', icon: 'pi pi-user', group: 'Administration', order: 20 }, + { label: 'Documentation', targetScreen: 'Documentation', icon: 'pi pi-book', group: 'Support', order: 10 }, + { label: 'Help', targetScreen: 'Help', icon: 'pi pi-question-circle', group: 'Support', order: 20 }, +]; + +/** + * The element one navigation entry becomes. + * + * `routeParameterBindings` is present and empty rather than omitted, because that is what + * `extractNavigationItem` expects to read - and an omitted bag and an empty one are the same thing right + * up until something iterates it. + */ +export function navigationElement(entry: NavigationEntry, activeScreen?: string): SceneElement { + return externalComponent(`nav-${entry.targetScreen}`, ComponentName.MenuItem, { + label: entry.label, + targetScreen: entry.targetScreen, + routeParameterBindings: {}, + order: entry.order, + group: entry.group, + icon: entry.icon, + isActive: entry.targetScreen === activeScreen, + }); +} + +/** + * The navigation as contributions to the `Navigation` contribution point. + * + * A screen carries these in its `contributions`, and anything bound to the contribution point recomputes + * from whatever is currently in scope - so a screen that contributes an extra entry gets it in the + * navigation without the navigation knowing that screen exists. + */ +export const navigationContributions: Contribution[] = navigationEntries.map(entry => ({ + contributionPointName: navigationContributionPoint, + content: navigationElement(entry), + order: entry.order, +})); + +/** The navigation entries of one group, in order. */ +export function entriesInGroup(group: string): NavigationEntry[] { + return navigationEntries.filter(entry => entry.group === group).sort((first, second) => first.order - second.order); +} + +/** Every group named by the navigation, in first-declared order. */ +export const navigationGroups: string[] = [...new Set(navigationEntries.map(entry => entry.group))]; diff --git a/Source/JavaScript/layout.default/gallery/nesting.ts b/Source/JavaScript/layout.default/gallery/nesting.ts new file mode 100644 index 0000000..bfdcfaf --- /dev/null +++ b/Source/JavaScript/layout.default/gallery/nesting.ts @@ -0,0 +1,82 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ScreenTemplate } from '@cratis/scene.model'; +import { ComponentName } from '../ComponentName'; +import { SlotName, column, slotLeaf } from '../layouts'; +import { TemplateSlotName } from './TemplateSlotName'; +import { button, externalComponent } from './elements'; +import { pageHeader } from './widgets'; + +/** + * A worked three-level chain, from the application layout down to a slice. + * + * The nesting rule is one rule applied at every depth: a template names, in `fitsSlot`, a slot declared by + * whatever contains it. A module's template fits the application layout's `content`; a feature's template + * fits a slot the module's template declares; a slice's fits one the feature's declares. Nothing here is a + * special case for a particular level - which is exactly why the hierarchy can be arbitrarily deep without + * a second mechanism. + * + * These three exist to make that concrete and to be asserted by a spec, because "it composes recursively" + * is the kind of claim that is true in a design document and wrong in the code. + */ + +/** + * Module level: fits the application shell's `content` slot. + * + * It brings its own header - a module always has a name and a description - and offers a body for the + * feature inside it plus an optional side panel. + */ +export const moduleWorkspaceTemplate: ScreenTemplate = { + name: 'ModuleWorkspace', + fitsSlot: SlotName.Content, + slots: [{ name: TemplateSlotName.Header }, { name: TemplateSlotName.Body }, { name: TemplateSlotName.SidePanel }], + content: { + [TemplateSlotName.Header]: [pageHeader('module-header', 'Operations', 'Everything the operations module owns')], + }, + displayName: 'Module workspace', + description: 'Module level: fits the application layout content slot and offers a body for one feature.', +}; + +/** + * Feature level: fits the module workspace's `body` slot. + * + * It brings a toolbar, because a feature is where actions belong - a module is a grouping, a slice is one + * behavior, and the feature in between is what a user thinks of as a screen with buttons on it. + */ +export const featureSectionTemplate: ScreenTemplate = { + name: 'FeatureSection', + fitsSlot: TemplateSlotName.Body, + slots: [{ name: TemplateSlotName.Toolbar }, { name: TemplateSlotName.Body }], + content: { + [TemplateSlotName.Toolbar]: [ + button('feature-new', 'New', { severity: 'primary', icon: 'pi pi-plus' }), + button('feature-export', 'Export', { severity: 'secondary', icon: 'pi pi-download' }), + ], + }, + displayName: 'Feature section', + description: 'Feature level: fits a module workspace body slot and brings the action toolbar.', +}; + +/** + * Slice level: fits the feature section's `body` slot. + * + * The end of the chain, and deliberately the thinnest thing in it - one behavior's surface, with somewhere + * to put it and somewhere to put its actions. + */ +export const sliceSectionTemplate: ScreenTemplate = { + name: 'SliceSection', + fitsSlot: TemplateSlotName.Body, + slots: [{ name: TemplateSlotName.Body }, { name: TemplateSlotName.Actions }], + arrangement: { + root: column([slotLeaf(TemplateSlotName.Body, { grow: 1 }), slotLeaf(TemplateSlotName.Actions)]), + }, + content: { + [TemplateSlotName.Body]: [externalComponent('slice-body', ComponentName.PageHeader, { title: 'Adjustment', subtitle: 'One behavior, one surface' })], + }, + displayName: 'Slice section', + description: 'Slice level: fits a feature section body slot and hosts one behavior.', +}; + +/** The chain, outermost first - what a spec walks to prove `fitsSlot` resolves at every level. */ +export const nestingChainTemplates: ScreenTemplate[] = [moduleWorkspaceTemplate, featureSectionTemplate, sliceSectionTemplate]; diff --git a/Source/JavaScript/layout.default/gallery/screens.ts b/Source/JavaScript/layout.default/gallery/screens.ts new file mode 100644 index 0000000..0ac7678 --- /dev/null +++ b/Source/JavaScript/layout.default/gallery/screens.ts @@ -0,0 +1,97 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Layout, Screen, ScreenTemplate } from '@cratis/scene.model'; +import { LayoutName, appShellLayout, fullPageLayout } from '../layouts'; +import { BreadcrumbEntry, applicationChrome, fullPageChrome } from './applicationChrome'; +import { templateContentInLayout } from './composeScreen'; +import { authTemplates } from './authTemplates'; +import { navigationContributions } from './navigation'; +import { nestingChainTemplates } from './nesting'; +import { statusTemplates } from './statusTemplates'; +import { supportTemplates } from './supportTemplates'; +import { workspaceTemplates } from './workspaceTemplates'; + +/** + * The screen templates this blueprint provides. + * + * Ten shapes for inside the application shell, ten for the full-page shell, and the three-level nesting + * chain that demonstrates how `fitsSlot` composes. The list is what the manifest's `screenTemplates` names + * and what the bundle provides; `validatePackageBundle` proves the two agree. + */ +export const galleryScreenTemplates: ScreenTemplate[] = [ + ...workspaceTemplates, + ...supportTemplates, + ...authTemplates, + ...statusTemplates, + ...nestingChainTemplates, +]; + +/** Which layout each template renders inside - the application shell unless it is one of the chrome-less screens. */ +const fullPageTemplateNames = new Set([...authTemplates, ...statusTemplates].map(template => template.name)); + +/** The trail shown above each application-shell screen. */ +const breadcrumbs: Record = { + Dashboard: [{ label: 'Dashboard' }], + CrudList: [{ label: 'Catalog', targetScreen: 'Dashboard' }, { label: 'Products' }], + DetailView: [{ label: 'Catalog', targetScreen: 'Dashboard' }, { label: 'Products', targetScreen: 'CrudList' }, { label: 'Bamboo Watch' }], + FormPage: [{ label: 'Catalog', targetScreen: 'Dashboard' }, { label: 'Products', targetScreen: 'CrudList' }, { label: 'New product' }], + Empty: [{ label: 'Catalog', targetScreen: 'Dashboard' }, { label: 'Products' }], + Documentation: [{ label: 'Support', targetScreen: 'Dashboard' }, { label: 'Documentation' }], + ProfileSettings: [{ label: 'Administration', targetScreen: 'Dashboard' }, { label: 'Your profile' }], + UserManagement: [{ label: 'Administration', targetScreen: 'Dashboard' }, { label: 'Users' }], + Invoice: [{ label: 'Billing', targetScreen: 'Dashboard' }, { label: 'INV-2043' }], + Help: [{ label: 'Support', targetScreen: 'Dashboard' }, { label: 'Help' }], + ModuleWorkspace: [{ label: 'Operations' }], + FeatureSection: [{ label: 'Operations', targetScreen: 'ModuleWorkspace' }, { label: 'Adjustments' }], + SliceSection: [ + { label: 'Operations', targetScreen: 'ModuleWorkspace' }, + { label: 'Adjustments', targetScreen: 'FeatureSection' }, + { label: 'Record an adjustment' }, + ], +}; + +/** + * Builds the {@link Screen} that instantiates one template. + * + * A screen is an instance, not a shape: it names the layout it renders in, the template it fills, the + * content that fills it, and what it contributes elsewhere. Everything structural comes from the template, + * which is why adding a screen is a handful of lines rather than another copy of the chrome. + */ +function screenFor(template: ScreenTemplate): Screen { + const isFullPage = fullPageTemplateNames.has(template.name); + const layout: Layout = isFullPage ? fullPageLayout : appShellLayout; + const chrome = isFullPage ? fullPageChrome() : applicationChrome(template.name, breadcrumbs[template.name] ?? [{ label: template.displayName ?? template.name }]); + + return { + name: template.name, + layout: isFullPage ? LayoutName.FullPage : LayoutName.AppShell, + screenTemplate: template.name, + slotContent: mergeSlotContent(chrome, templateContentInLayout(template, layout)), + forms: [], + contributions: isFullPage ? [] : navigationContributions, + }; +} + +function mergeSlotContent(chrome: Record, content: Record): Screen['slotContent'] { + const merged: Screen['slotContent'] = { ...chrome }; + for (const [slotName, elements] of Object.entries(content)) { + merged[slotName] = [...(merged[slotName] ?? []), ...elements]; + } + + return merged; +} + +/** + * The gallery: one screen per template, ready to boot through the real engine. + * + * These exist so a preview is a working miniature application rather than a set of pictures - the same + * `Screen` shape Stage produces, put through the same `Scene.Engine` and `Scene.React`, with no separate + * preview pipeline and nothing mocked. + */ +export const galleryScreens: Screen[] = galleryScreenTemplates.map(screenFor); + +/** One gallery screen by name, for a story or a host that boots a specific one. */ +export function galleryScreen(name: string): Screen | undefined { + return galleryScreens.find(screen => screen.name === name); +} diff --git a/Source/JavaScript/layout.default/gallery/statusTemplates.ts b/Source/JavaScript/layout.default/gallery/statusTemplates.ts new file mode 100644 index 0000000..0abf906 --- /dev/null +++ b/Source/JavaScript/layout.default/gallery/statusTemplates.ts @@ -0,0 +1,118 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ScreenTemplate } from '@cratis/scene.model'; +import { ComponentName } from '../ComponentName'; +import { SlotName } from '../layouts'; +import { TemplateSlotName } from './TemplateSlotName'; +import { button, card, externalComponent, panel, text } from './elements'; +import { widget } from './widgets'; + +/** + * The four screens that are not part of anyone's plan: a server error, a refusal, a wrong address, and the + * page that has to sell the product before any of the others exist. + * + * They use the full-page layout for the same reason the sign-in screens do - none of them has navigation + * to render - and they matter more than their frequency suggests. An error page is the screen most likely + * to be someone's first impression of how carefully an application was built. + */ + +/** Something broke on our side. */ +export const errorTemplate: ScreenTemplate = { + name: 'Error', + fitsSlot: SlotName.Content, + slots: [{ name: TemplateSlotName.Body }], + content: { + [TemplateSlotName.Body]: [ + card('error-card', [ + externalComponent('error-tag', 'tag', { value: '500', severity: 'danger' }), + text('error-title', 'Something went wrong on our side'), + text('error-message', 'The team has been told. Try again in a moment - nothing you were working on was lost.'), + panel('error-actions', [ + button('error-retry', 'Try again', { severity: 'primary' }), + button('error-home', 'Back to the dashboard', { severity: 'secondary', targetScreen: 'Dashboard' }), + ]), + ]), + ], + }, + displayName: 'Error', + description: 'A server-side failure, said plainly, with a way onward.', +}; + +/** Signed in, and still not allowed. */ +export const accessDeniedTemplate: ScreenTemplate = { + name: 'AccessDenied', + fitsSlot: SlotName.Content, + slots: [{ name: TemplateSlotName.Body }], + content: { + [TemplateSlotName.Body]: [ + card('denied-card', [ + externalComponent('denied-tag', 'tag', { value: '403', severity: 'warning' }), + text('denied-title', 'You do not have access to this'), + text('denied-message', 'Your account is signed in, but it is not allowed here. An administrator can change that.'), + panel('denied-actions', [ + button('denied-request', 'Request access', { severity: 'primary' }), + button('denied-home', 'Back to the dashboard', { severity: 'secondary', targetScreen: 'Dashboard' }), + ]), + ]), + ], + }, + displayName: 'Access denied', + description: 'A refusal that distinguishes "not signed in" from "not allowed".', +}; + +/** No such address. */ +export const notFoundTemplate: ScreenTemplate = { + name: 'NotFound', + fitsSlot: SlotName.Content, + slots: [{ name: TemplateSlotName.Body }], + content: { + [TemplateSlotName.Body]: [ + card('not-found-card', [ + externalComponent('not-found-tag', 'tag', { value: '404', severity: 'info' }), + text('not-found-title', 'That page is not here'), + text('not-found-message', 'The link may be old, or the thing it pointed at may have been removed.'), + externalComponent('not-found-search', 'inputText', { placeholder: 'Search instead' }), + button('not-found-home', 'Back to the dashboard', { severity: 'secondary', targetScreen: 'Dashboard' }), + ]), + ], + }, + displayName: 'Not found', + description: 'A wrong address, with a search box rather than a dead end.', +}; + +/** The page that has to do the selling. */ +export const landingTemplate: ScreenTemplate = { + name: 'Landing', + fitsSlot: SlotName.Content, + slots: [{ name: TemplateSlotName.Header }, { name: TemplateSlotName.Body }, { name: TemplateSlotName.Actions }], + content: { + [TemplateSlotName.Header]: [ + panel('landing-nav', [ + externalComponent('landing-logo', ComponentName.Logo, { label: 'Contoso', initials: 'C' }), + button('landing-signin', 'Sign in', { severity: 'secondary', targetScreen: 'Login' }), + button('landing-start', 'Start free', { severity: 'primary', targetScreen: 'Register' }), + ]), + ], + [TemplateSlotName.Body]: [ + card('landing-hero', [ + text('landing-headline', 'Ship the shell on day one'), + text('landing-subhead', 'Eight menu modes, twenty screen templates and two themes, in one blueprint an application picks once.'), + button('landing-cta', 'Start free', { severity: 'primary', targetScreen: 'Register' }), + ]), + widget('landing-feature-modes', 'Every mode people expect', [text('landing-modes-text', 'Static, overlay, slim, slim+, compact, horizontal, reveal and drawer.')]), + widget('landing-feature-themes', 'Themeable to the token', [text('landing-themes-text', 'Ten semantic tokens define the whole shell. Swap them and everything follows.')]), + widget('landing-feature-templates', 'The pages you were going to build anyway', [ + text('landing-templates-text', 'Dashboard, list, detail, form, invoice, settings, help - and every screen around signing in.'), + ]), + ], + [TemplateSlotName.Actions]: [ + panel('landing-footer', [text('landing-copyright', '© Contoso'), button('landing-contact', 'Talk to us', { severity: 'secondary' })]), + ], + }, + displayName: 'Landing', + description: 'The marketing front door, with its own navigation because it has no application chrome.', +}; + +/** The four status and marketing templates. */ +export const statusTemplates: ScreenTemplate[] = [errorTemplate, accessDeniedTemplate, notFoundTemplate, landingTemplate]; diff --git a/Source/JavaScript/layout.default/gallery/supportTemplates.ts b/Source/JavaScript/layout.default/gallery/supportTemplates.ts new file mode 100644 index 0000000..bd154a1 --- /dev/null +++ b/Source/JavaScript/layout.default/gallery/supportTemplates.ts @@ -0,0 +1,163 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ScreenTemplate } from '@cratis/scene.model'; +import { SlotName } from '../layouts'; +import { TemplateSlotName } from './TemplateSlotName'; +import { button, card, externalComponent, text } from './elements'; +import { field, formActions, pageHeader, table, widget } from './widgets'; + +/** + * The five shapes an application needs that are not the CRUD loop: documentation, the signed-in user's own + * settings, user administration, a printable document, and help. + * + * They are in the set because leaving them out is what makes a template line feel thin. Every real + * application grows all five, and the ones that were never designed are the ones that end up looking like + * a different product. + */ + +/** Documentation: a table of contents beside prose. */ +export const documentationTemplate: ScreenTemplate = { + name: 'Documentation', + fitsSlot: SlotName.Content, + slots: [{ name: TemplateSlotName.SidePanel }, { name: TemplateSlotName.Body }], + content: { + [TemplateSlotName.SidePanel]: [ + widget('docs-toc', 'On this page', [ + text('docs-toc-1', 'Getting started'), + text('docs-toc-2', 'Configuring the shell'), + text('docs-toc-3', 'Themes'), + text('docs-toc-4', 'Publishing a blueprint'), + ]), + ], + [TemplateSlotName.Body]: [ + pageHeader('docs-header', 'Getting started', 'Everything you need to run the blueprint locally'), + card('docs-body', [ + text('docs-intro', 'Install the blueprint, add it to a ui profile, and pick a layout. The shell reads its mode from the configurator and remembers it.'), + externalComponent('docs-note', 'message', { severity: 'info', text: 'The shell needs its stylesheet imported once, at the host.' }), + ]), + ], + }, + displayName: 'Documentation', + description: 'Prose with a table of contents beside it.', +}; + +/** Profile settings: the signed-in user editing their own account. */ +export const profileSettingsTemplate: ScreenTemplate = { + name: 'ProfileSettings', + fitsSlot: SlotName.Content, + slots: [{ name: TemplateSlotName.Header }, { name: TemplateSlotName.Body }, { name: TemplateSlotName.Actions }], + content: { + [TemplateSlotName.Header]: [pageHeader('profile-header', 'Your profile', 'How you appear to everyone else in the workspace')], + [TemplateSlotName.Body]: [ + card('profile-identity', [ + externalComponent('profile-avatar', 'avatar', { label: 'AN', size: 'large', shape: 'circle' }), + field('profile-name', 'Display name', 'inputText', { value: 'Amelia Nyquist' }), + field('profile-email', 'Email', 'inputText', { value: 'amelia@contoso.com' }), + field('profile-photo', 'Photo', 'fileUpload', { accept: 'image/*' }), + ]), + card('profile-security', [ + field('profile-current', 'Current password', 'password', {}), + field('profile-new', 'New password', 'password', { feedback: true }), + field('profile-notify', 'Email me about mentions', 'checkbox', { checked: true }), + ]), + ], + [TemplateSlotName.Actions]: [formActions('profile-actions', 'Save changes')], + }, + displayName: 'Profile settings', + description: 'The signed-in user editing their own name, photo, password and notifications.', +}; + +/** User management: administering everybody else. */ +export const userManagementTemplate: ScreenTemplate = { + name: 'UserManagement', + fitsSlot: SlotName.Content, + slots: [{ name: TemplateSlotName.Toolbar }, { name: TemplateSlotName.Body }], + content: { + [TemplateSlotName.Toolbar]: [ + pageHeader('users-header', 'Users', '34 people, 3 pending invitations', [button('users-invite', 'Invite people', { severity: 'primary', icon: 'pi pi-user-plus' })]), + ], + [TemplateSlotName.Body]: [ + table( + 'users-table', + [ + { field: 'name', header: 'Name' }, + { field: 'email', header: 'Email' }, + { field: 'role', header: 'Role' }, + { field: 'status', header: 'Status' }, + { field: 'lastSeen', header: 'Last seen' }, + ], + [ + { name: 'Amelia Nyquist', email: 'amelia@contoso.com', role: 'Owner', status: 'Active', lastSeen: '2 minutes ago' }, + { name: 'Bjørn Holt', email: 'bjorn@contoso.com', role: 'Administrator', status: 'Active', lastSeen: 'Yesterday' }, + { name: 'Chidi Okafor', email: 'chidi@contoso.com', role: 'Editor', status: 'Active', lastSeen: '3 days ago' }, + { name: 'Dana Whitfield', email: 'dana@contoso.com', role: 'Viewer', status: 'Invited', lastSeen: 'Never' }, + ], + ), + ], + }, + displayName: 'User management', + description: 'The people table, with roles, status and an invitation action.', +}; + +/** Invoice: a printable document, which is a different shape from a screen. */ +export const invoiceTemplate: ScreenTemplate = { + name: 'Invoice', + fitsSlot: SlotName.Content, + slots: [{ name: TemplateSlotName.Header }, { name: TemplateSlotName.Body }, { name: TemplateSlotName.Actions }], + content: { + [TemplateSlotName.Header]: [ + pageHeader('invoice-header', 'Invoice INV-2043', 'Issued 4 March · Due 3 April', [button('invoice-print', 'Print', { severity: 'secondary', icon: 'pi pi-print' })]), + ], + [TemplateSlotName.Body]: [ + card('invoice-parties', [ + text('invoice-from', 'From: Contoso Ltd, 4 Chandler Street, Dublin'), + text('invoice-to', 'To: Northwind Traders, 18 Quay Road, Cork'), + ]), + table( + 'invoice-lines', + [ + { field: 'description', header: 'Description' }, + { field: 'quantity', header: 'Quantity' }, + { field: 'unitPrice', header: 'Unit price' }, + { field: 'amount', header: 'Amount' }, + ], + [ + { description: 'Bamboo Watch', quantity: 12, unitPrice: '$65.00', amount: '$780.00' }, + { description: 'Blue Band', quantity: 4, unitPrice: '$79.00', amount: '$316.00' }, + { description: 'Expedited shipping', quantity: 1, unitPrice: '$144.00', amount: '$144.00' }, + ], + ), + card('invoice-total', [text('invoice-subtotal', 'Subtotal: $1,240.00'), text('invoice-vat', 'VAT (23%): $285.20'), text('invoice-due', 'Total due: $1,525.20')]), + ], + [TemplateSlotName.Actions]: [formActions('invoice-actions', 'Mark as paid', 'Send reminder')], + }, + displayName: 'Invoice', + description: 'A printable document: parties, line items and totals.', +}; + +/** Help: the answers, and a way to ask when they are not there. */ +export const helpTemplate: ScreenTemplate = { + name: 'Help', + fitsSlot: SlotName.Content, + slots: [{ name: TemplateSlotName.Header }, { name: TemplateSlotName.Body }, { name: TemplateSlotName.SidePanel }], + content: { + [TemplateSlotName.Header]: [pageHeader('help-header', 'Help', 'Search the answers, or ask us directly')], + [TemplateSlotName.Body]: [ + externalComponent('help-search', 'inputText', { placeholder: 'Search help' }), + widget('help-popular', 'Popular answers', [ + text('help-1', 'How do I invite someone to the workspace?'), + text('help-2', 'Why can I not see the sidebar on my phone?'), + text('help-3', 'How do I change the theme?'), + ]), + ], + [TemplateSlotName.SidePanel]: [ + widget('help-contact', 'Still stuck?', [text('help-contact-text', 'We answer within one working day.'), button('help-contact-button', 'Contact support', { severity: 'primary' })]), + ], + }, + displayName: 'Help', + description: 'Searchable answers with a route to a human.', +}; + +/** The five support templates. */ +export const supportTemplates: ScreenTemplate[] = [documentationTemplate, profileSettingsTemplate, userManagementTemplate, invoiceTemplate, helpTemplate]; diff --git a/Source/JavaScript/layout.default/gallery/widgets.ts b/Source/JavaScript/layout.default/gallery/widgets.ts new file mode 100644 index 0000000..b01f4fe --- /dev/null +++ b/Source/JavaScript/layout.default/gallery/widgets.ts @@ -0,0 +1,63 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { SceneElement } from '@cratis/scene.model'; +import { ComponentName } from '../ComponentName'; +import { button, card, externalComponent, panel, text } from './elements'; + +/** + * The repeated pieces the gallery's screen templates are built from. + * + * The gallery only earns its place if a preview shows something that looks like an application - a + * dashboard with four stat cards over two columns of larger widgets, a list with real columns and real + * rows. Seeded content that says "Lorem ipsum" proves the renderer runs and nothing else. These builders + * are what keep twenty templates' worth of realistic content from becoming twenty pages of literals. + */ + +/** A screen's title, subtitle and actions - the furniture every template starts with. */ +export function pageHeader(id: string, title: string, subtitle: string, actions: SceneElement[] = []): SceneElement { + return externalComponent(id, ComponentName.PageHeader, { title, subtitle }, { actions }); +} + +/** + * One of the four figures across the top of a dashboard - the composition Sakai's dashboard opens with, + * and the one every template in the line has copied since. + */ +export function statCard(id: string, label: string, value: string, delta: string, icon: string): SceneElement { + return card(id, [text(`${id}-label`, label), text(`${id}-value`, value), text(`${id}-delta`, delta)], { icon, variant: 'stat' }); +} + +/** A titled surface holding a widget's content. */ +export function widget(id: string, title: string, content: SceneElement[]): SceneElement { + return card(id, [text(`${id}-title`, title), ...content], { title }); +} + +/** A table with real columns and real rows, so a list template looks like a list. */ +export function table(id: string, columns: { field: string; header: string }[], rows: Record[]): SceneElement { + return externalComponent( + id, + 'dataTable', + { value: rows, dataKey: columns[0]?.field ?? 'id', paginator: rows.length > 8, rows: 8 }, + { columns: columns.map(column => externalComponent(`${id}-${column.field}`, 'column', { field: column.field, header: column.header, sortable: true })) }, + ); +} + +/** One labeled input in a form template. */ +export function field(id: string, label: string, componentName: string, properties: Record = {}): SceneElement { + return panel(id, [text(`${id}-label`, label), externalComponent(`${id}-input`, componentName, properties)]); +} + +/** A row of buttons closing a form. */ +export function formActions(id: string, confirmLabel: string, cancelLabel = 'Cancel'): SceneElement { + return panel(id, [button(`${id}-confirm`, confirmLabel, { severity: 'primary' }), button(`${id}-cancel`, cancelLabel, { severity: 'secondary' })]); +} + +/** The empty state a list shows before anything exists - the designed one, never a build-time apology. */ +export function emptyState(id: string, title: string, message: string, actionLabel: string): SceneElement { + return card(id, [ + externalComponent(`${id}-icon`, 'image', { alt: title }), + text(`${id}-title`, title), + text(`${id}-message`, message), + button(`${id}-action`, actionLabel, { severity: 'primary' }), + ]); +} diff --git a/Source/JavaScript/layout.default/gallery/workspaceTemplates.ts b/Source/JavaScript/layout.default/gallery/workspaceTemplates.ts new file mode 100644 index 0000000..68c0cce --- /dev/null +++ b/Source/JavaScript/layout.default/gallery/workspaceTemplates.ts @@ -0,0 +1,183 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ScreenTemplate, WidthSizeClass } from '@cratis/scene.model'; +import { SlotName, column, grid, slotLeaf } from '../layouts'; +import { TemplateSlotName } from './TemplateSlotName'; +import { button, card, externalComponent, text } from './elements'; +import { emptyState, field, formActions, pageHeader, statCard, table, widget } from './widgets'; + +/** + * The five workhorse shapes inside an application shell: a dashboard, a list, a detail, a form and the + * designed empty state. + * + * Every one fits the layout's `content` slot, and every one carries realistic seeded content rather than + * placeholder text. A gallery whose dashboard shows four boxes labeled "Card" proves the renderer runs; a + * gallery whose dashboard shows revenue, orders, customers and a real table proves the blueprint is worth + * starting an application from. + */ + +/** + * The dashboard: four figures across the top, then two columns of larger widgets. + * + * This is Sakai's composition, and the reason to copy it is that it is the arrangement people already read + * fluently - the eye takes the row of numbers first and then settles into the detail. + * + * Its own arrangement collapses the two columns into one at a compact width, because a two-column widget + * grid on a phone is two columns of unreadable slivers. + */ +export const dashboardTemplate: ScreenTemplate = { + name: 'Dashboard', + fitsSlot: SlotName.Content, + slots: [{ name: TemplateSlotName.Stats }, { name: TemplateSlotName.Primary }, { name: TemplateSlotName.Secondary }], + arrangement: { + root: column([slotLeaf(TemplateSlotName.Stats), grid([slotLeaf(TemplateSlotName.Primary, { span: 2 }), slotLeaf(TemplateSlotName.Secondary)], 3, 16)]), + overrides: [ + { + width: WidthSizeClass.Compact, + root: column([slotLeaf(TemplateSlotName.Stats), slotLeaf(TemplateSlotName.Primary), slotLeaf(TemplateSlotName.Secondary)], 16), + }, + ], + }, + content: { + [TemplateSlotName.Stats]: [ + statCard('stat-revenue', 'Revenue', '$284,120', '+12.4% this month', 'pi pi-dollar'), + statCard('stat-orders', 'Orders', '1,842', '+3.1% this month', 'pi pi-shopping-cart'), + statCard('stat-customers', 'Customers', '9,410', '+128 new', 'pi pi-users'), + statCard('stat-open', 'Open tickets', '17', '-4 since Monday', 'pi pi-inbox'), + ], + [TemplateSlotName.Primary]: [ + widget('widget-revenue', 'Revenue over time', [externalComponent('revenue-chart', 'chart', { type: 'line' })]), + widget('widget-orders', 'Recent orders', [ + table( + 'orders-table', + [ + { field: 'reference', header: 'Reference' }, + { field: 'customer', header: 'Customer' }, + { field: 'total', header: 'Total' }, + { field: 'status', header: 'Status' }, + ], + [ + { reference: 'ORD-4192', customer: 'Northwind Traders', total: '$1,240.00', status: 'Shipped' }, + { reference: 'ORD-4191', customer: 'Contoso Ltd', total: '$318.50', status: 'Packing' }, + { reference: 'ORD-4188', customer: 'Fabrikam', total: '$2,980.00', status: 'Awaiting payment' }, + { reference: 'ORD-4184', customer: 'Adventure Works', total: '$76.20', status: 'Shipped' }, + ], + ), + ]), + ], + [TemplateSlotName.Secondary]: [ + widget('widget-activity', 'Activity', [externalComponent('activity-timeline', 'timeline', { align: 'left' })]), + widget('widget-capacity', 'Warehouse capacity', [externalComponent('capacity-bar', 'progressBar', { value: 68 })]), + ], + }, + displayName: 'Dashboard', + description: 'Four stat cards over two columns of widgets - the composition every template line opens with.', +}; + +/** The list: a filter toolbar, a real table, and the row actions a list needs. */ +export const crudListTemplate: ScreenTemplate = { + name: 'CrudList', + fitsSlot: SlotName.Content, + slots: [{ name: TemplateSlotName.Toolbar }, { name: TemplateSlotName.Body }], + content: { + [TemplateSlotName.Toolbar]: [ + pageHeader('crud-header', 'Products', '412 products across 9 categories', [ + button('crud-new', 'New product', { severity: 'primary', icon: 'pi pi-plus' }), + button('crud-import', 'Import', { severity: 'secondary', icon: 'pi pi-upload' }), + ]), + externalComponent('crud-search', 'inputText', { placeholder: 'Search products' }), + ], + [TemplateSlotName.Body]: [ + table( + 'products-table', + [ + { field: 'code', header: 'Code' }, + { field: 'name', header: 'Name' }, + { field: 'category', header: 'Category' }, + { field: 'price', header: 'Price' }, + { field: 'stock', header: 'In stock' }, + ], + [ + { code: 'P-1001', name: 'Bamboo Watch', category: 'Accessories', price: '$65.00', stock: 24 }, + { code: 'P-1002', name: 'Black Watch', category: 'Accessories', price: '$72.00', stock: 61 }, + { code: 'P-1003', name: 'Blue Band', category: 'Fitness', price: '$79.00', stock: 2 }, + { code: 'P-1004', name: 'Blue T-Shirt', category: 'Clothing', price: '$29.00', stock: 25 }, + { code: 'P-1005', name: 'Bracelet', category: 'Accessories', price: '$15.00', stock: 73 }, + ], + ), + ], + }, + displayName: 'List', + description: 'A searchable table with a header, primary action and row data.', +}; + +/** The detail: a summary panel beside the record's own sections. */ +export const detailViewTemplate: ScreenTemplate = { + name: 'DetailView', + fitsSlot: SlotName.Content, + slots: [{ name: TemplateSlotName.Header }, { name: TemplateSlotName.Body }, { name: TemplateSlotName.SidePanel }], + content: { + [TemplateSlotName.Header]: [ + pageHeader('detail-header', 'Bamboo Watch', 'P-1001 · Accessories', [ + button('detail-edit', 'Edit', { severity: 'primary', icon: 'pi pi-pencil' }), + button('detail-archive', 'Archive', { severity: 'secondary', icon: 'pi pi-inbox' }), + ]), + ], + [TemplateSlotName.Body]: [ + widget('detail-description', 'Description', [ + text('detail-description-text', 'A bamboo-cased watch with a sapphire face and a recycled steel strap.'), + ]), + widget('detail-history', 'Price history', [externalComponent('detail-chart', 'chart', { type: 'bar' })]), + ], + [TemplateSlotName.SidePanel]: [ + widget('detail-summary', 'Summary', [ + text('detail-stock', 'In stock: 24'), + text('detail-reserved', 'Reserved: 3'), + externalComponent('detail-status', 'tag', { value: 'Active', severity: 'success' }), + ]), + ], + }, + displayName: 'Detail', + description: 'One record: a header with actions, its sections, and a summary panel.', +}; + +/** The form: fields, grouped, with the actions that close them. */ +export const formPageTemplate: ScreenTemplate = { + name: 'FormPage', + fitsSlot: SlotName.Content, + slots: [{ name: TemplateSlotName.Header }, { name: TemplateSlotName.Body }, { name: TemplateSlotName.Actions }], + content: { + [TemplateSlotName.Header]: [pageHeader('form-header', 'New product', 'Everything marked with an asterisk is required')], + [TemplateSlotName.Body]: [ + card('form-card', [ + field('form-name', 'Name', 'inputText', { placeholder: 'Bamboo Watch' }), + field('form-code', 'Code', 'inputText', { placeholder: 'P-1001' }), + field('form-category', 'Category', 'dropdown', { options: ['Accessories', 'Clothing', 'Fitness'] }), + field('form-price', 'Price', 'inputNumber', { mode: 'currency', currency: 'USD' }), + field('form-available', 'Available from', 'calendar', {}), + field('form-notes', 'Notes', 'inputTextarea', { rows: 4 }), + ]), + ], + [TemplateSlotName.Actions]: [formActions('form-actions', 'Create product')], + }, + displayName: 'Form', + description: 'A grouped form with the field types an application actually uses.', +}; + +/** The empty state: what a list looks like before anything exists, designed rather than apologized for. */ +export const emptyTemplate: ScreenTemplate = { + name: 'Empty', + fitsSlot: SlotName.Content, + slots: [{ name: TemplateSlotName.Body }], + content: { + [TemplateSlotName.Body]: [ + emptyState('empty-state', 'No products yet', 'Products you create will show up here, with their stock and pricing.', 'Create the first product'), + ], + }, + displayName: 'Empty state', + description: 'The designed empty state for a list that has nothing in it yet.', +}; + +/** The five workspace templates. */ +export const workspaceTemplates: ScreenTemplate[] = [dashboardTemplate, crudListTemplate, detailViewTemplate, formPageTemplate, emptyTemplate]; diff --git a/Source/JavaScript/layout.default/index.ts b/Source/JavaScript/layout.default/index.ts new file mode 100644 index 0000000..7b06826 --- /dev/null +++ b/Source/JavaScript/layout.default/index.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. + +export * from './packageName'; +export * from './ComponentName'; +export * from './configuration'; +export * from './layouts'; +export * from './gallery'; +export * from './shell'; +export * from './themes'; +export * from './defaultBlueprintComponents'; +export * from './defaultBlueprint'; diff --git a/Source/JavaScript/layout.default/layouts/LayoutName.ts b/Source/JavaScript/layout.default/layouts/LayoutName.ts new file mode 100644 index 0000000..0a93056 --- /dev/null +++ b/Source/JavaScript/layout.default/layouts/LayoutName.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * The application shells this package provides. + * + * Only true application-level shells are layouts. Everything else this package ships - dashboards, CRUD + * lists, sign-in screens - is a page or dialog template that *fits into* one of these, not a layout of its + * own. Keeping the distinction in the type means the manifest's `layouts` list cannot quietly grow into a + * catalog of pages. + */ +export enum LayoutName { + /** Topbar, sidebar, breadcrumb, content, footer and an optional right panel - the shell an application signs in to. */ + AppShell = 'AppShell', + + /** No chrome at all: content, an optional branding aside, and the configurator. */ + FullPage = 'FullPage', +} diff --git a/Source/JavaScript/layout.default/layouts/SlotName.ts b/Source/JavaScript/layout.default/layouts/SlotName.ts new file mode 100644 index 0000000..68c502a --- /dev/null +++ b/Source/JavaScript/layout.default/layouts/SlotName.ts @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * The regions this package's layouts expose. + * + * These are the names a {@link Layout}'s {@link Slot}s carry, the keys a page template's content is + * filed under, and the keys the shell components read out of their `slots` prop - one enum for all three, + * because a slot filled under a name the shell never reads renders nothing at all and reports nothing + * either. That silent failure is exactly what a shared vocabulary prevents. + * + * The set follows PrimeTek's template line: Sakai establishes topbar/sidebar/menu/content/footer, and the + * premium templates (Diamond, Atlantis, Freya, Apollo, Ultima, Avalon, Verona) add breadcrumb and a right + * panel. Both are exposed here, because a layout package that only covers the free template's regions + * forces anyone wanting the others to fork it. + */ +export enum SlotName { + /** The fixed strip across the top: brand, the sidebar toggle, and per-screen actions. */ + Topbar = 'topbar', + + /** The sidebar's own chrome - its header, brand and pin button. */ + Sidebar = 'sidebar', + + /** The navigation itself, so a screen can replace the menu without replacing the sidebar around it. */ + Menu = 'menu', + + /** The trail above the content. */ + Breadcrumb = 'breadcrumb', + + /** The screen itself. The only slot every layout declares. */ + Content = 'content', + + /** The strip below the content. */ + Footer = 'footer', + + /** The optional inspector panel down the right-hand edge. */ + RightPanel = 'rightPanel', + + /** The floating configurator. Present in both shells, because a full-page screen still has to be themeable. */ + ConfigPanel = 'configPanel', + + /** The branding half of a full-page screen's split - the panel a login form sits beside. */ + Aside = 'aside', +} diff --git a/Source/JavaScript/layout.default/layouts/appShell.ts b/Source/JavaScript/layout.default/layouts/appShell.ts new file mode 100644 index 0000000..d137bf0 --- /dev/null +++ b/Source/JavaScript/layout.default/layouts/appShell.ts @@ -0,0 +1,106 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { FlowArrangement, HeightSizeClass, Layout, WidthSizeClass } from '@cratis/scene.model'; +import { LayoutName } from './LayoutName'; +import { SlotName } from './SlotName'; +import { column, row, slotLeaf } from './flowBuilders'; + +/** + * The tree at a regular width and height: a topbar across the top, the sidebar column beside the main + * column, an optional right panel down the edge, and the configurator floating over all of it. + */ +export const appShellRegularRoot = column([ + slotLeaf(SlotName.Topbar), + row( + [ + column([slotLeaf(SlotName.Sidebar), slotLeaf(SlotName.Menu, { grow: 1 })]), + column([slotLeaf(SlotName.Breadcrumb), slotLeaf(SlotName.Content, { grow: 1 }), slotLeaf(SlotName.Footer)], 0, { grow: 1 }), + slotLeaf(SlotName.RightPanel), + ], + 0, + { grow: 1 }, + ), + slotLeaf(SlotName.ConfigPanel), +]); + +/** + * The tree at a compact width: the sidebar and the right panel leave the flow entirely. + * + * They leave rather than shrink because there is no width at which an 18rem panel and a 20rem panel both + * fit beside content on a phone. The sidebar is still rendered - off-canvas, over the content, behind the + * mask - but it no longer *occupies* anything, and that is a fact about the arrangement, not about CSS. + */ +export const appShellCompactWidthRoot = column([ + slotLeaf(SlotName.Topbar), + column([slotLeaf(SlotName.Breadcrumb), slotLeaf(SlotName.Content, { grow: 1 }), slotLeaf(SlotName.Footer)], 0, { grow: 1 }), + slotLeaf(SlotName.ConfigPanel), +]); + +/** + * The tree at a compact height: the breadcrumb and the footer go. + * + * A landscape phone has room across but almost none down, and two horizontal strips of chrome eat most of + * what is left. Dropping them is the height axis earning its place in the size-class matrix - width alone + * cannot express it. + */ +export const appShellCompactHeightRoot = column([ + slotLeaf(SlotName.Topbar), + row( + [column([slotLeaf(SlotName.Sidebar), slotLeaf(SlotName.Menu, { grow: 1 })]), slotLeaf(SlotName.Content, { grow: 1 }), slotLeaf(SlotName.RightPanel)], + 0, + { grow: 1 }, + ), + slotLeaf(SlotName.ConfigPanel), +]); + +/** + * The tree when both axes are compact - a phone in landscape: nothing but the topbar, the content and the + * configurator. + * + * This override exists to be *more specific* than the two single-axis ones. `evaluateFlowArrangement` + * scores an override by how many axes it targets, so without this one a landscape phone would pick + * whichever single-axis override was declared last and keep a footer it has no room for. + */ +export const appShellCompactRoot = column([ + slotLeaf(SlotName.Topbar), + slotLeaf(SlotName.Content, { grow: 1 }), + slotLeaf(SlotName.ConfigPanel), +]); + +/** The arrangement of the {@link appShellLayout}'s own slots, with one override per size-class combination that changes it. */ +export const appShellArrangement: FlowArrangement = { + root: appShellRegularRoot, + overrides: [ + { width: WidthSizeClass.Compact, root: appShellCompactWidthRoot }, + { height: HeightSizeClass.Compact, root: appShellCompactHeightRoot }, + { width: WidthSizeClass.Compact, height: HeightSizeClass.Compact, root: appShellCompactRoot }, + ], +}; + +/** + * The application shell layout. + * + * This is the *application-level* structure - the base navigational look an application picks once and + * every page then lives inside. The regions are the ones PrimeTek's template line settled on: Sakai + * establishes topbar, sidebar, menu, content and footer, and the premium templates add the breadcrumb and + * the right panel. Both sets are here, because a layout package covering only the free template's regions + * forces a fork on anyone who wants the others. + * + * The slots carry no arrangement of their own: how a screen's content is arranged *inside* a slot is that + * page template's business, and a layout that dictated it would stop being a shell. + */ +export const appShellLayout: Layout = { + name: LayoutName.AppShell, + slots: [ + { name: SlotName.Topbar }, + { name: SlotName.Sidebar }, + { name: SlotName.Menu }, + { name: SlotName.Breadcrumb }, + { name: SlotName.Content }, + { name: SlotName.Footer }, + { name: SlotName.RightPanel }, + { name: SlotName.ConfigPanel }, + ], + arrangement: appShellArrangement, +}; diff --git a/Source/JavaScript/layout.default/layouts/defaultLayouts.ts b/Source/JavaScript/layout.default/layouts/defaultLayouts.ts new file mode 100644 index 0000000..96bef8d --- /dev/null +++ b/Source/JavaScript/layout.default/layouts/defaultLayouts.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 { Layout } from '@cratis/scene.model'; +import { appShellLayout } from './appShell'; +import { fullPageLayout } from './fullPage'; + +/** + * The layouts this package provides. + * + * Two, and deliberately only two - every application shell worth having is one of these, and everything + * else this package ships is a page or dialog template that fits *into* one of them. The list is what the + * manifest's `layouts` names and what the bundle provides, which is what `validatePackageBundle` proves. + */ +export const defaultLayouts: Layout[] = [appShellLayout, fullPageLayout]; diff --git a/Source/JavaScript/layout.default/layouts/flowBuilders.ts b/Source/JavaScript/layout.default/layouts/flowBuilders.ts new file mode 100644 index 0000000..e554c81 --- /dev/null +++ b/Source/JavaScript/layout.default/layouts/flowBuilders.ts @@ -0,0 +1,49 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { FlowColumn, FlowGrid, FlowNode, FlowRow, FlowSlotLeaf } from '@cratis/scene.model'; + +/** + * Small constructors for the `flow` primitives a layout's macro arrangement is built from. + * + * They exist because the primitives are structural interfaces with required members (`gap` and + * `children` on every container), so writing a tree as object literals buries the shape under + * boilerplate - and a layout tree is a thing to be read, not decoded. These keep the declarations in + * `appShell.ts` and `fullPage.ts` down to the structure itself. + * + * Note that {@link FlowRow} and {@link FlowColumn} are structurally identical in `Scene.Model` - both are + * a bare {@link FlowContainer} - so a consumer cannot tell them apart from the value alone. Building them + * through named functions at least keeps the *intent* legible at the declaration site. + */ + +/** + * A leaf positioning one of the containing layout's or screen template's own named slots. + * + * Takes a plain string rather than one enum, because a layout arranges {@link SlotName}s while a screen + * template arranges its own vocabulary - and the same builder has to serve both. That every leaf names a + * slot its container actually declares is checked by a spec rather than by the type, since no type can + * express "one of whatever this particular container declared". + */ +export function slotLeaf(slotName: string, node: FlowNode = {}): FlowSlotLeaf { + return { ...node, slotName }; +} + +/** A container arranging its children horizontally. */ +export function row(children: FlowNode[], gap = 0, node: FlowNode = {}): FlowRow { + return { ...node, gap, children }; +} + +/** A container arranging its children vertically. */ +export function column(children: FlowNode[], gap = 0, node: FlowNode = {}): FlowColumn { + return { ...node, gap, children }; +} + +/** + * A container arranging its children in a grid. + * + * `columns` is always set rather than left to the renderer, because it is the only member that + * distinguishes a {@link FlowGrid} from a {@link FlowRow} or {@link FlowColumn} in the model as it stands. + */ +export function grid(children: FlowNode[], columns: number, gap = 0, node: FlowNode = {}): FlowGrid { + return { ...node, gap, children, columns }; +} diff --git a/Source/JavaScript/layout.default/layouts/fullPage.ts b/Source/JavaScript/layout.default/layouts/fullPage.ts new file mode 100644 index 0000000..5105ac8 --- /dev/null +++ b/Source/JavaScript/layout.default/layouts/fullPage.ts @@ -0,0 +1,48 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { FlowArrangement, Layout, WidthSizeClass } from '@cratis/scene.model'; +import { LayoutName } from './LayoutName'; +import { SlotName } from './SlotName'; +import { column, row, slotLeaf } from './flowBuilders'; + +/** + * The tree at a regular width: the branding aside beside the content, with the configurator over both. + * + * The split is the shape every premium PrimeTek sign-in page uses - a colored branding half and a form + * half - and it is the reason `aside` is a slot rather than something a screen paints inside `content`. + */ +export const fullPageRegularRoot = column([row([slotLeaf(SlotName.Aside), slotLeaf(SlotName.Content, { grow: 1 })], 0, { grow: 1 }), slotLeaf(SlotName.ConfigPanel)]); + +/** + * The tree at a compact width: the branding aside leaves the flow. + * + * On a phone the aside would take 40% of the width from the only thing that matters on a sign-in screen, + * which is the form. It drops out rather than stacking above it, because a sign-in form pushed below the + * fold by decoration is the worst possible first screen. + */ +export const fullPageCompactWidthRoot = column([slotLeaf(SlotName.Content, { grow: 1 }), slotLeaf(SlotName.ConfigPanel)]); + +/** The arrangement of the {@link fullPageLayout}'s own slots. */ +export const fullPageArrangement: FlowArrangement = { + root: fullPageRegularRoot, + overrides: [{ width: WidthSizeClass.Compact, root: fullPageCompactWidthRoot }], +}; + +/** + * The chrome-less layout. + * + * Sign-in, register, forgotten password, verification, lock, error, access-denied, not-found and landing + * screens all use this rather than a stripped-down application shell, and that split is structural in + * every PrimeTek template for a reason worth repeating: none of those screens has navigation state, a + * sidebar to remember or a breadcrumb to place, so hanging them off the application shell would mean every + * one of the eight modes needs an answer for a page with no menu. + * + * The configurator stays, because the sign-in page is very often the first page anyone sees and it still + * has to honor the chosen theme. + */ +export const fullPageLayout: Layout = { + name: LayoutName.FullPage, + slots: [{ name: SlotName.Aside }, { name: SlotName.Content }, { name: SlotName.ConfigPanel }], + arrangement: fullPageArrangement, +}; diff --git a/Source/JavaScript/layout.default/layouts/index.ts b/Source/JavaScript/layout.default/layouts/index.ts new file mode 100644 index 0000000..680c25c --- /dev/null +++ b/Source/JavaScript/layout.default/layouts/index.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. + +export * from './SlotName'; +export * from './LayoutName'; +export * from './flowBuilders'; +export * from './shellComponents'; +export * from './appShell'; +export * from './fullPage'; +export * from './defaultLayouts'; diff --git a/Source/JavaScript/layout.default/layouts/shellComponents.ts b/Source/JavaScript/layout.default/layouts/shellComponents.ts new file mode 100644 index 0000000..2b506bf --- /dev/null +++ b/Source/JavaScript/layout.default/layouts/shellComponents.ts @@ -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. + +import { ComponentName } from '../ComponentName'; +import { LayoutName } from './LayoutName'; + +/** + * Which component renders which layout. + * + * A {@link Layout} is data - named slots and an arrangement - and says nothing about how it is drawn. This + * is the one place that pairing lives, so a host holding a screen and its layout name can find the shell + * to render it in without hardcoding the answer, and a spec can prove every layout the blueprint declares + * actually has one. + */ +const shellComponents: Record = { + [LayoutName.AppShell]: ComponentName.AppShell, + [LayoutName.FullPage]: ComponentName.FullPageShell, +}; + +/** + * The component that renders a layout. + * + * @param layoutName The layout's name. + * @returns The bare component name, or `undefined` when the layout is not one of this blueprint's. + */ +export function shellComponentForLayout(layoutName: string): ComponentName | undefined { + return shellComponents[layoutName as LayoutName]; +} diff --git a/Source/JavaScript/layout.default/package.json b/Source/JavaScript/layout.default/package.json new file mode 100644 index 0000000..1569662 --- /dev/null +++ b/Source/JavaScript/layout.default/package.json @@ -0,0 +1,65 @@ +{ + "name": "@cratis/scene.layout.default", + "version": "1.0.0", + "description": "The default layout package: application shell layouts, their slot components, and a gallery of sample screens covering the page set a real application needs.", + "author": "Cratis", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/Cratis/Scene.git" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "**/*.ts", + "**/*.tsx" + ], + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/esm/index.d.ts", + "require": "./dist/cjs/index.js", + "import": "./dist/esm/index.js" + } + }, + "scripts": { + "prepare": "yarn g:build", + "clean": "yarn g:clean", + "build": "yarn g:build", + "lint": "yarn g:lint", + "lint:ci": "yarn g:lint:ci", + "test": "yarn g:test", + "ci": "yarn g:ci", + "up": "yarn g:up", + "dev": "storybook dev -p 6006", + "build-storybook": "storybook build" + }, + "dependencies": { + "@cratis/scene.engine": "1.0.0", + "@cratis/scene.model": "1.0.0", + "@cratis/scene.react": "1.0.0" + }, + "devDependencies": { + "@cratis/scene.engine": "1.0.0", + "@cratis/scene.model": "1.0.0", + "@cratis/scene.react": "1.0.0", + "@storybook/addon-links": "^10.4.1", + "@storybook/react": "^10.4.1", + "@storybook/react-vite": "^10.4.1", + "primeicons": "^7.0.0", + "primereact": "10.9.8", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "storybook": "^10.4.1" + }, + "peerDependencies": { + "primereact": "^10.9.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } +} diff --git a/Source/JavaScript/layout.default/packageName.ts b/Source/JavaScript/layout.default/packageName.ts new file mode 100644 index 0000000..675f92b --- /dev/null +++ b/Source/JavaScript/layout.default/packageName.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 name this blueprint is known by inside a `ui profile`. + * + * It is not the npm package name: a profile lists Scene package names, a theme declares compatibility + * with them, and `componentRegistryKey` builds every registry key from this. It therefore lives on its own + * rather than inside the manifest, so the registry can key off it without importing the manifest that + * describes the registry. + */ +export const defaultBlueprintName = 'Cratis.Blueprint.Default'; diff --git a/Source/JavaScript/layout.default/rollup.config.mjs b/Source/JavaScript/layout.default/rollup.config.mjs new file mode 100644 index 0000000..95b1f95 --- /dev/null +++ b/Source/JavaScript/layout.default/rollup.config.mjs @@ -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 { rollup } from '../../../rollup.config.mjs'; + +import pkg from './package.json' with { type: 'json' }; + +import path from "path"; + +const cjsPath = path.dirname(pkg.main); +const esmPath = path.dirname(pkg.module); +const tsconfigPath = path.join(import.meta.dirname, "tsconfig.json"); + +export default rollup(cjsPath, esmPath, tsconfigPath, pkg); diff --git a/Source/JavaScript/layout.default/shell/AppShell.tsx b/Source/JavaScript/layout.default/shell/AppShell.tsx new file mode 100644 index 0000000..62a9d8a --- /dev/null +++ b/Source/JavaScript/layout.default/shell/AppShell.tsx @@ -0,0 +1,66 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ReactNode } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { LayoutConfigProvider, isPointerRevealMode, useLayoutConfig, useOptionalLayoutConfig } from '../configuration'; +import { SlotName } from '../layouts'; +import { LayoutMask } from './Mask'; + +/** + * The application shell: the `AppShell` layout's slots, positioned. + * + * This owns the positioned boxes - the fixed topbar, the sliding sidebar panel, the pushed main column - + * while the components filling the slots own what goes inside them. Splitting it there is what lets a + * screen replace the entire menu without knowing anything about how `reveal` mode translates the panel it + * lives in. + * + * It puts a {@link LayoutConfigProvider} around itself when a host has not, because a gallery preview + * hands the renderer one `appShell` element and nothing else, and the shell still has to be able to + * switch modes. + */ +export function AppShell({ element, slots }: RegisteredComponentProps) { + const surface = ; + return useOptionalLayoutConfig() ? surface : {surface}; +} + +interface AppShellSurfaceProps { + elementId: string; + slots: Record; +} + +function AppShellSurface({ elementId, slots }: AppShellSurfaceProps) { + const { config, effectiveMode, wrapperClasses, setSidebarRevealed } = useLayoutConfig(); + const revealsOnPointer = isPointerRevealMode(effectiveMode); + const hasSidebar = filled(slots, SlotName.Sidebar) || filled(slots, SlotName.Menu); + + return ( +
+ {filled(slots, SlotName.Topbar) &&
{slots[SlotName.Topbar]}
} + + {hasSidebar && ( + + )} + +
+ {filled(slots, SlotName.Breadcrumb) &&
{slots[SlotName.Breadcrumb]}
} +
{slots[SlotName.Content]}
+ {filled(slots, SlotName.Footer) &&
{slots[SlotName.Footer]}
} +
+ + {filled(slots, SlotName.RightPanel) && } + {slots[SlotName.ConfigPanel]} + +
+ ); +} + +function filled(slots: Record, name: SlotName): boolean { + return (slots[name]?.length ?? 0) > 0; +} diff --git a/Source/JavaScript/layout.default/shell/Breadcrumb.tsx b/Source/JavaScript/layout.default/shell/Breadcrumb.tsx new file mode 100644 index 0000000..520ee77 --- /dev/null +++ b/Source/JavaScript/layout.default/shell/Breadcrumb.tsx @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { BreadCrumb } from 'primereact/breadcrumb'; +import { MenuItem as PrimeMenuItem } from 'primereact/menuitem'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { readOptionalString, readRecords, recordString } from './elementProperties'; + +/** + * The trail above the content. + * + * PrimeReact's own `BreadCrumb` does the rendering rather than hand-written markup, because a breadcrumb + * is an ordinary component with no layout-mode behavior at all - it looks the same in every one of the + * eight modes. The shell only writes CSS where PrimeReact 10 genuinely has no answer, and this is not one + * of those places. + */ +export function Breadcrumb({ element }: RegisteredComponentProps) { + const home = readOptionalString(element, 'homeTargetScreen'); + const model = readRecords(element, 'items').map( + (item): PrimeMenuItem => ({ + label: recordString(item, 'label'), + url: recordString(item, 'targetScreen') ? `#/${recordString(item, 'targetScreen')}` : undefined, + }), + ); + + return ( +
+ +
+ ); +} diff --git a/Source/JavaScript/layout.default/shell/ConfigPanel.tsx b/Source/JavaScript/layout.default/shell/ConfigPanel.tsx new file mode 100644 index 0000000..2991454 --- /dev/null +++ b/Source/JavaScript/layout.default/shell/ConfigPanel.tsx @@ -0,0 +1,105 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useState } from 'react'; +import { Button } from 'primereact/button'; +import { Sidebar } from 'primereact/sidebar'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { ColorScheme, MenuTheme, colorSchemes, menuThemes, useLayoutConfig } from '../configuration'; +import { LayoutModeSwitcher } from './LayoutModeSwitcher'; +import { ThemeSwitcher } from './ThemeSwitcher'; +import { readBoolean, readString } from './elementProperties'; + +/** What each color scheme is called in the configurator. */ +const colorSchemeLabels: Record = { + [ColorScheme.Light]: 'Light', + [ColorScheme.Dark]: 'Dark', +}; + +/** What each menu theme is called in the configurator. */ +const menuThemeLabels: Record = { + [MenuTheme.Light]: 'Light', + [MenuTheme.Dark]: 'Dark', + [MenuTheme.Primary]: 'Primary', +}; + +/** + * The floating configurator. + * + * The axes are the ones the modern PrimeTek templates expose - color scheme, menu mode, menu theme and + * theme - because those are the four choices that actually change how an application feels, and a + * configurator with more knobs than that becomes a settings screen nobody finishes reading. + * + * The panel itself is PrimeReact 10's `Sidebar`, which is an overlay drawer rather than an app-shell + * sidebar - this is the one use it was designed for. (v11 renames it `Drawer` precisely to stop the + * confusion, and gives the app-shell role to a new `Sidebar`.) + */ +export function ConfigPanel({ element, slots }: RegisteredComponentProps) { + const [isVisible, setIsVisible] = useState(false); + const { config, setColorScheme, setMenuTheme } = useLayoutConfig(); + const title = readString(element, 'title', 'Settings'); + const showsMenuTheme = readBoolean(element, 'showMenuTheme', true); + const showsMode = readBoolean(element, 'showLayoutMode', true); + + return ( + <> + + ))} + + + + + + {showsMode && ( + + )} + + {showsMenuTheme && ( +
+

Menu theme

+
+ {menuThemes.map(menuTheme => ( + + ))} +
+
+ )} + + {slots.content} + + + + ); +} diff --git a/Source/JavaScript/layout.default/shell/Footer.tsx b/Source/JavaScript/layout.default/shell/Footer.tsx new file mode 100644 index 0000000..4d66c7f --- /dev/null +++ b/Source/JavaScript/layout.default/shell/Footer.tsx @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { readString } from './elementProperties'; + +/** + * The strip below the content. + * + * Kept as its own slot rather than folded into the content because it is application chrome, not part of + * a screen: the same footer is on every page, and a screen that had to render it would have to remember + * to. + */ +export function Footer({ element, slots }: RegisteredComponentProps) { + const text = readString(element, 'text'); + + return ( + <> + {text} + {slots.content} + + ); +} diff --git a/Source/JavaScript/layout.default/shell/FullPageShell.tsx b/Source/JavaScript/layout.default/shell/FullPageShell.tsx new file mode 100644 index 0000000..7f08da0 --- /dev/null +++ b/Source/JavaScript/layout.default/shell/FullPageShell.tsx @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ReactNode } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { LayoutConfigProvider, useOptionalLayoutConfig } from '../configuration'; +import { SlotName } from '../layouts'; + +/** + * The chrome-less shell: content, an optional branding aside, and the configurator. + * + * Login, register, password reset, verification, lock, error, access-denied, not-found and landing screens + * all render here rather than in a stripped-down application shell. That split is structural in every + * PrimeTek template, and for a good reason: those screens have no navigation state to hold, no sidebar to + * remember, and no breadcrumb to place, so hanging them off the application shell means every one of the + * eight modes has to have an answer for a page that has no menu. + * + * The configurator stays, because a sign-in page still has to honor the chosen theme - it is very often + * the first page anyone sees. + */ +export function FullPageShell({ element, slots }: RegisteredComponentProps) { + const surface = ; + return useOptionalLayoutConfig() ? surface : {surface}; +} + +interface FullPageSurfaceProps { + elementId: string; + slots: Record; +} + +function FullPageSurface({ elementId, slots }: FullPageSurfaceProps) { + const hasAside = (slots[SlotName.Aside]?.length ?? 0) > 0; + + return ( +
+ {hasAside && } +
{slots[SlotName.Content]}
+ {slots[SlotName.ConfigPanel]} +
+ ); +} diff --git a/Source/JavaScript/layout.default/shell/LayoutModeSwitcher.tsx b/Source/JavaScript/layout.default/shell/LayoutModeSwitcher.tsx new file mode 100644 index 0000000..9aade26 --- /dev/null +++ b/Source/JavaScript/layout.default/shell/LayoutModeSwitcher.tsx @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { LayoutMode, layoutModes, useLayoutConfig } from '../configuration'; +import { readString } from './elementProperties'; + +/** What each mode is called in the configurator - the same names the PrimeTek template line uses. */ +const modeLabels: Record = { + [LayoutMode.Static]: 'Static', + [LayoutMode.Overlay]: 'Overlay', + [LayoutMode.Slim]: 'Slim', + [LayoutMode.SlimPlus]: 'Slim+', + [LayoutMode.Compact]: 'Compact', + [LayoutMode.Horizontal]: 'Horizontal', + [LayoutMode.Reveal]: 'Reveal', + [LayoutMode.Drawer]: 'Drawer', +}; + +/** + * Switches between the layout modes. + * + * Below the mobile breakpoint every button is disabled and the panel says why, rather than the picker + * disappearing. A control that vanishes reads as a bug; a disabled control with a sentence next to it + * reads as a decision - and the choice is still recorded and still there when the window grows again. + */ +export function LayoutModeSwitcher({ element }: RegisteredComponentProps) { + const { config, setMode } = useLayoutConfig(); + const label = readString(element, 'label', 'Menu mode'); + + return ( +
+

{label}

+
+ {layoutModes.map(mode => ( + + ))} +
+ {config.isMobile &&

Below 991px every mode renders off-canvas, so the choice is kept but not applied.

} +
+ ); +} diff --git a/Source/JavaScript/layout.default/shell/Logo.tsx b/Source/JavaScript/layout.default/shell/Logo.tsx new file mode 100644 index 0000000..8f3913e --- /dev/null +++ b/Source/JavaScript/layout.default/shell/Logo.tsx @@ -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. + +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { useSceneTheme } from '@cratis/scene.react'; +import { readOptionalString, readString } from './elementProperties'; + +/** + * The brand mark. + * + * This is the one place in the shell that reads the active theme directly rather than through a token, + * because picking between a light-background and a dark-background logo asset is a decision no CSS custom + * property can express - the two are different files. `useSceneTheme` exists for exactly this case. + */ +export function Logo({ element }: RegisteredComponentProps) { + const theme = useSceneTheme(); + const label = readString(element, 'label', 'Cratis'); + const initials = readString(element, 'initials', label.slice(0, 1).toUpperCase()); + const lightSource = readOptionalString(element, 'lightImageUrl'); + const darkSource = readOptionalString(element, 'darkImageUrl'); + const source = theme?.isDark ? darkSource ?? lightSource : lightSource ?? darkSource; + const targetScreen = readString(element, 'targetScreen', 'Dashboard'); + + return ( + + {source ? {label} : {initials}} + {label} + + ); +} diff --git a/Source/JavaScript/layout.default/shell/Mask.tsx b/Source/JavaScript/layout.default/shell/Mask.tsx new file mode 100644 index 0000000..73bb2d5 --- /dev/null +++ b/Source/JavaScript/layout.default/shell/Mask.tsx @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useLayoutConfig } from '../configuration'; + +/** + * The scrim behind a floating sidebar. + * + * It is a `button` rather than a `div` because its only job is to close the sidebar, and a click target + * that is not focusable or reachable from the keyboard traps anyone not using a mouse behind an open + * overlay with no way out. It renders nothing at all when no sidebar is floating, so it never sits + * invisibly over the page swallowing clicks. + */ +export function LayoutMask() { + const { isMaskVisible, setSidebarOpen } = useLayoutConfig(); + if (!isMaskVisible) { + return undefined; + } + + return