@cratis/scene.blueprint.default 4.27.0 → 4.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/scene.blueprint.default",
3
- "version": "4.27.0",
3
+ "version": "4.29.0",
4
4
  "description": "The default blueprint: application shell layouts, the screen and dialog templates that go inside them, the components that fill their slots, and two themes.",
5
5
  "keywords": [
6
6
  "cratis",
@@ -54,15 +54,15 @@
54
54
  "build-storybook": "storybook build"
55
55
  },
56
56
  "dependencies": {
57
- "@cratis/scene.engine": "4.27.0",
58
- "@cratis/scene.model": "4.27.0",
59
- "@cratis/scene.react": "4.27.0"
57
+ "@cratis/scene.engine": "4.29.0",
58
+ "@cratis/scene.model": "4.29.0",
59
+ "@cratis/scene.react": "4.29.0"
60
60
  },
61
61
  "devDependencies": {
62
- "@cratis/scene.engine": "4.27.0",
63
- "@cratis/scene.model": "4.27.0",
64
- "@cratis/scene.primereact": "4.27.0",
65
- "@cratis/scene.react": "4.27.0",
62
+ "@cratis/scene.engine": "4.29.0",
63
+ "@cratis/scene.model": "4.29.0",
64
+ "@cratis/scene.primereact": "4.29.0",
65
+ "@cratis/scene.react": "4.29.0",
66
66
  "@primereact/core": "11.2.0",
67
67
  "@primereact/headless": "11.2.0",
68
68
  "@primeuix/themes": "^3.0.1",
@@ -0,0 +1,99 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import type { Meta, StoryObj } from '@storybook/react';
5
+ import { expect, userEvent, within } from 'storybook/test';
6
+ import { PrimeReactProvider } from '@primereact/core';
7
+ import { ApplicationStructure, ExternalComponent, NavigationModel, NavigationRegionKind, NavigationRegionMode, SceneElement, Visibility } from '@cratis/scene.model';
8
+ import { ApplicationStructureIndex } from '@cratis/scene.engine';
9
+ import { CoreOutlet, RegisteredComponentProps, SceneNavigationHost } from '@cratis/scene.react';
10
+ import { LayoutConfigProvider } from '../configuration';
11
+ import { Sidebar } from './Sidebar';
12
+ import { Topbar } from './Topbar';
13
+
14
+ const structure = {
15
+ modules: [
16
+ { id: 'dashboard', name: 'Dashboard', features: [{ id: 'overview', name: 'Overview', features: [], slices: [] }] },
17
+ { id: 'sales', name: 'Sales', features: [{ id: 'orders', name: 'Orders', features: [{ id: 'returns', name: 'Returns', features: [], slices: [] }], slices: [] }, { id: 'customers', name: 'Customers', features: [], slices: [] }] },
18
+ { id: 'support', name: 'Support', features: [{ id: 'tickets', name: 'Tickets', features: [], slices: [] }, { id: 'kb', name: 'Knowledge base', features: [], slices: [] }] },
19
+ ],
20
+ } as unknown as ApplicationStructure;
21
+
22
+ const navigation: NavigationModel = {
23
+ application: { regions: [
24
+ { region: NavigationRegionKind.Topbar, items: [
25
+ { id: 'dashboard', title: 'Dashboard', target: { module: 'dashboard' } },
26
+ { id: 'sales', title: 'Sales', target: { module: 'sales' } },
27
+ { id: 'support', title: 'Support', target: { module: 'support' } },
28
+ ] },
29
+ { region: NavigationRegionKind.Sidebar, items: [{ id: 'dashboard', title: 'Dashboard', target: { module: 'dashboard', feature: 'overview' } }] },
30
+ ] },
31
+ modules: [
32
+ { module: 'sales', regions: [{ region: NavigationRegionKind.Sidebar, mode: NavigationRegionMode.Override, items: [
33
+ { id: 'orders', title: 'Orders', target: { module: 'sales', feature: 'orders' }, children: [{ id: 'returns', title: 'Returns', target: { module: 'sales', feature: 'orders.returns' } }] },
34
+ { id: 'customers', title: 'Customers', target: { module: 'sales', feature: 'customers' } },
35
+ ] }] },
36
+ { module: 'support', regions: [
37
+ { region: NavigationRegionKind.Topbar, mode: NavigationRegionMode.Extend, items: [{ id: 'help', title: 'Help center', target: { module: 'support', feature: 'kb' }, slot: 'side' }] },
38
+ { region: NavigationRegionKind.Sidebar, mode: NavigationRegionMode.Override, items: [
39
+ { id: 'tickets', title: 'Tickets', target: { module: 'support', feature: 'tickets' } },
40
+ { id: 'kb', title: 'Knowledge base', target: { module: 'support', feature: 'kb' }, slot: 'side' },
41
+ ] },
42
+ ] },
43
+ ],
44
+ };
45
+
46
+ const element = (id: string, componentName: string, properties: Record<string, unknown> = {}, content: SceneElement[] = []) =>
47
+ ({ id, componentName, properties, slots: content.length > 0 ? { content } : {}, visibility: Visibility.Visible }) as unknown as ExternalComponent;
48
+
49
+ function Page({ element: page, slots }: RegisteredComponentProps) {
50
+ return <section style={{ padding: '1rem' }}><h2>{page.id}</h2>{slots.content}</section>;
51
+ }
52
+
53
+ const screens: Record<string, SceneElement> = {
54
+ 'dashboard.overview': element('dashboard.overview', 'story:page'),
55
+ 'sales.orders': element('sales.orders', 'story:page'),
56
+ 'sales.orders.returns': element('sales.orders.returns', 'story:page'),
57
+ 'sales.customers': element('sales.customers', 'story:page'),
58
+ 'support.tickets': element('support.tickets', 'story:page', {}, [element('side', 'core:outlet', { name: 'side' })]),
59
+ 'support.kb': element('support.kb', 'story:page'),
60
+ };
61
+
62
+ function Application({ initialScreen }: { initialScreen: string }) {
63
+ return <PrimeReactProvider><LayoutConfigProvider>
64
+ <SceneNavigationHost screens={screens} initialScreen={initialScreen} registry={{ 'story:page': Page, 'core:outlet': CoreOutlet }}
65
+ navigationModel={{ structure: new ApplicationStructureIndex(structure), navigation }}
66
+ frame={content => <div className='layout-wrapper layout-static'>
67
+ <div className='layout-topbar'><Topbar element={element('topbar', 'topbar')} slots={{}} /></div>
68
+ <div className='layout-sidebar'><Sidebar element={element('sidebar', 'sidebar')} slots={{}} /></div>
69
+ <div className='layout-content-wrapper'>{content}</div>
70
+ </div>} />
71
+ </LayoutConfigProvider></PrimeReactProvider>;
72
+ }
73
+
74
+ /**
75
+ * The topbar and sidebar of the shell showing the effective navigation of the module on the page: the application items, a
76
+ * module's own, hierarchical sidebar items, and items that render into a second slot.
77
+ */
78
+ const meta = {
79
+ title: 'Blueprint/Navigation regions',
80
+ component: Application,
81
+ parameters: { layout: 'fullscreen' },
82
+ } satisfies Meta<typeof Application>;
83
+
84
+ export default meta;
85
+
86
+ type Story = StoryObj<typeof meta>;
87
+
88
+ /** A module whose sidebar replaces the application's and nests Returns under Orders. */
89
+ export const HierarchicalSidebar: Story = { args: { initialScreen: 'sales.orders' } };
90
+
91
+ /** Activating the Knowledge base item renders it into the side outlet while Tickets stays in the main one; both are active. */
92
+ export const TwoSlots: Story = {
93
+ args: { initialScreen: 'support.tickets' },
94
+ play: async ({ canvasElement }) => {
95
+ const canvas = within(canvasElement);
96
+ await userEvent.click(canvas.getByRole('link', { name: 'Knowledge base' }));
97
+ await expect(canvasElement.querySelectorAll('.layout-region-sidebar [data-active="true"]').length).toBe(2);
98
+ },
99
+ };
@@ -0,0 +1,31 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { NavigationRegionKind } from '@cratis/scene.model';
5
+ import { NavigationRegionClassNames, NavigationRegionView, useSceneNavigationModel } from '@cratis/scene.react';
6
+
7
+ /** The `layout-menu` vocabulary the shell's stylesheet already styles, so a region looks like the menu it replaces. */
8
+ const classNames: Partial<NavigationRegionClassNames> = {
9
+ root: 'layout-region',
10
+ list: 'layout-menu',
11
+ item: 'layout-menu-item',
12
+ link: 'layout-menu-link',
13
+ icon: 'layout-menu-icon',
14
+ label: 'layout-menu-label',
15
+ toggle: 'layout-menu-toggle',
16
+ submenu: 'layout-submenu',
17
+ };
18
+
19
+ /** Whether a navigation model is in scope, so the topbar and sidebar have a region to show. */
20
+ export function useHasRegionNavigation(): boolean {
21
+ return useSceneNavigationModel() !== undefined;
22
+ }
23
+
24
+ /**
25
+ * The navigation a shell's topbar or sidebar shows when the application supplies a navigation model: the effective items of
26
+ * its region - inherit/extend/override already applied - as the shell's menu, with the active item, the hierarchy and
27
+ * the keyboard behaviour of `NavigationRegionView`.
28
+ */
29
+ export function RegionNavigation({ region, label }: { region: NavigationRegionKind; label?: string }) {
30
+ return <NavigationRegionView region={region} label={label} classNames={{ ...classNames, root: `layout-region layout-region-${region}` }} />;
31
+ }
package/shell/Sidebar.tsx CHANGED
@@ -4,7 +4,9 @@
4
4
  import { Button } from 'primereact/button';
5
5
  import { RegisteredComponentProps } from '@cratis/scene.react';
6
6
  import { isPointerRevealMode, useLayoutConfig } from '../configuration';
7
- import { readString } from './elementProperties';
7
+ import { NavigationRegionKind } from '@cratis/scene.model';
8
+ import { readBoolean, readString } from './elementProperties';
9
+ import { RegionNavigation, useHasRegionNavigation } from './RegionNavigation';
8
10
 
9
11
  /**
10
12
  * The sidebar's own chrome, inside the panel {@link AppShell} positions.
@@ -14,11 +16,15 @@ import { readString } from './elementProperties';
14
16
  * the shell; everything about *what is in it* belongs to the screen. A template can replace this header
15
17
  * wholesale without any of the mode CSS noticing.
16
18
  *
19
+ * When the application supplies a navigation model and the sidebar has no `content` of its own, the content is the effective
20
+ * navigation of the sidebar region for the module on the page (set `regionNavigation` to false to opt out).
21
+ *
17
22
  * The pin button only appears in `reveal` and `drawer`, the two modes where the sidebar answers to the
18
23
  * pointer and therefore needs a way to be made to stay.
19
24
  */
20
25
  export function Sidebar({ element, slots }: RegisteredComponentProps) {
21
26
  const { effectiveMode, config, toggleSidebarAnchor } = useLayoutConfig();
27
+ const hasRegion = useHasRegionNavigation();
22
28
  const title = readString(element, 'title');
23
29
  const isPinnable = isPointerRevealMode(effectiveMode);
24
30
 
@@ -40,7 +46,7 @@ export function Sidebar({ element, slots }: RegisteredComponentProps) {
40
46
  </Button>
41
47
  )}
42
48
  </div>
43
- {slots.content}
49
+ {slots.content?.length ? slots.content : hasRegion && readBoolean(element, 'regionNavigation', true) ? <RegionNavigation region={NavigationRegionKind.Sidebar} label='Sidebar' /> : null}
44
50
  </>
45
51
  );
46
52
  }
package/shell/Topbar.tsx CHANGED
@@ -4,7 +4,9 @@
4
4
  import { Button } from 'primereact/button';
5
5
  import { RegisteredComponentProps } from '@cratis/scene.react';
6
6
  import { LayoutMode, useLayoutConfig } from '../configuration';
7
+ import { NavigationRegionKind } from '@cratis/scene.model';
7
8
  import { readBoolean } from './elementProperties';
9
+ import { RegionNavigation, useHasRegionNavigation } from './RegionNavigation';
8
10
 
9
11
  /**
10
12
  * The fixed strip across the top of the application shell.
@@ -13,12 +15,16 @@ import { readBoolean } from './elementProperties';
13
15
  * menu itself, which is why the menu arrives here as a slot rather than being hard-wired into the
14
16
  * sidebar - the same navigation markup has to be able to render in two places.
15
17
  *
18
+ * When the application supplies a navigation model and the topbar has no `menu` slot of its own, the menu is the effective
19
+ * navigation of the topbar region for the module on the page (set `regionNavigation` to false to opt out).
20
+ *
16
21
  * The toggle hides itself in the rail modes, where there is nothing to toggle: `slim`, `slim-plus` and
17
22
  * `compact` are always showing, `reveal` and `drawer` answer to the pointer and the pin, and `horizontal`
18
23
  * has no sidebar at all. A button that does nothing is worse than no button.
19
24
  */
20
25
  export function Topbar({ element, slots }: RegisteredComponentProps) {
21
26
  const { effectiveMode, config, toggleSidebar } = useLayoutConfig();
27
+ const hasRegion = useHasRegionNavigation();
22
28
  const showsToggle = readBoolean(element, 'showToggle', true) && togglesSidebar(effectiveMode);
23
29
 
24
30
  return (
@@ -39,7 +45,7 @@ export function Topbar({ element, slots }: RegisteredComponentProps) {
39
45
  {slots.logo}
40
46
  {slots.start}
41
47
  </div>
42
- {slots.menu}
48
+ {slots.menu?.length ? slots.menu : hasRegion && readBoolean(element, 'regionNavigation', true) ? <RegionNavigation region={NavigationRegionKind.Topbar} label='Modules' /> : null}
43
49
  <div className='layout-topbar-end'>{slots.end}</div>
44
50
  </>
45
51
  );
package/shell/layout.css CHANGED
@@ -825,3 +825,64 @@
825
825
  padding: 2rem;
826
826
  }
827
827
  }
828
+
829
+ /* ---------------------------------------------------------------------------
830
+ * Navigation regions - the effective topbar / sidebar items of a NavigationRegionView.
831
+ * The renderer carries no design chrome; state is on data attributes.
832
+ * ------------------------------------------------------------------------ */
833
+
834
+ .layout-region-topbar .layout-menu {
835
+ flex-direction: row;
836
+ flex-wrap: nowrap;
837
+ align-items: center;
838
+ gap: 0.25rem;
839
+ }
840
+
841
+ .layout-region-topbar .layout-menu-item[data-active='true'] > .layout-menu-link {
842
+ background: transparent;
843
+ color: inherit;
844
+ font-weight: 700;
845
+ border-radius: var(--layout-radius) var(--layout-radius) 0.5rem 0.5rem;
846
+ box-shadow: inset 0 -0.2rem 0 var(--layout-primary);
847
+ }
848
+
849
+ .layout-region-sidebar .layout-menu-item {
850
+ position: relative;
851
+ }
852
+
853
+ .layout-region-sidebar .layout-menu-item[data-active-descendant='true'] > .layout-menu-link {
854
+ font-weight: 600;
855
+ }
856
+
857
+ .layout-menu-toggle {
858
+ position: absolute;
859
+ inset-block-start: 0.35rem;
860
+ inset-inline-end: 0.5rem;
861
+ width: 1.75rem;
862
+ height: 1.75rem;
863
+ padding: 0;
864
+ color: inherit;
865
+ cursor: pointer;
866
+ background: transparent;
867
+ border: 0;
868
+ border-radius: var(--layout-radius);
869
+ }
870
+
871
+ .layout-menu-toggle::before {
872
+ content: '\25B8';
873
+ display: inline-block;
874
+ transition: transform var(--layout-transition);
875
+ }
876
+
877
+ .layout-menu-toggle[aria-expanded='true']::before {
878
+ transform: rotate(90deg);
879
+ }
880
+
881
+ .layout-menu-toggle:hover {
882
+ background: var(--layout-sidebar-hover);
883
+ }
884
+
885
+ .layout-menu-toggle:focus-visible {
886
+ outline: none;
887
+ box-shadow: var(--layout-focus-ring);
888
+ }