@plantops/ui 0.1.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.
Files changed (63) hide show
  1. package/README.md +74 -0
  2. package/dist/data/data-table.d.ts +41 -0
  3. package/dist/data/data-table.d.ts.map +1 -0
  4. package/dist/data/data-table.js +31 -0
  5. package/dist/data/scope-tree-select.d.ts +51 -0
  6. package/dist/data/scope-tree-select.d.ts.map +1 -0
  7. package/dist/data/scope-tree-select.js +59 -0
  8. package/dist/data/scope-tree.d.ts +127 -0
  9. package/dist/data/scope-tree.d.ts.map +1 -0
  10. package/dist/data/scope-tree.js +163 -0
  11. package/dist/data/status-tag.d.ts +14 -0
  12. package/dist/data/status-tag.d.ts.map +1 -0
  13. package/dist/data/status-tag.js +50 -0
  14. package/dist/feedback/error-copy.d.ts +38 -0
  15. package/dist/feedback/error-copy.d.ts.map +1 -0
  16. package/dist/feedback/error-copy.js +97 -0
  17. package/dist/feedback/page-header.d.ts +17 -0
  18. package/dist/feedback/page-header.d.ts.map +1 -0
  19. package/dist/feedback/page-header.js +23 -0
  20. package/dist/feedback/state-panels.d.ts +50 -0
  21. package/dist/feedback/state-panels.d.ts.map +1 -0
  22. package/dist/feedback/state-panels.js +46 -0
  23. package/dist/forms/auth-layout.d.ts +12 -0
  24. package/dist/forms/auth-layout.d.ts.map +1 -0
  25. package/dist/forms/auth-layout.js +28 -0
  26. package/dist/forms/credentials-form.d.ts +34 -0
  27. package/dist/forms/credentials-form.d.ts.map +1 -0
  28. package/dist/forms/credentials-form.js +37 -0
  29. package/dist/icons/icon-registry.d.ts +25 -0
  30. package/dist/icons/icon-registry.d.ts.map +1 -0
  31. package/dist/icons/icon-registry.js +102 -0
  32. package/dist/index.d.ts +52 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +51 -0
  35. package/dist/layout/app-shell.d.ts +23 -0
  36. package/dist/layout/app-shell.d.ts.map +1 -0
  37. package/dist/layout/app-shell.js +84 -0
  38. package/dist/layout/brand.d.ts +10 -0
  39. package/dist/layout/brand.d.ts.map +1 -0
  40. package/dist/layout/brand.js +48 -0
  41. package/dist/layout/nav-menu.d.ts +30 -0
  42. package/dist/layout/nav-menu.d.ts.map +1 -0
  43. package/dist/layout/nav-menu.js +75 -0
  44. package/dist/layout/nav-tree.d.ts +67 -0
  45. package/dist/layout/nav-tree.d.ts.map +1 -0
  46. package/dist/layout/nav-tree.js +101 -0
  47. package/dist/layout/user-menu.d.ts +20 -0
  48. package/dist/layout/user-menu.d.ts.map +1 -0
  49. package/dist/layout/user-menu.js +75 -0
  50. package/dist/theme/color-mode.d.ts +45 -0
  51. package/dist/theme/color-mode.d.ts.map +1 -0
  52. package/dist/theme/color-mode.js +81 -0
  53. package/dist/theme/theme-provider.d.ts +17 -0
  54. package/dist/theme/theme-provider.d.ts.map +1 -0
  55. package/dist/theme/theme-provider.js +65 -0
  56. package/dist/theme/theme.d.ts +23 -0
  57. package/dist/theme/theme.d.ts.map +1 -0
  58. package/dist/theme/theme.js +141 -0
  59. package/dist/theme/tokens.d.ts +130 -0
  60. package/dist/theme/tokens.d.ts.map +1 -0
  61. package/dist/theme/tokens.js +127 -0
  62. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  63. package/package.json +48 -0
package/dist/index.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `@plantops/ui` — the shared React presentation layer for every PlantOps
3
+ * console (Doc 08 §2).
4
+ *
5
+ * Ant Design 6 supplies the components; this library supplies the *product*:
6
+ * the design tokens, the theme built from them, the shell every console renders
7
+ * inside, the icon-key registry Doc 05 §7 requires, and the handful of patterns
8
+ * that must not be reinvented per screen — page headers, list tables bound to
9
+ * the pagination envelope, status tags, and the four not-showing-data states.
10
+ *
11
+ * ## The rule that keeps it reusable
12
+ *
13
+ * Nothing here calls the IAM. The library depends on `@plantops/contracts` for
14
+ * types and on nothing else in the workspace — enforced by the `scope:ui`
15
+ * boundary in the root ESLint config — so a component takes data and callbacks
16
+ * and returns markup. The stateful half (a client, tokens, grants, navigation
17
+ * fetching) lives in `@plantops/web-kit`, which depends on this.
18
+ *
19
+ * That split is what makes the gatepass and visitor consoles cheap: they mount
20
+ * the same `<PlantOpsThemeProvider>` and `<AppShell>`, render the same
21
+ * `<NavMenu>` from their own `/iam/navigation` response, and inherit the
22
+ * product's appearance without inheriting the IAM's screens.
23
+ *
24
+ * ```tsx
25
+ * <PlantOpsThemeProvider>
26
+ * <AppShell brand={<Brand product="Gatepass" />} nav={<NavMenu … />}>
27
+ * <PageHeader title="Passes" />
28
+ * <DataTable result={page} columns={columns} rowKey={(r) => r.id} />
29
+ * </AppShell>
30
+ * </PlantOpsThemeProvider>
31
+ * ```
32
+ */
33
+ export * from './data/data-table';
34
+ export * from './data/scope-tree';
35
+ export * from './data/scope-tree-select';
36
+ export * from './data/status-tag';
37
+ export * from './feedback/error-copy';
38
+ export * from './feedback/page-header';
39
+ export * from './feedback/state-panels';
40
+ export * from './forms/auth-layout';
41
+ export * from './forms/credentials-form';
42
+ export * from './icons/icon-registry';
43
+ export * from './layout/app-shell';
44
+ export * from './layout/brand';
45
+ export * from './layout/nav-menu';
46
+ export * from './layout/nav-tree';
47
+ export * from './layout/user-menu';
48
+ export * from './theme/color-mode';
49
+ export * from './theme/theme';
50
+ export * from './theme/theme-provider';
51
+ export * from './theme/tokens';
@@ -0,0 +1,23 @@
1
+ import * as React from 'react';
2
+ export interface AppShellProps {
3
+ /** Usually a `<Brand>`. Rendered at the top of the sidebar. */
4
+ brand?: React.ReactNode;
5
+ /** Usually a `<NavMenu>`. Receives the collapsed state via `renderNav`. */
6
+ nav?: React.ReactNode;
7
+ /** Use instead of `nav` when the menu needs to know it is collapsed. */
8
+ renderNav?: (state: {
9
+ collapsed: boolean;
10
+ }) => React.ReactNode;
11
+ /** Pinned to the bottom of the sidebar — a version string, a support link. */
12
+ navFooter?: React.ReactNode;
13
+ /** Left of the header, after the collapse toggle — breadcrumbs, a title. */
14
+ headerLeft?: React.ReactNode;
15
+ /** Right of the header — usually a `<UserMenu>`. */
16
+ headerRight?: React.ReactNode;
17
+ children: React.ReactNode;
18
+ /** Controlled collapse. Omit to let the shell manage it. */
19
+ collapsed?: boolean;
20
+ onCollapsedChange?: (collapsed: boolean) => void;
21
+ }
22
+ export declare function AppShell({ brand, nav, renderNav, navFooter, headerLeft, headerRight, children, collapsed, onCollapsedChange, }: AppShellProps): React.ReactElement;
23
+ //# sourceMappingURL=app-shell.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-shell.d.ts","sourceRoot":"","sources":["../../src/layout/app-shell.tsx"],"names":[],"mappings":"AAwBA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAM/B,MAAM,WAAW,aAAa;IAC5B,+DAA+D;IAC/D,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACxB,2EAA2E;IAC3E,GAAG,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACtB,wEAAwE;IACxE,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,KAAK,KAAK,CAAC,SAAS,CAAC;IAC/D,8EAA8E;IAC9E,SAAS,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC5B,4EAA4E;IAC5E,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC7B,oDAAoD;IACpD,WAAW,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC9B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,4DAA4D;IAC5D,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;CAClD;AAED,wBAAgB,QAAQ,CAAC,EACvB,KAAK,EACL,GAAG,EACH,SAAS,EACT,SAAS,EACT,UAAU,EACV,WAAW,EACX,QAAQ,EACR,SAAS,EACT,iBAAiB,GAClB,EAAE,aAAa,GAAG,KAAK,CAAC,YAAY,CA+GpC"}
@@ -0,0 +1,84 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * The console frame: sidebar, header, content.
5
+ *
6
+ * Every authenticated screen in every PlantOps console renders inside one of
7
+ * these. It owns the chrome — the collapse state, the fixed header, the
8
+ * scrolling content region — and nothing else: what goes in the sidebar is a
9
+ * `nav` node, what goes in the header's right side is `headerRight`. That keeps
10
+ * the frame free of any knowledge of navigation, permissions or identity, which
11
+ * is what lets the gatepass and visitor consoles reuse it without inheriting
12
+ * the IAM's ideas about menus.
13
+ *
14
+ * ## Layout mechanics worth knowing
15
+ *
16
+ * The sidebar is `position: fixed` and the content region carries a matching
17
+ * inline offset. The alternative — a flex row with an overflowing child — makes
18
+ * the *page* scroll, which detaches the header from the viewport and puts the
19
+ * table header out of reach on a long user list. Fixed chrome plus one
20
+ * scrolling region is the behaviour an admin console needs.
21
+ */
22
+ import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
23
+ import { Button, Grid, Layout } from 'antd';
24
+ import * as React from 'react';
25
+ import { layout as layoutTokens, navSurface, spacing } from '../theme/tokens';
26
+ const { Header, Sider, Content } = Layout;
27
+ export function AppShell({ brand, nav, renderNav, navFooter, headerLeft, headerRight, children, collapsed, onCollapsedChange, }) {
28
+ const screens = Grid.useBreakpoint();
29
+ const [selfCollapsed, setSelfCollapsed] = React.useState(false);
30
+ const isControlled = collapsed !== undefined;
31
+ const effectiveCollapsed = isControlled ? collapsed : selfCollapsed;
32
+ const setCollapsed = React.useCallback((next) => {
33
+ if (!isControlled)
34
+ setSelfCollapsed(next);
35
+ onCollapsedChange?.(next);
36
+ }, [isControlled, onCollapsedChange]);
37
+ // Below `lg` there is not enough width for a 248px rail and a usable table,
38
+ // so the sidebar collapses to icons. Deliberately not a drawer: an operator
39
+ // switching between Users and Access all morning should not have to open a
40
+ // panel each time.
41
+ const isNarrow = screens.lg === false;
42
+ React.useEffect(() => {
43
+ if (isNarrow)
44
+ setCollapsed(true);
45
+ }, [isNarrow, setCollapsed]);
46
+ const siderWidth = effectiveCollapsed
47
+ ? layoutTokens.sidebarCollapsedWidth
48
+ : layoutTokens.sidebarWidth;
49
+ return (_jsxs(Layout, { style: { minHeight: '100vh' }, children: [_jsxs(Sider, { collapsible: true, collapsed: effectiveCollapsed, onCollapse: setCollapsed, trigger: null, width: layoutTokens.sidebarWidth, collapsedWidth: layoutTokens.sidebarCollapsedWidth, style: {
50
+ position: 'fixed',
51
+ insetBlock: 0,
52
+ insetInlineStart: 0,
53
+ zIndex: 20,
54
+ display: 'flex',
55
+ flexDirection: 'column',
56
+ borderInlineEnd: `1px solid ${navSurface.border}`,
57
+ }, children: [brand, _jsx("div", { style: {
58
+ flex: 1,
59
+ minHeight: 0,
60
+ overflowY: 'auto',
61
+ overflowX: 'hidden',
62
+ paddingBlock: spacing.xs,
63
+ }, children: renderNav === undefined ? nav : renderNav({ collapsed: effectiveCollapsed }) }), navFooter !== undefined && (_jsx("div", { style: {
64
+ padding: spacing.sm,
65
+ borderBlockStart: `1px solid ${navSurface.border}`,
66
+ color: navSurface.text,
67
+ fontSize: 12,
68
+ }, children: navFooter }))] }), _jsxs(Layout, { style: {
69
+ marginInlineStart: siderWidth,
70
+ transition: 'margin-inline-start 0.2s',
71
+ }, children: [_jsxs(Header, { style: {
72
+ position: 'sticky',
73
+ top: 0,
74
+ zIndex: 10,
75
+ display: 'flex',
76
+ alignItems: 'center',
77
+ gap: spacing.md,
78
+ borderBlockEnd: '1px solid var(--ant-color-border-secondary)',
79
+ }, children: [_jsx(Button, { type: "text", "aria-label": effectiveCollapsed ? 'Expand navigation' : 'Collapse navigation', icon: effectiveCollapsed ? _jsx(MenuUnfoldOutlined, {}) : _jsx(MenuFoldOutlined, {}), onClick: () => setCollapsed(!effectiveCollapsed) }), _jsx("div", { style: { flex: 1, minWidth: 0 }, children: headerLeft }), headerRight] }), _jsx(Content, { style: {
80
+ padding: spacing.lg,
81
+ maxWidth: layoutTokens.contentMaxWidth,
82
+ width: '100%',
83
+ }, children: children })] })] }));
84
+ }
@@ -0,0 +1,10 @@
1
+ import * as React from 'react';
2
+ export interface BrandProps {
3
+ /** The console's name — "IAM", "Gatepass". Hidden when collapsed. */
4
+ product?: string;
5
+ /** Icon-only, for the collapsed rail. */
6
+ collapsed?: boolean;
7
+ onClick?: () => void;
8
+ }
9
+ export declare function Brand({ product, collapsed, onClick, }: BrandProps): React.ReactElement;
10
+ //# sourceMappingURL=brand.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"brand.d.ts","sourceRoot":"","sources":["../../src/layout/brand.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAI/B,MAAM,WAAW,UAAU;IACzB,qEAAqE;IACrE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,yCAAyC;IACzC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;CACtB;AAyBD,wBAAgB,KAAK,CAAC,EACpB,OAAO,EACP,SAAiB,EACjB,OAAO,GACR,EAAE,UAAU,GAAG,KAAK,CAAC,YAAY,CAoDjC"}
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ /**
4
+ * The product mark at the top of the sidebar.
5
+ *
6
+ * A component rather than an `<img>` because every console in this monorepo
7
+ * shows the *same* PlantOps mark with a *different* word beside it — "IAM",
8
+ * "Gatepass", "Visitor" — and the thing that must not drift between them is the
9
+ * mark, not the word. Passing `product` keeps the drift impossible.
10
+ *
11
+ * The mark is inline SVG: a shared library that fetches an image from a path
12
+ * only some of its consumers serve is a broken logo waiting for the first
13
+ * deployment that does not copy the asset.
14
+ */
15
+ import { Typography } from 'antd';
16
+ import { navSurface, palette, spacing } from '../theme/tokens';
17
+ /**
18
+ * The mark: three stacked bars narrowing upward inside a rounded square — a
19
+ * process vessel read from the side, which is as close to "plant operations"
20
+ * as an 20px glyph gets without becoming a picture of a factory.
21
+ */
22
+ function Mark({ size = 28 }) {
23
+ return (_jsxs("svg", { width: size, height: size, viewBox: "0 0 32 32", role: "img", "aria-label": "PlantOps", focusable: "false", children: [_jsx("rect", { width: "32", height: "32", rx: "8", fill: palette.primary }), _jsx("rect", { x: "8", y: "8", width: "16", height: "4", rx: "2", fill: "#FFFFFF" }), _jsx("rect", { x: "10", y: "14", width: "12", height: "4", rx: "2", fill: "#FFFFFF", opacity: "0.8" }), _jsx("rect", { x: "12", y: "20", width: "8", height: "4", rx: "2", fill: "#FFFFFF", opacity: "0.6" })] }));
24
+ }
25
+ export function Brand({ product, collapsed = false, onClick, }) {
26
+ const content = (_jsxs(_Fragment, { children: [_jsx(Mark, {}), !collapsed && (_jsxs("span", { style: {
27
+ display: 'flex',
28
+ flexDirection: 'column',
29
+ lineHeight: 1.15,
30
+ minWidth: 0,
31
+ }, children: [_jsx(Typography.Text, { strong: true, style: { color: navSurface.textSelected, fontSize: 15 }, children: "PlantOps" }), product !== undefined && (_jsx(Typography.Text, { style: { color: navSurface.text, fontSize: 11, letterSpacing: 0.6 }, children: product.toUpperCase() }))] }))] }));
32
+ const style = {
33
+ display: 'flex',
34
+ alignItems: 'center',
35
+ gap: spacing.sm,
36
+ height: 56,
37
+ padding: `0 ${collapsed ? spacing.md : spacing.lg}px`,
38
+ background: 'transparent',
39
+ border: 'none',
40
+ width: '100%',
41
+ cursor: onClick === undefined ? 'default' : 'pointer',
42
+ overflow: 'hidden',
43
+ };
44
+ if (onClick === undefined) {
45
+ return _jsx("div", { style: style, children: content });
46
+ }
47
+ return (_jsx("button", { type: "button", onClick: onClick, style: style, children: content }));
48
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The sidebar menu — a pure render of `GET /iam/navigation` (Doc 05 §7).
3
+ *
4
+ * The component holds no route table, no permission check and no idea which
5
+ * console it is in. It is handed a tree and a pathname and produces an antd
6
+ * `Menu`; if a node is in the tree the subject may see it, because the server
7
+ * already pruned everything they may not (Doc 05 §3). That is the whole
8
+ * contract, and it is what makes one component serve the platform console, the
9
+ * client console, and the gatepass and visitor consoles that follow.
10
+ *
11
+ * Navigation is a callback rather than a `next/link`: this library stays free
12
+ * of a router so that a console built on something other than the Next app
13
+ * router — or a Storybook page, or a test — can mount it unchanged.
14
+ */
15
+ import type { NavNodeDTO } from '@plantops/contracts';
16
+ import * as React from 'react';
17
+ export interface NavMenuProps {
18
+ /** The `tree` field of the navigation response, rendered as-is. */
19
+ tree: readonly NavNodeDTO[];
20
+ /** Current location, for selection and sub-menu expansion. */
21
+ pathname: string;
22
+ /** Called with a node's `route` when the user picks a row. */
23
+ onNavigate: (route: string) => void;
24
+ /** Dark in both colour modes — see `tokens.ts`. */
25
+ theme?: 'dark' | 'light';
26
+ /** Collapsed rail: antd renders icons only and pops sub-menus out. */
27
+ collapsed?: boolean;
28
+ }
29
+ export declare function NavMenu({ tree, pathname, onNavigate, theme, collapsed, }: NavMenuProps): React.ReactElement;
30
+ //# sourceMappingURL=nav-menu.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nav-menu.d.ts","sourceRoot":"","sources":["../../src/layout/nav-menu.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAEtD,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAO/B,MAAM,WAAW,YAAY;IAC3B,mEAAmE;IACnE,IAAI,EAAE,SAAS,UAAU,EAAE,CAAC;IAC5B,8DAA8D;IAC9D,QAAQ,EAAE,MAAM,CAAC;IACjB,8DAA8D;IAC9D,UAAU,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,mDAAmD;IACnD,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IACzB,sEAAsE;IACtE,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AA8BD,wBAAgB,OAAO,CAAC,EACtB,IAAI,EACJ,QAAQ,EACR,UAAU,EACV,KAAc,EACd,SAAiB,GAClB,EAAE,YAAY,GAAG,KAAK,CAAC,YAAY,CAoEnC"}
@@ -0,0 +1,75 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { Menu } from 'antd';
4
+ import * as React from 'react';
5
+ import { NavIcon } from '../icons/icon-registry';
6
+ import { navSelectionForPath } from './nav-tree';
7
+ /**
8
+ * Builds antd's item tree, keyed by node id.
9
+ *
10
+ * Ids rather than the catalog `key` because the cross-application shell
11
+ * (Doc 05 §4, `GET /iam/navigation` with no `applicationId`) concatenates trees
12
+ * from several applications, and catalog keys are only unique *within* an
13
+ * application — two apps may each legitimately have a `settings` node.
14
+ */
15
+ function toMenuItems(nodes) {
16
+ return nodes.map((node) => {
17
+ const icon = node.icon === null || node.icon === undefined ? undefined : (_jsx(NavIcon, { iconKey: node.icon }));
18
+ if (node.children.length > 0) {
19
+ return {
20
+ key: node.id,
21
+ icon,
22
+ label: node.label,
23
+ children: toMenuItems(node.children),
24
+ };
25
+ }
26
+ return { key: node.id, icon, label: node.label };
27
+ });
28
+ }
29
+ export function NavMenu({ tree, pathname, onNavigate, theme = 'dark', collapsed = false, }) {
30
+ const items = React.useMemo(() => toMenuItems(tree), [tree]);
31
+ const selection = React.useMemo(() => navSelectionForPath(tree, pathname), [tree, pathname]);
32
+ /**
33
+ * Which sub-menus are open.
34
+ *
35
+ * Seeded from the location and then owned by the user: re-deriving it on
36
+ * every render would slam a sub-menu shut the moment someone opened it to
37
+ * look at a sibling section. It *is* re-seeded when the location changes, so
38
+ * that a deep link or a back-button navigation reveals its own row.
39
+ */
40
+ const [openKeys, setOpenKeys] = React.useState(selection.openKeys);
41
+ // Keyed on the *derived* open keys rather than on the pathname alone. The
42
+ // tree arrives asynchronously, so a deep link into a sub-menu renders once
43
+ // with an empty tree — and a pathname-keyed seed would conclude it had
44
+ // already seeded for that URL and leave the sub-menu shut around the
45
+ // highlighted row.
46
+ const seedKey = `${pathname}|${selection.openKeys.join(',')}`;
47
+ const seededFor = React.useRef(seedKey);
48
+ if (seededFor.current !== seedKey) {
49
+ seededFor.current = seedKey;
50
+ // Union, not replacement: navigating within an open section should not
51
+ // collapse the other sections the user deliberately opened.
52
+ const merged = [...new Set([...openKeys, ...selection.openKeys])];
53
+ if (merged.length !== openKeys.length)
54
+ setOpenKeys(merged);
55
+ }
56
+ const routeById = React.useMemo(() => {
57
+ const map = new Map();
58
+ const walk = (nodes) => {
59
+ for (const node of nodes) {
60
+ if (typeof node.route === 'string' && node.route.trim() !== '') {
61
+ map.set(node.id, node.route);
62
+ }
63
+ walk(node.children);
64
+ }
65
+ };
66
+ walk(tree);
67
+ return map;
68
+ }, [tree]);
69
+ const handleClick = React.useCallback(({ key }) => {
70
+ const route = routeById.get(String(key));
71
+ if (route !== undefined)
72
+ onNavigate(route);
73
+ }, [routeById, onNavigate]);
74
+ return (_jsx(Menu, { mode: "inline", theme: theme, items: items, selectedKeys: selection.selectedKeys, ...(collapsed ? {} : { openKeys, onOpenChange: (keys) => setOpenKeys(keys) }), onClick: handleClick, inlineIndent: 16, style: { borderInlineEnd: 'none', background: 'transparent' } }));
75
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Pure functions over the `NavNodeDTO` tree `GET /iam/navigation` returns.
3
+ *
4
+ * Separated from `nav-menu.tsx` so the interesting logic — which menu row is
5
+ * "current" for a given URL — is testable without rendering antd, and reusable
6
+ * by anything else that needs to reason about the menu: a breadcrumb, a
7
+ * command palette, a redirect that has to land somewhere the user can actually
8
+ * see.
9
+ *
10
+ * Nothing here knows a route literal. Every answer is derived from the tree the
11
+ * server sent (Doc 05 §7: the console "renders the returned tree directly — it
12
+ * does not maintain its own menu constants"), which is what lets a platform
13
+ * admin add a menu tonight and have it highlight correctly tomorrow with no
14
+ * deploy.
15
+ */
16
+ import type { NavNodeDTO } from '@plantops/contracts';
17
+ /** One routable node, with the path of container ids that leads to it. */
18
+ export interface NavRouteEntry {
19
+ node: NavNodeDTO;
20
+ route: string;
21
+ /** Ids of the ancestors, outermost first — the sub-menus to open. */
22
+ ancestorIds: string[];
23
+ }
24
+ /**
25
+ * Every routable node in the tree, depth-first in display order.
26
+ *
27
+ * Containers are skipped: Doc 05 §3 calls a node with children a container and
28
+ * gives it no route, and a menu whose parent is clickable would make "open the
29
+ * sub-menu" and "go somewhere" the same gesture.
30
+ */
31
+ export declare function flattenNavRoutes(tree: readonly NavNodeDTO[]): NavRouteEntry[];
32
+ /**
33
+ * Where to send a user who has arrived at the application root.
34
+ *
35
+ * The first routable node in menu order, which for a pruned tree is the first
36
+ * screen this particular subject may see — so a platform admin and a gate
37
+ * supervisor land in different places from the same `/` without either being
38
+ * hardcoded. `null` when the subject's menu is empty, which the caller must
39
+ * handle: a subject with no grants exists (Doc 05 §3) and needs an explanation,
40
+ * not a redirect loop.
41
+ */
42
+ export declare function firstNavRoute(tree: readonly NavNodeDTO[]): string | null;
43
+ /**
44
+ * The node a URL belongs to: the longest route that prefixes it.
45
+ *
46
+ * Prefix rather than equality because a detail screen is not in the menu.
47
+ * `/admin/users/8f2c…` has to light up *Users*, and it does so by matching
48
+ * `/admin/users`. The "longest" part is what keeps `/admin/users/by-role` — a
49
+ * menu row in its own right — from being swallowed by its shorter sibling.
50
+ *
51
+ * The `/` boundary is required: without it `/admin/users-archive` would match
52
+ * `/admin/users`, and the wrong row would highlight on a screen the menu does
53
+ * not contain.
54
+ */
55
+ export declare function findNavRouteForPath(tree: readonly NavNodeDTO[], pathname: string): NavRouteEntry | null;
56
+ /** What antd's `Menu` needs to render the current location. */
57
+ export interface NavSelection {
58
+ /** Ids of selected rows — at most one, but antd takes an array. */
59
+ selectedKeys: string[];
60
+ /** Ids of the sub-menus that must be open for the selection to be visible. */
61
+ openKeys: string[];
62
+ }
63
+ /** {@link findNavRouteForPath}, in the shape antd's `Menu` consumes. */
64
+ export declare function navSelectionForPath(tree: readonly NavNodeDTO[], pathname: string): NavSelection;
65
+ /** Ids of every container node — the full set of expandable sub-menus. */
66
+ export declare function navContainerIds(tree: readonly NavNodeDTO[]): string[];
67
+ //# sourceMappingURL=nav-tree.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nav-tree.d.ts","sourceRoot":"","sources":["../../src/layout/nav-tree.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAEtD,0EAA0E;AAC1E,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,UAAU,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,qEAAqE;IACrE,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,UAAU,EAAE,GAAG,aAAa,EAAE,CAiB7E;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,GAAG,IAAI,CAExE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,SAAS,UAAU,EAAE,EAC3B,QAAQ,EAAE,MAAM,GACf,aAAa,GAAG,IAAI,CAatB;AAED,+DAA+D;AAC/D,MAAM,WAAW,YAAY;IAC3B,mEAAmE;IACnE,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,8EAA8E;IAC9E,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,wEAAwE;AACxE,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,SAAS,UAAU,EAAE,EAC3B,QAAQ,EAAE,MAAM,GACf,YAAY,CAId;AAED,0EAA0E;AAC1E,wBAAgB,eAAe,CAAC,IAAI,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,EAAE,CAYrE"}
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Pure functions over the `NavNodeDTO` tree `GET /iam/navigation` returns.
3
+ *
4
+ * Separated from `nav-menu.tsx` so the interesting logic — which menu row is
5
+ * "current" for a given URL — is testable without rendering antd, and reusable
6
+ * by anything else that needs to reason about the menu: a breadcrumb, a
7
+ * command palette, a redirect that has to land somewhere the user can actually
8
+ * see.
9
+ *
10
+ * Nothing here knows a route literal. Every answer is derived from the tree the
11
+ * server sent (Doc 05 §7: the console "renders the returned tree directly — it
12
+ * does not maintain its own menu constants"), which is what lets a platform
13
+ * admin add a menu tonight and have it highlight correctly tomorrow with no
14
+ * deploy.
15
+ */
16
+ /**
17
+ * Every routable node in the tree, depth-first in display order.
18
+ *
19
+ * Containers are skipped: Doc 05 §3 calls a node with children a container and
20
+ * gives it no route, and a menu whose parent is clickable would make "open the
21
+ * sub-menu" and "go somewhere" the same gesture.
22
+ */
23
+ export function flattenNavRoutes(tree) {
24
+ const entries = [];
25
+ const walk = (nodes, ancestorIds) => {
26
+ for (const node of nodes) {
27
+ const route = typeof node.route === 'string' ? node.route.trim() : '';
28
+ if (route !== '') {
29
+ entries.push({ node, route, ancestorIds });
30
+ }
31
+ if (node.children.length > 0) {
32
+ walk(node.children, [...ancestorIds, node.id]);
33
+ }
34
+ }
35
+ };
36
+ walk(tree, []);
37
+ return entries;
38
+ }
39
+ /**
40
+ * Where to send a user who has arrived at the application root.
41
+ *
42
+ * The first routable node in menu order, which for a pruned tree is the first
43
+ * screen this particular subject may see — so a platform admin and a gate
44
+ * supervisor land in different places from the same `/` without either being
45
+ * hardcoded. `null` when the subject's menu is empty, which the caller must
46
+ * handle: a subject with no grants exists (Doc 05 §3) and needs an explanation,
47
+ * not a redirect loop.
48
+ */
49
+ export function firstNavRoute(tree) {
50
+ return flattenNavRoutes(tree)[0]?.route ?? null;
51
+ }
52
+ /**
53
+ * The node a URL belongs to: the longest route that prefixes it.
54
+ *
55
+ * Prefix rather than equality because a detail screen is not in the menu.
56
+ * `/admin/users/8f2c…` has to light up *Users*, and it does so by matching
57
+ * `/admin/users`. The "longest" part is what keeps `/admin/users/by-role` — a
58
+ * menu row in its own right — from being swallowed by its shorter sibling.
59
+ *
60
+ * The `/` boundary is required: without it `/admin/users-archive` would match
61
+ * `/admin/users`, and the wrong row would highlight on a screen the menu does
62
+ * not contain.
63
+ */
64
+ export function findNavRouteForPath(tree, pathname) {
65
+ const path = normalizePath(pathname);
66
+ let best = null;
67
+ for (const entry of flattenNavRoutes(tree)) {
68
+ const route = normalizePath(entry.route);
69
+ const matches = path === route || path.startsWith(`${route}/`);
70
+ if (matches && (best === null || route.length > normalizePath(best.route).length)) {
71
+ best = entry;
72
+ }
73
+ }
74
+ return best;
75
+ }
76
+ /** {@link findNavRouteForPath}, in the shape antd's `Menu` consumes. */
77
+ export function navSelectionForPath(tree, pathname) {
78
+ const match = findNavRouteForPath(tree, pathname);
79
+ if (match === null)
80
+ return { selectedKeys: [], openKeys: [] };
81
+ return { selectedKeys: [match.node.id], openKeys: match.ancestorIds };
82
+ }
83
+ /** Ids of every container node — the full set of expandable sub-menus. */
84
+ export function navContainerIds(tree) {
85
+ const ids = [];
86
+ const walk = (nodes) => {
87
+ for (const node of nodes) {
88
+ if (node.children.length > 0) {
89
+ ids.push(node.id);
90
+ walk(node.children);
91
+ }
92
+ }
93
+ };
94
+ walk(tree);
95
+ return ids;
96
+ }
97
+ /** Trailing slashes off, empty becomes `/`. Comparisons need one spelling. */
98
+ function normalizePath(value) {
99
+ const trimmed = value.trim().replace(/\/+$/, '');
100
+ return trimmed === '' ? '/' : trimmed;
101
+ }
@@ -0,0 +1,20 @@
1
+ import { type MenuProps } from 'antd';
2
+ import * as React from 'react';
3
+ export interface UserMenuProps {
4
+ /** Display name, falling back to the email when a user has no name yet. */
5
+ name: string;
6
+ /** The tenant's name or slug. */
7
+ tenant?: string | null;
8
+ /** Secondary line in the dropdown — normally the email. */
9
+ subtitle?: string | null;
10
+ onLogout: () => void;
11
+ /** Colour-mode toggle. Omit to hide the row. */
12
+ colorMode?: {
13
+ mode: 'light' | 'dark';
14
+ onToggle: () => void;
15
+ };
16
+ /** Rows inserted above the sign-out row. */
17
+ extraItems?: MenuProps['items'];
18
+ }
19
+ export declare function UserMenu({ name, tenant, subtitle, onLogout, colorMode, extraItems, }: UserMenuProps): React.ReactElement;
20
+ //# sourceMappingURL=user-menu.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"user-menu.d.ts","sourceRoot":"","sources":["../../src/layout/user-menu.tsx"],"names":[],"mappings":"AAiBA,OAAO,EAAgC,KAAK,SAAS,EAAE,MAAM,MAAM,CAAC;AACpE,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAI/B,MAAM,WAAW,aAAa;IAC5B,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAC;IACb,iCAAiC;IACjC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,gDAAgD;IAChD,SAAS,CAAC,EAAE;QAAE,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,IAAI,CAAA;KAAE,CAAC;IAC7D,4CAA4C;IAC5C,UAAU,CAAC,EAAE,SAAS,CAAC,OAAO,CAAC,CAAC;CACjC;AAaD,wBAAgB,QAAQ,CAAC,EACvB,IAAI,EACJ,MAAM,EACN,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,UAAU,GACX,EAAE,aAAa,GAAG,KAAK,CAAC,YAAY,CAgFpC"}
@@ -0,0 +1,75 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * The signed-in subject, and what they can do about it.
5
+ *
6
+ * The header's right-hand control: who am I, which tenant am I in, log out.
7
+ * `tenant` is shown rather than tucked away because the same email can exist in
8
+ * two clients (Doc 06 §8 — "same email creatable under two different clients"),
9
+ * so "which PlantOps am I looking at" is a question the interface has to answer
10
+ * without being asked.
11
+ *
12
+ * Extra rows go in `extraItems` — "My sessions" arrives with the session
13
+ * screen, "Change password" with the profile screen — so this component never
14
+ * grows a prop per feature.
15
+ */
16
+ import { LogoutOutlined, MoonOutlined, SunOutlined, UserOutlined } from '@ant-design/icons';
17
+ import { Avatar, Dropdown, Typography } from 'antd';
18
+ import { spacing } from '../theme/tokens';
19
+ /** Two initials from a display name — "Priya Nair" → "PN", "ops@x" → "OP". */
20
+ function initialsOf(name) {
21
+ const words = name.trim().split(/\s+/).filter((word) => word !== '');
22
+ const first = words.at(0) ?? '';
23
+ const last = words.at(-1) ?? '';
24
+ if (first === '')
25
+ return '?';
26
+ if (words.length === 1)
27
+ return first.slice(0, 2).toUpperCase();
28
+ return `${first.slice(0, 1)}${last.slice(0, 1)}`.toUpperCase();
29
+ }
30
+ export function UserMenu({ name, tenant, subtitle, onLogout, colorMode, extraItems, }) {
31
+ const items = [
32
+ {
33
+ key: 'identity',
34
+ type: 'group',
35
+ label: (_jsxs("span", { style: { display: 'block', padding: `${spacing.xxs}px 0` }, children: [_jsx(Typography.Text, { strong: true, style: { display: 'block' }, children: name }), subtitle != null && subtitle !== '' && (_jsx(Typography.Text, { type: "secondary", style: { fontSize: 12 }, children: subtitle }))] })),
36
+ },
37
+ { type: 'divider' },
38
+ ...(extraItems ?? []),
39
+ ...(colorMode === undefined
40
+ ? []
41
+ : [
42
+ {
43
+ key: 'color-mode',
44
+ icon: colorMode.mode === 'dark' ? _jsx(SunOutlined, {}) : _jsx(MoonOutlined, {}),
45
+ label: colorMode.mode === 'dark' ? 'Light appearance' : 'Dark appearance',
46
+ onClick: colorMode.onToggle,
47
+ },
48
+ ]),
49
+ { type: 'divider' },
50
+ {
51
+ key: 'logout',
52
+ icon: _jsx(LogoutOutlined, {}),
53
+ label: 'Sign out',
54
+ danger: true,
55
+ onClick: onLogout,
56
+ },
57
+ ];
58
+ return (_jsx(Dropdown, { menu: { items }, trigger: ['click'], placement: "bottomRight", children: _jsxs("button", { type: "button", style: {
59
+ display: 'flex',
60
+ alignItems: 'center',
61
+ gap: spacing.xs,
62
+ background: 'transparent',
63
+ border: 'none',
64
+ cursor: 'pointer',
65
+ padding: `${spacing.xxs}px ${spacing.xs}px`,
66
+ borderRadius: 6,
67
+ color: 'inherit',
68
+ }, children: [_jsx(Avatar, { size: 30, style: { background: 'var(--ant-color-primary)' }, children: name === '' ? _jsx(UserOutlined, {}) : initialsOf(name) }), _jsxs("span", { style: {
69
+ display: 'flex',
70
+ flexDirection: 'column',
71
+ alignItems: 'flex-start',
72
+ lineHeight: 1.2,
73
+ maxWidth: 180,
74
+ }, children: [_jsx(Typography.Text, { style: { fontSize: 13 }, ellipsis: true, children: name }), tenant != null && tenant !== '' && (_jsx(Typography.Text, { type: "secondary", style: { fontSize: 11 }, ellipsis: true, children: tenant }))] })] }) }));
75
+ }