@net-advantage/jo-ui-shell
v2.1.0
Published
Application shell for JourneyOf. Toolbar nodes, groups, and destinations come from one version-1 navigation document. The document can be defined locally or returned by a server.
Readme
@net-advantage/jo-ui-shell
Application shell for JourneyOf. Toolbar nodes, groups, and destinations come from one version-1 navigation document. The document can be defined locally or returned by a server.
The words for this package are defined in LANGUAGE.md. Use those spoken names in issues and discussion. When a feature adds, renames, or retires a shell concept, update that glossary in the same change.
The shell follows a strict MVVM split:
- Model functions are pure. They parse, normalize, index, and resolve the document.
- The view-model hook owns selection, fallback, icon resolution, and the active destination page.
- The view renders the view-model. It does not decide which item is active.
MVVM
flowchart LR
Document["ShellNavigationDocument<br/>local object or server JSON"] --> Model["shell.model.ts<br/>parse, normalize, index"]
Props["ShellProps<br/>controlled ids and callbacks"] --> Factory["createShellViewModel"]
Model --> Factory
Factory --> Observable["Observable ShellViewModel"]
Observable --> Hook["useShellViewModel"]
Hook --> View["Shell.tsx<br/>toolbar, groups, items"]
View -->|"onToolbarSelect / onNavSelect"| Factory
Factory -->|"onActiveNavItemChange"| Host["Host application"]| Layer | File | Responsibility |
| --- | --- | --- |
| Contract | shell.types.ts | Document, slice, selection, page, and view-model types |
| Model | shell.model.ts | Parse JSON, enforce hierarchy, index ids, build breadcrumbs, project toolbar/nav/header data, normalize navigation panel visibility, and map content padding onto shell root custom properties |
| View-model | shell.view-model.ts | Framework-free createShellViewModel. Selection, fallbacks, callbacks, chrome text, and page status live on an Observable |
| Hook | shell.use-view-model.ts | useShellViewModel subscribes to that observable and owns ids, refs, effects, focus trap, and the page lifecycle, including dispose on unmount, shallow page params, and the duplicate destinationId guard |
| View | Shell.tsx | Render the toolbar, the logo, the navigation panel when it is visible, header, content, and footer. It resolves iconKey and host icons into nodes. Below 1024px it renders the mobile top bar, drawer, and bottom bar. |
| Page | shell-page.view-model.ts | ShellPageViewModel chrome and status. updateParams stores params on the view-model and does not mutate the readonly context. samePageParams shallow-compares page params. activate?(): void \| Promise<void> is optional. defineShellPage in shell-page.tsx pairs the class with view slots |
| View-model | shell-missing-page.view-model.ts | Missing-page trail and return-home action |
| View | ShellMissingPage.tsx | Render the missing-destination page |
Models
flowchart TD
Props["ShellProps"] --> Build["buildShellModel"]
Document["ShellNavigationDocument"] --> Build
Build --> ShellModel["ShellModel<br/>index, slices, presentation, actions"]
ShellModel --> Toolbar["ShellToolbarItemViewModel[]"]
ShellModel --> Groups["ShellNavGroupViewModel[]"]
Props --> Header["ShellHeaderActionViewModel[]"]
Props --> Padding["contentPaddingStyle<br/>block and inline lengths"]
Toolbar --> ViewModel["ShellViewModel"]
Groups --> ViewModel
Header --> ViewModel
Padding --> ViewModel
MissingProps["ShellMissingPageProps"] --> MissingVm["ShellMissingPageViewModel"]The view resolves icons in this order: an explicit icon, then a presentation icon from slices/toolbarItems, then resolveIcon(iconKey), then the default section icon. The view-model carries iconKey, not a rendered node.
Navigation models
flowchart TD
Document["ShellNavigationDocument<br/>version: 1"] --> Toolbar["ShellNavigationNode<br/>kind: toolbar"]
Toolbar --> Group["ShellNavigationNode<br/>kind: group"]
Group --> Item["ShellNavigationNode<br/>kind: item"]
Toolbar --> Slice["ShellSlice"]
Group --> NavGroup["ShellNavGroup"]
Item --> NavItem["ShellNavItem"]Rules (this version supports exactly three levels: toolbar > navigation group > destination):
- A toolbar node is a root. Its children must be groups.
- A group is a heading in the navigation panel, not a link. Its children must be items. Groups do not nest.
- An item is a destination and a leaf. It cannot contain children.
indexNavigationthrows when a node breaks these rules, naming the parent and the offending child.- Every id is unique and non-empty.
- Every node has an integer
orderBy. Siblings are sorted byorderBy, then by id. Array position is not the order. placementis only meaningful on toolbar nodes:top,primary, orbottom.
placement only chooses where a toolbar node sits. A bottom node with children is a section and navigates like any other toolbar item. A toolbar node with no children is an action: it reports its id and does not change the destination. Legacy slices, toolbarItems, and bottomToolbarItems can supply icons and order, but they do not remove sections defined by the document.
Server payload
{
"version": 1,
"nodes": [
{
"id": "settings",
"kind": "toolbar",
"label": "Settings",
"orderBy": 10,
"placement": "primary",
"iconKey": "settings",
"children": [
{
"id": "people",
"kind": "group",
"label": "People",
"orderBy": 10,
"children": [
{ "id": "users", "kind": "item", "label": "Users", "orderBy": 10 }
]
},
{
"id": "access",
"kind": "group",
"label": "Access",
"orderBy": 20,
"children": [
{ "id": "roles", "kind": "item", "label": "Roles", "orderBy": 10 }
]
}
]
}
]
}const navigation = parseShellNavigationDocument(payload);
<Shell
navigation={navigation}
resolveIcon={(iconKey) => icons[iconKey ?? '']}
onActiveNavItemChange={setNavId}
/>JSON cannot carry React nodes. Use iconKey in the document and resolveIcon in the host. Existing React icons passed through slices or toolbar overrides are preserved by the view.
Selection
- Omit
activeNavItemIdfor uncontrolled selection. Pass it to control selection from the host. - The active section is derived from the active destination. The host does not track a section id.
- The shell does not own the browser address. The host reads the destination from the path and writes it when selection changes.
browserShellAddress(target, slices)writes withhistory.pushState, so Back and Forward restore the destination. - A selected destination is written as
/{sectionId}/{destinationId}. Home destinations are written as/{destinationId}. The short paths are read aliases:/selects Home's first destination, and/{sectionId}selects that section's first destination. An unknown path reads as no destination. The host can then passmissingPath. - Changing a toolbar section selects that section's first enabled destination.
- Home is a toolbar node in the navigation document. It is selected the same way as any other section.
- Disabled toolbar sections and destinations do not change selection.
viewContext.breadcrumbsis toolbar node > navigation group > destination. The last crumb is current. Headerbreadcrumbsitems accepthreforonClickand render as links or buttons. The current item is text, not a link.
Layout
- Left toolbar: primary sections, bottom sections, and the user control. Bottom sections use
placement: "bottom"and navigate the same way as primary sections. - Logo: the wordmark in the header row, above the navigation panel. Omit
logoand the shell renders@net-advantage/jo-ui-logo. Passlogoto replace that wordmark. Inter and Caveat come from@net-advantage/jo-ui-theme/style.css. The logo stays visible when the navigation panel is hidden. - Navigation panel: the navigation groups of the active section, each a heading over its destinations. It sits under the logo.
navPanelVisibledefaults totrue, which keeps today's layout. Set it tofalseto omit the navigation panel only. The panel is not rendered and does not keep its column: the content area expands into the space under the logo, beside the toolbar. The logo and header stay on the top row.- Main area: shell header and the host outlet.
- Main content padding uses
--jo-ui-shell-content-padding-blockand--jo-ui-shell-content-padding-inline. See Content padding. - The host passes one
pagefromdefineShellPage, plus optionalpageParams. Define that page at module scope. The shell keeps one view-model perdestinationId. It constructs that view-model when the active destination matches, callsactivateonce, callsupdateParamswhenpageParamschange by a shallow compare, and callsdisposewhen navigation leaves that destination or the shell unmounts.activatemust catch its own errors and callsetStatus({ kind: 'error', message }). The shell does not catch them. A new params object with the same keys and values does not callupdateParams. A shell-context change does not construct another view-model. A page whosedestinationIddoes not match the active destination is not mounted. Outside production, and outside Vite hot reload, a differentcreateViewModelfor adestinationIdthat already has a live view-model throws. A new definition object with the samecreateViewModeldoes not. Production replaces that view-model when the factory differs. - When the page is mounted and ready,
view.contentis the outlet.childrenis used only when no page matches. Omit both and the shell rendersShellMissingPage. PassmissingPathto name the address that did not match. - When the page is mounted, its chrome title wins over the shell
titleprop, and its header-action data wins over the shellheaderActionsprop. A view slot (headerActions,subtitle,footer,loading,error) wins over the matching chrome data. Shelltitle,breadcrumbs, andheaderActionsapply when no page is mounted. Disabled header actions do not report. - Page header actions carry
iconKey. ShellheaderActionsmay still pass a Reacticon. The header action row keeps the classjo-ui-shell__header-actions, including when a view slot supplies the actions. That class is a public test hook. - Header actions use
variant:icon(the default),primary,secondary, orghost.primary,secondary, andghostshow the label.badgeis an optional mark. Setpressedto renderaria-pressedand the token-based on state.titleis an optional tooltip. The desktop and mobile buttons describe it witharia-describedbyand show it on hover and keyboard focus. A blank title is ignored. - Status
loadingrenders<Shadow variant="form" />unlessview.loadingis set. The label defaults toLoading. Statuserrorrenders the message and a jo-uiButton(variant="primary") labelled Retry whenretryis set, unlessview.erroris set. - Below 720px the header row grows and labelled actions wrap onto the next line instead of overflowing.
- Footer spans the navigation and content columns. Hiding the navigation panel does not change that span; the content grows to meet it. A mounted page
view.footerslot wins. Omit it and the shell renders thefooterprop. theme="dark"switches the shell palette.
Content padding
The main content region, .jo-ui-shell__main-content, reads two custom properties. Each property falls back to today's padding when it is not set, so the shell looks the same until a page opts in.
| Token | Above 1100px | 1100px and below |
| --- | --- | --- |
| --jo-ui-shell-content-padding-block | 1.25rem | 1rem |
| --jo-ui-shell-content-padding-inline | 1.75rem | 1.25rem |
The mobile shell (1023px and below) has no separate content padding. It uses the 1100px fallbacks: 1rem block and 1.25rem inline.
Set either variable on the shell root or on an ancestor. A defined variable replaces the fallback at every width. Set different values per breakpoint with your own media queries on that ancestor.
contentPadding writes the same variables as inline style on the shell root. A blank or omitted length leaves that axis on the fallback. When both contentPadding and rootProps.style set a variable, contentPadding wins for the axes it sets.
<Shell contentPadding={{ block: '12px' }} /><Shell
rootProps={{
style: {
['--jo-ui-shell-content-padding-block']: '12px',
},
}}
/>Journey Details uses 12px block padding so the content gap matches the Sarah mock. Inline padding stays on the breakpoint default unless the page sets it.
Structure
- src: view, model, view-model, types, and styles
- stories: Storybook docs and the navigation document example
- LANGUAGE.md: ubiquitous language for shell regions, nodes, selection, and MVVM
Publish footprint
This package publishes only:
- dist
- README.md
- LANGUAGE.md
