@bitbaum/sitekit 0.5.1 → 0.6.1

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/README.md CHANGED
@@ -73,6 +73,23 @@ design tokens. Existing document sites keep their utility-class appearance.
73
73
  links={[{ href: '/roadmap', label: 'Roadmap' }]} extra="Beta" />
74
74
  ```
75
75
 
76
+ For a grouped application menu, pass `navGroups` alongside `navItems`. Each
77
+ group has a `label` and `items` containing `path`, `label` and an optional
78
+ `description`. Import `SiteNavGroup` from `@bitbaum/sitekit/react` for typed
79
+ configuration. Nonempty groups replace the flat application navigation with
80
+ one native disclosure and a responsive panel; omitted or empty groups retain
81
+ the flat API. Group labels, destinations and descriptions belong to the host.
82
+
83
+ ```tsx
84
+ navGroups={[{ label: 'Create', items: [
85
+ { path: 'write', label: 'Write', description: 'Start an article.' }
86
+ ] }]}
87
+ ```
88
+
89
+ Run `pnpm exec playwright install --with-deps chromium` before `pnpm run verify`.
90
+ An existing Chromium can be selected with `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`.
91
+ The browser regression uses the public renderer and stylesheet at four widths.
92
+
76
93
  The package owns the responsive menu, its native no-JavaScript fallback,
77
94
  Escape/outside/route dismissal, skip link, active states, touch targets and
78
95
  footer layout. Products supply labels, navigation data, identity and account
@@ -1,9 +1,12 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { SiteChrome as SiteChromeSpec, SiteNavItem } from '../schema.js';
3
3
  import { type LinkLike } from './link.js';
4
+ import { type SiteNavGroup } from './SiteNav.js';
4
5
  interface Props {
5
6
  chrome: SiteChromeSpec;
6
7
  navItems: SiteNavItem[];
8
+ /** Descriptive application navigation; product-owned group configuration. */
9
+ navGroups?: SiteNavGroup[];
7
10
  currentPath: string;
8
11
  /** Framework link component (e.g. next/link). Defaults to a plain anchor. */
9
12
  Link?: LinkLike;
@@ -16,7 +19,7 @@ interface Props {
16
19
  navLabel?: string;
17
20
  menuLabel?: string;
18
21
  }
19
- export declare function SiteMasthead({ chrome, navItems, currentPath, Link, appearance, brand, actions, mainId, skipLabel, navLabel, menuLabel, }: Props): import("react").JSX.Element;
22
+ export declare function SiteMasthead({ chrome, navItems, navGroups, currentPath, Link, appearance, brand, actions, mainId, skipLabel, navLabel, menuLabel, }: Props): import("react").JSX.Element;
20
23
  export declare function SiteFooter({ chrome, appearance, links, groups, Link, label, extra, }: {
21
24
  chrome: SiteChromeSpec;
22
25
  appearance?: 'application';
@@ -13,9 +13,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
13
13
  import { href } from '../model.js';
14
14
  import { DefaultLink } from './link.js';
15
15
  import { SiteNav } from './SiteNav.js';
16
- export function SiteMasthead({ chrome, navItems, currentPath, Link = DefaultLink, appearance, brand, actions, mainId, skipLabel = 'Skip to content', navLabel = 'Main navigation', menuLabel = 'Menu', }) {
16
+ export function SiteMasthead({ chrome, navItems, navGroups, currentPath, Link = DefaultLink, appearance, brand, actions, mainId, skipLabel = 'Skip to content', navLabel = 'Main navigation', menuLabel = 'Menu', }) {
17
17
  if (appearance === 'application')
18
- return (_jsxs(_Fragment, { children: [mainId && (_jsx("a", { className: "sk-skip", href: '#' + mainId, children: skipLabel })), _jsx("header", { className: "sk-masthead", children: _jsxs("div", { className: "sk-shell-frame sk-masthead-row", children: [_jsx(Link, { href: "/", className: "sk-brand", children: brand ?? chrome.name }), _jsx(SiteNav, { items: navItems, currentPath: currentPath, Link: Link, appearance: "application", label: navLabel, menuLabel: menuLabel, menuActions: actions }), actions && _jsx("div", { className: "sk-account-actions", children: actions })] }) })] }));
18
+ return (_jsxs(_Fragment, { children: [mainId && (_jsx("a", { className: "sk-skip", href: '#' + mainId, children: skipLabel })), _jsx("header", { className: "sk-masthead", children: _jsxs("div", { className: "sk-shell-frame sk-masthead-row", children: [_jsx(Link, { href: "/", className: "sk-brand", children: brand ?? chrome.name }), _jsx(SiteNav, { items: navItems, groups: navGroups, currentPath: currentPath, Link: Link, appearance: "application", label: navLabel, menuLabel: menuLabel, menuActions: actions }), actions && _jsx("div", { className: "sk-account-actions", children: actions })] }) })] }));
19
19
  return (_jsx("header", { className: "sticky top-0 z-30 border-b border-subtle bg-surface-page/85 backdrop-blur", children: _jsx("div", { className: "mx-auto max-w-shell px-4 sm:px-6 lg:px-8", children: _jsxs("div", { className: "flex flex-wrap items-center justify-between gap-x-6 py-2", children: [_jsx(Link, { href: href(), className: "inline-flex min-h-11 shrink-0 items-center rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent", children: _jsx("span", { className: "font-heading text-lg font-semibold tracking-display text-fg-primary", children: chrome.name }) }), _jsx(SiteNav, { items: navItems, currentPath: currentPath, Link: Link })] }) }) }));
20
20
  }
21
21
  export function SiteFooter({ chrome, appearance, links = [], groups, Link = DefaultLink, label = 'Footer navigation', extra, }) {
@@ -9,6 +9,12 @@
9
9
  import type { ReactNode } from 'react';
10
10
  import type { SiteNavItem } from '../schema.js';
11
11
  import { type LinkLike } from './link.js';
12
+ export interface SiteNavGroup {
13
+ label: string;
14
+ items: Array<SiteNavItem & {
15
+ description?: string;
16
+ }>;
17
+ }
12
18
  interface Props {
13
19
  /**
14
20
  * Deliberately NOT `SitePage[]`. Everything the nav receives ends up in the
@@ -16,6 +22,8 @@ interface Props {
16
22
  */
17
23
  items: SiteNavItem[];
18
24
  currentPath: string;
25
+ /** Opt-in grouped application menu. Flat items remain unchanged when omitted. */
26
+ groups?: SiteNavGroup[];
19
27
  /** Framework link component (e.g. next/link). Defaults to a plain anchor. */
20
28
  Link?: LinkLike;
21
29
  appearance?: 'application';
@@ -28,5 +36,5 @@ interface Props {
28
36
  */
29
37
  menuActions?: ReactNode;
30
38
  }
31
- export declare function SiteNav({ items, currentPath, Link, appearance, label, menuLabel, menuActions, }: Props): import("react").JSX.Element | null;
39
+ export declare function SiteNav({ items, groups, currentPath, Link, appearance, label, menuLabel, menuActions, }: Props): import("react").JSX.Element | null;
32
40
  export {};
@@ -3,8 +3,12 @@ import { href } from '../model.js';
3
3
  import { DefaultLink } from './link.js';
4
4
  import { MenuDismiss } from './MenuDismiss.js';
5
5
  import { NavAutoScroll } from './NavAutoScroll.js';
6
- export function SiteNav({ items, currentPath, Link = DefaultLink, appearance, label = 'Sections', menuLabel = 'Menu', menuActions, }) {
6
+ export function SiteNav({ items, groups, currentPath, Link = DefaultLink, appearance, label = 'Sections', menuLabel = 'Menu', menuActions, }) {
7
7
  if (appearance === 'application') {
8
+ const populatedGroups = groups?.filter((group) => group.items.length > 0);
9
+ if (populatedGroups?.length) {
10
+ return (_jsxs(_Fragment, { children: [_jsxs("details", { className: "sk-grouped-menu", "data-sitekit-menu": true, children: [_jsxs("summary", { children: [menuLabel, _jsx("span", { "aria-hidden": "true", children: "\u2630" })] }), _jsxs("nav", { className: "sk-grouped-panel", "aria-label": label, children: [_jsx("div", { className: "sk-nav-groups", children: populatedGroups.map((group) => (_jsxs("section", { className: "sk-nav-group", "aria-label": group.label, children: [_jsx("h2", { children: group.label }), group.items.map((item) => (_jsxs(Link, { href: href(item.path), "aria-current": item.path === currentPath ? 'page' : undefined, children: [_jsx("span", { className: "sk-nav-link-label", children: item.label }), item.description && (_jsx("span", { className: "sk-nav-description", children: item.description }))] }, item.path)))] }, group.label))) }), menuActions && _jsx("div", { className: "sk-grouped-actions", children: menuActions })] })] }), _jsx(MenuDismiss, { currentPath: currentPath })] }));
11
+ }
8
12
  if (items.length === 0)
9
13
  return null;
10
14
  const links = () => items.map((item) => (_jsx(Link, { href: href(item.path), "aria-current": item.path === currentPath ? 'page' : undefined, children: item.label }, item.path)));
@@ -8,7 +8,7 @@
8
8
  */
9
9
  export { SiteSections } from './SiteSections.js';
10
10
  export { SiteMasthead, SiteFooter } from './SiteChrome.js';
11
- export { SiteNav } from './SiteNav.js';
11
+ export { SiteNav, type SiteNavGroup } from './SiteNav.js';
12
12
  export { SiteTabBar, type SiteTabItem } from './SiteTabBar.js';
13
13
  export { NavAutoScroll } from './NavAutoScroll.js';
14
14
  export { DefaultLink, type LinkLike, type LinkProps } from './link.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitbaum/sitekit",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "A website as data: a closed union of section shapes validated at runtime, one set of React renderers, and per-field provenance so a generated site can prove it fabricated nothing.",
5
5
  "license": "MIT",
6
6
  "author": "Mao Nakamoto",
@@ -67,8 +67,10 @@
67
67
  "@eslint/js": "^10.0.1",
68
68
  "@types/node": "^26.4.1",
69
69
  "@types/react": "^19.0.0",
70
+ "esbuild": "^0.28.2",
70
71
  "eslint": "^10.9.1",
71
72
  "globals": "^17.12.0",
73
+ "playwright": "^1.64.1",
72
74
  "prettier": "3.9.10",
73
75
  "react": "^19.0.0",
74
76
  "react-dom": "^19.0.0",
package/styles.css CHANGED
@@ -393,3 +393,99 @@
393
393
  display: none;
394
394
  }
395
395
  }
396
+
397
+ /* One native disclosure and one link tree for grouped application navigation. */
398
+ .sk-grouped-menu {
399
+ margin-inline-start: auto;
400
+ }
401
+ .sk-grouped-menu summary {
402
+ display: flex;
403
+ align-items: center;
404
+ justify-content: center;
405
+ gap: 0.5rem;
406
+ min-height: 44px;
407
+ padding: 0.65rem 0.85rem;
408
+ list-style: none;
409
+ cursor: pointer;
410
+ border: 1px solid var(--sk-line, #d6d6d6);
411
+ border-radius: var(--sk-radius, 0.5rem);
412
+ font-size: 0.875rem;
413
+ font-weight: 600;
414
+ }
415
+ .sk-grouped-menu summary::-webkit-details-marker {
416
+ display: none;
417
+ }
418
+ .sk-grouped-panel {
419
+ position: absolute;
420
+ inset-block-start: 100%;
421
+ inset-inline-end: var(--sk-gutter, 1rem);
422
+ width: min(54rem, calc(100% - 2 * var(--sk-gutter, 1rem)));
423
+ box-sizing: border-box;
424
+ max-height: 75dvh;
425
+ overflow-y: auto;
426
+ padding: 1rem;
427
+ background: var(--sk-bg, Canvas);
428
+ border: 1px solid var(--sk-line, #d6d6d6);
429
+ border-radius: var(--sk-radius, 0.5rem);
430
+ box-shadow: 0 18px 40px #00000014;
431
+ }
432
+ .sk-nav-groups {
433
+ display: grid;
434
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
435
+ gap: 1rem;
436
+ }
437
+ .sk-nav-group {
438
+ min-width: 0;
439
+ }
440
+ .sk-nav-group h2 {
441
+ margin: 0 0 0.5rem;
442
+ font-size: 0.875rem;
443
+ color: var(--sk-muted, #555);
444
+ }
445
+ .sk-grouped-actions a,
446
+ .sk-nav-group a {
447
+ display: flex;
448
+ flex-direction: column;
449
+ justify-content: center;
450
+ box-sizing: border-box;
451
+ min-height: 44px;
452
+ padding: 0.75rem;
453
+ border-radius: var(--sk-radius, 0.5rem);
454
+ color: var(--sk-fg, CanvasText);
455
+ text-decoration: none;
456
+ overflow-wrap: anywhere;
457
+ }
458
+ .sk-nav-group a:hover,
459
+ .sk-nav-group a[aria-current='page'] {
460
+ background: var(--sk-raised, #f0f0f0);
461
+ }
462
+ .sk-nav-link-label {
463
+ font-weight: 600;
464
+ }
465
+ .sk-nav-description {
466
+ margin-top: 0.25rem;
467
+ font-size: 0.875rem;
468
+ line-height: 1.5;
469
+ color: var(--sk-muted, #555);
470
+ }
471
+ .sk-grouped-actions {
472
+ display: none;
473
+ }
474
+ @media (max-width: 600px) {
475
+ .sk-nav-groups {
476
+ grid-template-columns: minmax(0, 1fr);
477
+ }
478
+ }
479
+ @media (max-width: 380px) {
480
+ .sk-masthead-row:has(.sk-grouped-menu) > .sk-account-actions {
481
+ display: none;
482
+ }
483
+ .sk-grouped-actions {
484
+ display: flex;
485
+ flex-direction: column;
486
+ gap: 0.5rem;
487
+ border-top: 1px solid var(--sk-line, #d6d6d6);
488
+ padding-top: 0.75rem;
489
+ margin-top: 0.75rem;
490
+ }
491
+ }