@medalsocial/meda 2.7.0 → 2.7.2
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/dist/lib/utils.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ClassValue } from 'clsx';
|
|
2
2
|
export declare function cn(...inputs: ClassValue[]): string;
|
package/dist/lib/utils.js
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
|
-
import { clsx } from 'clsx';
|
|
2
1
|
import { twMerge } from 'tailwind-merge';
|
|
2
|
+
/**
|
|
3
|
+
* Flatten one clsx-style class value.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately inlined instead of calling `clsx`, so that `cn` — which nearly
|
|
6
|
+
* every component in this package calls — leaves no RUNTIME edge to the `clsx`
|
|
7
|
+
* module. `clsx` stays a dependency for its `ClassValue` type, which appears in
|
|
8
|
+
* this file's public signature; `import type` is erased at compile time.
|
|
9
|
+
*
|
|
10
|
+
* Why it is worth inlining twenty lines: `clsx` is also a dependency of
|
|
11
|
+
* `recharts`, so a bundler that groups the chart vendor graph into one chunk
|
|
12
|
+
* puts the single shared `clsx` module in THAT chunk. Every eager importer of
|
|
13
|
+
* `cn` then hard-depends on the whole chart bundle. Measured in the Medal web
|
|
14
|
+
* app (2026-09-06): the `/login` server closure reached a 392 kB
|
|
15
|
+
* `vendor-recharts` chunk through exactly one edge — the shell's `cn` — and
|
|
16
|
+
* removing this import dropped that closure from 4,758,902 to 4,357,355 bytes
|
|
17
|
+
* with the chart chunk no longer reachable at all.
|
|
18
|
+
*
|
|
19
|
+
* Behaviour is identical to `clsx`, pinned by the equivalence test in
|
|
20
|
+
* `test/unit/lib/utils.test.ts`.
|
|
21
|
+
*/
|
|
22
|
+
function toClassName(input) {
|
|
23
|
+
if (!input)
|
|
24
|
+
return '';
|
|
25
|
+
if (typeof input === 'string' || typeof input === 'number') {
|
|
26
|
+
return String(input);
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(input)) {
|
|
29
|
+
return input.map(toClassName).filter(Boolean).join(' ');
|
|
30
|
+
}
|
|
31
|
+
if (typeof input === 'object') {
|
|
32
|
+
let className = '';
|
|
33
|
+
for (const key in input) {
|
|
34
|
+
if (input[key]) {
|
|
35
|
+
className = className ? `${className} ${key}` : key;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return className;
|
|
39
|
+
}
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
3
42
|
export function cn(...inputs) {
|
|
4
|
-
return twMerge(
|
|
43
|
+
return twMerge(toClassName(inputs));
|
|
5
44
|
}
|
|
@@ -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
|
|
47
|
-
|
|
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
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
}
|