@medalsocial/meda 2.6.0 → 2.7.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.
@@ -43,8 +43,11 @@ export function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace
43
43
  // nested viewport-height divs collapse cleanly — no double-scroll.
44
44
  const commandRegistry = useContext(CommandRegistryContext);
45
45
  const shell = (_jsxs("div", { className: "flex h-svh flex-col", children: [isMobile ? (_jsx(MobileHeader, { globalActions: globalActions, headerCenter: headerCenter })) : (_jsx(ShellHeader, { globalActions: globalActions, headerCenter: headerCenter, headerLeading: headerLeading, appTabsRenderLink: appTabs?.renderLink, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter, showPanelToggle: resolvedRightPanel.panelViews.length > 0, panelViews: resolvedRightPanel.panelViews })), banners ? (_jsx("div", { "data-meda-banners": "", className: "flex-shrink-0", children: banners })) : (false), _jsxs("div", { className: "relative flex flex-1 overflow-hidden", children: [!isMobile && iconRail && (_jsx(IconRail, { mainItems: iconRail.mainItems, utilityItems: iconRail.utilityItems, footer: iconRail.footer, activeId: iconRail.activeId, renderLink: iconRail.renderLink, labelVisibility: iconRail.labelVisibility })), !isMobile && contextRail && (_jsx(ContextRail, { appId: contextRail.appId, module: contextRail.module, activeItemId: contextRail.activeItemId, renderLink: contextRail.renderLink, header: contextRail.header, scroll: contextRail.scroll })), _jsx(ShellMain, { layout: mainLayout ?? 'workspace', className: cn(mainClassName,
46
- // Pad the scroll area so the floating dock never covers content.
47
- isMobile && mobileNav && 'pb-[calc(env(safe-area-inset-bottom)+80px)]'), children: children }), !isMobile && resolvedRightPanel.panelViews.length > 0 && (_jsx(RightPanel, { panelViews: staticPanelViews, defaultView: rightPanel?.defaultView }))] }), isMobile && mobileNav ? (_jsxs(_Fragment, { children: [_jsx(MobileDock, { items: mobileNav.dock, activeTo: mobileNav.activeTo, renderLink: mobileNav.renderLink }), _jsx(MobileWorkspaceSheet, { tree: mobileNav.tree, activeTo: mobileNav.activeTo, renderLink: mobileNav.renderLink, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter })] })) : (_jsxs(_Fragment, { children: [isMobile && hasDrawerContent && _jsx(MobileBottomNav, { items: navItems }), isMobile && hasDrawerContent && (_jsx(MobileDrawers, { menuItems: mobileMenuItems, menuActiveId: iconRail?.activeId, menuRenderLink: iconRail?.renderLink, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter, module: contextRail?.module, moduleAppId: contextRail?.appId, moduleActiveItemId: contextRail?.activeItemId, moduleRenderLink: contextRail?.renderLink, moduleHeader: contextRail?.header, moduleScroll: contextRail?.scroll, sectionTabs: headerLeading, panelViews: resolvedRightPanel.panelViews, defaultView: resolvedRightPanel.defaultView }))] }))] }));
46
+ // Pad the scroll area so the dock never covers content. 64px is
47
+ // the bar-variant dock's intrinsic height (py-2 + 25px icon +
48
+ // label) — the previous 80px over-reserved 16px of permanently
49
+ // unreachable space at the bottom of every mobile surface.
50
+ isMobile && mobileNav && 'pb-[calc(env(safe-area-inset-bottom)+64px)]'), children: children }), !isMobile && resolvedRightPanel.panelViews.length > 0 && (_jsx(RightPanel, { panelViews: staticPanelViews, defaultView: rightPanel?.defaultView }))] }), isMobile && mobileNav ? (_jsxs(_Fragment, { children: [_jsx(MobileDock, { items: mobileNav.dock, activeTo: mobileNav.activeTo, renderLink: mobileNav.renderLink, variant: mobileNav.variant }), _jsx(MobileWorkspaceSheet, { tree: mobileNav.tree, activeTo: mobileNav.activeTo, renderLink: mobileNav.renderLink, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter })] })) : (_jsxs(_Fragment, { children: [isMobile && hasDrawerContent && _jsx(MobileBottomNav, { items: navItems }), isMobile && hasDrawerContent && (_jsx(MobileDrawers, { menuItems: mobileMenuItems, menuActiveId: iconRail?.activeId, menuRenderLink: iconRail?.renderLink, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter, module: contextRail?.module, moduleAppId: contextRail?.appId, moduleActiveItemId: contextRail?.activeItemId, moduleRenderLink: contextRail?.renderLink, moduleHeader: contextRail?.header, moduleScroll: contextRail?.scroll, sectionTabs: headerLeading, panelViews: resolvedRightPanel.panelViews, defaultView: resolvedRightPanel.defaultView }))] }))] }));
48
51
  // Backwards-compatible default: meda provides the built-in CommandPalette
49
52
  // (and its CommandRegistryContext) so consumers calling useCommands()/
50
53
  // useCommandGroup() under the workspace shell keep working. When a registry is
@@ -7,11 +7,18 @@ export interface MobileDockProps {
7
7
  activeTo?: string;
8
8
  renderLink?: (args: MobileNavLinkArgs) => ReactNode;
9
9
  className?: string;
10
+ /** `pill` (default) floating dock, or `bar` full-width labeled bottom bar. */
11
+ variant?: 'pill' | 'bar';
10
12
  }
11
13
  /**
12
- * Floating mobile dock — a pill of pinned destinations plus a workspace-sheet
13
- * trigger, with any `emphasis: 'brand'` item rendered as a standalone circle
14
- * (e.g. Pilot) beside the pill. Replaces the bordered bottom bar. Hidden on
15
- * non-mobile viewports and when the right panel is fullscreen.
14
+ * Mobile dock — pinned destinations plus a workspace-sheet trigger. Two shapes:
15
+ *
16
+ * - `pill` (default): a floating, centered pill of icon-only slots, with any
17
+ * `emphasis: 'brand'` item (e.g. Pilot) as a standalone circle beside it.
18
+ * - `bar`: a flat, full-width bottom bar of evenly-spread slots — each a larger
19
+ * icon + label with a tinted active state — mirroring the native app. The
20
+ * brand item sits inline as the last slot (a filled circle).
21
+ *
22
+ * Hidden on non-mobile viewports and when the right panel is fullscreen.
16
23
  */
17
- export declare function MobileDock({ items, activeTo, renderLink, className }: MobileDockProps): import("react/jsx-runtime").JSX.Element | null;
24
+ export declare function MobileDock({ items, activeTo, renderLink, className, variant, }: MobileDockProps): import("react/jsx-runtime").JSX.Element | null;
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { createElement, isValidElement } from 'react';
4
4
  import { cn } from '../../lib/utils.js';
5
5
  import { useMedaShell } from '../shell-provider.js';
@@ -18,12 +18,17 @@ function renderIcon(icon, size) {
18
18
  return null;
19
19
  }
20
20
  /**
21
- * Floating mobile dock — a pill of pinned destinations plus a workspace-sheet
22
- * trigger, with any `emphasis: 'brand'` item rendered as a standalone circle
23
- * (e.g. Pilot) beside the pill. Replaces the bordered bottom bar. Hidden on
24
- * non-mobile viewports and when the right panel is fullscreen.
21
+ * Mobile dock — pinned destinations plus a workspace-sheet trigger. Two shapes:
22
+ *
23
+ * - `pill` (default): a floating, centered pill of icon-only slots, with any
24
+ * `emphasis: 'brand'` item (e.g. Pilot) as a standalone circle beside it.
25
+ * - `bar`: a flat, full-width bottom bar of evenly-spread slots — each a larger
26
+ * icon + label with a tinted active state — mirroring the native app. The
27
+ * brand item sits inline as the last slot (a filled circle).
28
+ *
29
+ * Hidden on non-mobile viewports and when the right panel is fullscreen.
25
30
  */
26
- export function MobileDock({ items, activeTo, renderLink, className }) {
31
+ export function MobileDock({ items, activeTo, renderLink, className, variant = 'pill', }) {
27
32
  const ctx = useMedaShell();
28
33
  const band = useShellViewport();
29
34
  if (band !== 'mobile')
@@ -32,8 +37,6 @@ export function MobileDock({ items, activeTo, renderLink, className }) {
32
37
  return null;
33
38
  if (items.length === 0)
34
39
  return null;
35
- const pillItems = items.filter((item) => item.emphasis !== 'brand');
36
- const brandItems = items.filter((item) => item.emphasis === 'brand');
37
40
  const runAction = (item) => {
38
41
  if (item.action === 'open-sheet')
39
42
  ctx.mobileDrawer.setOpen(WORKSPACE_SHEET_KEY);
@@ -42,6 +45,45 @@ export function MobileDock({ items, activeTo, renderLink, className }) {
42
45
  else if (item.action === 'open-command-palette')
43
46
  ctx.commandPalette.setOpen(true);
44
47
  };
48
+ // ── Full-width bar (native-app style) ──────────────────────────────────
49
+ if (variant === 'bar') {
50
+ const renderBarSlot = (item) => {
51
+ const label = typeof item.label === 'function' ? item.label() : item.label;
52
+ const isActive = Boolean(item.to && activeTo && item.to === activeTo);
53
+ const isBrand = item.emphasis === 'brand';
54
+ const iconEl = isBrand ? (_jsx("span", { className: "flex size-8 items-center justify-center rounded-full bg-[#5B2D8C] text-white", children: renderIcon(item.icon, 20) })) : (renderIcon(item.icon, 25));
55
+ let toneClass = 'text-muted-foreground hover:text-foreground';
56
+ if (isBrand)
57
+ toneClass = 'text-muted-foreground';
58
+ else if (isActive)
59
+ toneClass = 'font-medium text-primary';
60
+ const slotClass = cn('flex w-full flex-col items-center justify-center gap-1 py-2 text-[11px] leading-none transition-colors', toneClass);
61
+ const children = (_jsxs(_Fragment, { children: [_jsxs("span", { className: "relative flex items-center justify-center", children: [iconEl, item.badge ? (_jsx("span", { className: "absolute -top-0.5 -right-1 size-2 rounded-full bg-primary ring-2 ring-card" })) : null] }), _jsx("span", { children: label })] }));
62
+ if (item.to && !item.action) {
63
+ if (renderLink) {
64
+ return renderLink({
65
+ to: item.to,
66
+ isActive,
67
+ className: slotClass,
68
+ children,
69
+ onNavigate: () => undefined,
70
+ linkProps: {
71
+ href: item.to,
72
+ className: slotClass,
73
+ 'aria-label': label,
74
+ 'aria-current': isActive ? 'page' : undefined,
75
+ },
76
+ });
77
+ }
78
+ return (_jsx("a", { href: item.to, className: slotClass, "aria-label": label, "aria-current": isActive ? 'page' : undefined, children: children }));
79
+ }
80
+ return (_jsx("button", { type: "button", onClick: () => runAction(item), "aria-label": label, className: slotClass, children: children }));
81
+ };
82
+ return (_jsx("nav", { "data-testid": "mobile-dock", "aria-label": "Dock", className: cn('absolute inset-x-0 bottom-0 z-30 flex items-stretch border-t border-border bg-card pb-[env(safe-area-inset-bottom)]', className), children: items.map((item) => (_jsx("div", { className: "flex flex-1", children: renderBarSlot(item) }, item.id))) }));
83
+ }
84
+ // ── Floating pill (default) ────────────────────────────────────────────
85
+ const pillItems = items.filter((item) => item.emphasis !== 'brand');
86
+ const brandItems = items.filter((item) => item.emphasis === 'brand');
45
87
  const renderSlot = (item, size) => {
46
88
  const label = typeof item.label === 'function' ? item.label() : item.label;
47
89
  const isActive = Boolean(item.to && activeTo && item.to === activeTo);
@@ -118,6 +118,14 @@ export interface AppShellMobileNavConfig {
118
118
  activeTo?: string;
119
119
  /** Render dock/sheet targets as router links (else plain `<a>`). */
120
120
  renderLink?: (args: MobileNavLinkArgs) => ReactNode;
121
+ /**
122
+ * Dock presentation:
123
+ * - `pill` (default) — a floating, centered pill of icon-only slots with any
124
+ * `emphasis: 'brand'` item as a standalone circle beside it.
125
+ * - `bar` — a flat, full-width bottom bar of evenly-spread slots, each with a
126
+ * larger icon + label and a tinted active state (mirrors the native app).
127
+ */
128
+ variant?: 'pill' | 'bar';
121
129
  }
122
130
  export interface ShellRenderContext {
123
131
  workspaceId: string;
@@ -1,2 +1,19 @@
1
1
  import type { ShellViewport } from './types.js';
2
+ /**
3
+ * The shell's viewport band, resolved SYNCHRONOUSLY on the first client
4
+ * render.
5
+ *
6
+ * The previous implementation seeded `useState('desktop')` and detected the
7
+ * real band in a post-mount effect. Because effects run after the first
8
+ * commit, phones mounted the entire desktop shell tree (ShellHeader,
9
+ * IconRail, ContextRail, RightPanel — plus whatever subscriptions consumers
10
+ * hang off them) and immediately tore it down for the mobile tree: throwaway
11
+ * work on the mobile critical path on every load.
12
+ *
13
+ * `useSyncExternalStore` reads `matchMedia` during the first client render,
14
+ * so client-rendered apps commit the correct tree once. Server rendering
15
+ * still returns 'desktop' via the server snapshot, and hydration stays
16
+ * mismatch-safe (React uses the server snapshot while hydrating, then
17
+ * re-reads — SSR consumers keep exactly the old behavior).
18
+ */
2
19
  export declare function useShellViewport(): ShellViewport;
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { useEffect, useState } from 'react';
2
+ import { useSyncExternalStore } from 'react';
3
3
  const BREAKPOINTS = {
4
4
  mobile: '(max-width: 767px)',
5
5
  tablet: '(min-width: 768px) and (max-width: 1023px)',
@@ -24,29 +24,61 @@ function detectViewport(matchMedia = getMatchMedia()) {
24
24
  }
25
25
  return 'desktop';
26
26
  }
27
+ // MediaQueryLists captured at subscribe time. The snapshot reads THESE
28
+ // instances (not fresh matchMedia() calls) so a change event and the state it
29
+ // reports come from the same objects — and each snapshot read is a property
30
+ // access instead of five matchMedia() calls. Refreshed on every subscribe so
31
+ // a swapped window.matchMedia (tests, polyfills) is picked up per mount.
32
+ let subscribedMqls = null;
33
+ function subscribeToViewport(onChange) {
34
+ const matchMedia = getMatchMedia();
35
+ if (!matchMedia) {
36
+ subscribedMqls = null;
37
+ return () => { };
38
+ }
39
+ const mqls = Object.entries(BREAKPOINTS).map(([band, query]) => [band, matchMedia(query)]);
40
+ subscribedMqls = mqls;
41
+ const cleanups = mqls.map(([, mql]) => {
42
+ mql.addEventListener('change', onChange);
43
+ return () => mql.removeEventListener('change', onChange);
44
+ });
45
+ return () => {
46
+ for (const cleanup of cleanups)
47
+ cleanup();
48
+ // Don't clobber a newer subscriber's registration.
49
+ if (subscribedMqls === mqls)
50
+ subscribedMqls = null;
51
+ };
52
+ }
53
+ function getClientViewport() {
54
+ if (subscribedMqls) {
55
+ for (const [band, mql] of subscribedMqls) {
56
+ if (mql.matches)
57
+ return band;
58
+ }
59
+ return 'desktop';
60
+ }
61
+ // First render happens before subscribe — detect directly.
62
+ return detectViewport();
63
+ }
64
+ const getServerViewport = () => 'desktop';
65
+ /**
66
+ * The shell's viewport band, resolved SYNCHRONOUSLY on the first client
67
+ * render.
68
+ *
69
+ * The previous implementation seeded `useState('desktop')` and detected the
70
+ * real band in a post-mount effect. Because effects run after the first
71
+ * commit, phones mounted the entire desktop shell tree (ShellHeader,
72
+ * IconRail, ContextRail, RightPanel — plus whatever subscriptions consumers
73
+ * hang off them) and immediately tore it down for the mobile tree: throwaway
74
+ * work on the mobile critical path on every load.
75
+ *
76
+ * `useSyncExternalStore` reads `matchMedia` during the first client render,
77
+ * so client-rendered apps commit the correct tree once. Server rendering
78
+ * still returns 'desktop' via the server snapshot, and hydration stays
79
+ * mismatch-safe (React uses the server snapshot while hydrating, then
80
+ * re-reads — SSR consumers keep exactly the old behavior).
81
+ */
27
82
  export function useShellViewport() {
28
- // Initial state is always 'desktop' so server-rendered output matches the
29
- // client's first paint — actual band resolves in the post-mount effect.
30
- const [viewport, setViewport] = useState('desktop');
31
- useEffect(() => {
32
- const matchMedia = getMatchMedia();
33
- setViewport(detectViewport(matchMedia));
34
- if (!matchMedia)
35
- return;
36
- const cleanups = Object.entries(BREAKPOINTS).map(([band, query]) => {
37
- const mql = matchMedia(query);
38
- const onChange = () => {
39
- /* v8 ignore next — false branch: listener fires but mql.matches is already false */
40
- if (mql.matches)
41
- setViewport(band);
42
- };
43
- mql.addEventListener('change', onChange);
44
- return () => mql.removeEventListener('change', onChange);
45
- });
46
- return () => {
47
- for (const cleanup of cleanups)
48
- cleanup();
49
- };
50
- }, []);
51
- return viewport;
83
+ return useSyncExternalStore(subscribeToViewport, getClientViewport, getServerViewport);
52
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medalsocial/meda",
3
- "version": "2.6.0",
3
+ "version": "2.7.1",
4
4
  "description": "Shared Meda UI shell and runtime package.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {