@medalsocial/meda 1.4.0 → 1.5.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.
@@ -1,10 +1,11 @@
1
1
  import type { ReactNode } from 'react';
2
- import type { AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig } from './types.js';
2
+ import type { AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig } from './types.js';
3
3
  export interface AppShellWorkspaceProps {
4
4
  iconRail?: AppShellIconRailConfig;
5
5
  contextRail?: AppShellContextRailConfig;
6
6
  rightPanel?: AppShellRightPanelConfig;
7
+ workspace?: AppShellWorkspaceConfig;
7
8
  globalActions?: ReactNode;
8
9
  children: ReactNode;
9
10
  }
10
- export declare function AppShellWorkspace({ iconRail, contextRail, rightPanel, globalActions, children, }: AppShellWorkspaceProps): import("react/jsx-runtime").JSX.Element;
11
+ export declare function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, globalActions, children, }: AppShellWorkspaceProps): import("react/jsx-runtime").JSX.Element;
@@ -12,7 +12,7 @@ import { ShellHeader } from './shell-header.js';
12
12
  import { ShellMain } from './shell-main.js';
13
13
  import { useShellViewport } from './use-shell-viewport.js';
14
14
  const EMPTY_PANEL_VIEWS = [];
15
- export function AppShellWorkspace({ iconRail, contextRail, rightPanel, globalActions, children, }) {
15
+ export function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, globalActions, children, }) {
16
16
  const viewport = useShellViewport();
17
17
  const isMobile = viewport === 'mobile';
18
18
  const staticPanelViews = rightPanel?.panelViews ?? EMPTY_PANEL_VIEWS;
@@ -37,7 +37,7 @@ export function AppShellWorkspace({ iconRail, contextRail, rightPanel, globalAct
37
37
  // rendered without an explicit-height ancestor (tests, direct imports). The
38
38
  // <AppShell> wrapper already enforces h-screen for the workspace variant, so
39
39
  // nested viewport-height divs collapse cleanly — no double-scroll.
40
- return (_jsxs("div", { className: "flex h-screen flex-col", children: [isMobile ? (_jsx(MobileHeader, { globalActions: globalActions })) : (_jsx(ShellHeader, { globalActions: globalActions })), _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 })), !isMobile && contextRail && (_jsx(ContextRail, { appId: contextRail.appId, module: contextRail.module, activeItemId: contextRail.activeItemId })), _jsx(ShellMain, { layout: "workspace", children: children }), !isMobile && resolvedRightPanel.panelViews.length > 0 && (_jsx(RightPanel, { panelViews: staticPanelViews, defaultView: rightPanel?.defaultView }))] }), isMobile && hasDrawerContent && _jsx(MobileBottomNav, { items: navItems }), isMobile && hasDrawerContent && (_jsx(MobileDrawers, { menuItems: mobileMenuItems, menuActiveId: iconRail?.activeId, menuRenderLink: iconRail?.renderLink, module: contextRail?.module, moduleAppId: contextRail?.appId, panelViews: resolvedRightPanel.panelViews, defaultView: resolvedRightPanel.defaultView }))] }));
40
+ return (_jsxs("div", { className: "flex h-screen flex-col", children: [isMobile ? (_jsx(MobileHeader, { globalActions: globalActions })) : (_jsx(ShellHeader, { globalActions: globalActions, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter })), _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 })), !isMobile && contextRail && (_jsx(ContextRail, { appId: contextRail.appId, module: contextRail.module, activeItemId: contextRail.activeItemId })), _jsx(ShellMain, { layout: "workspace", children: children }), !isMobile && resolvedRightPanel.panelViews.length > 0 && (_jsx(RightPanel, { panelViews: staticPanelViews, defaultView: rightPanel?.defaultView }))] }), isMobile && hasDrawerContent && _jsx(MobileBottomNav, { items: navItems }), isMobile && hasDrawerContent && (_jsx(MobileDrawers, { menuItems: mobileMenuItems, menuActiveId: iconRail?.activeId, menuRenderLink: iconRail?.renderLink, module: contextRail?.module, moduleAppId: contextRail?.appId, panelViews: resolvedRightPanel.panelViews, defaultView: resolvedRightPanel.defaultView }))] }));
41
41
  }
42
42
  function buildMobileNavItems(iconRail, contextRail, panelViews) {
43
43
  const items = [];
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
- import type { AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig } from './types.js';
2
+ import type { AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig } from './types.js';
3
3
  interface AppShellBaseProps {
4
4
  children: ReactNode;
5
5
  className?: string;
@@ -15,6 +15,12 @@ export type AppShellProps = AppShellBaseProps & ({
15
15
  iconRail?: AppShellIconRailConfig;
16
16
  contextRail?: AppShellContextRailConfig;
17
17
  rightPanel?: AppShellRightPanelConfig;
18
+ /**
19
+ * Configurable workspace dropdown — replaces the package-default
20
+ * "Manage workspaces / Settings / Profile / Sign out" entries when
21
+ * `menuItems` is provided. The theme toggle is preserved automatically.
22
+ */
23
+ workspace?: AppShellWorkspaceConfig;
18
24
  globalActions?: ReactNode;
19
25
  } | {
20
26
  variant: 'chat';
@@ -16,7 +16,7 @@ export function AppShell(props) {
16
16
  case 'auth':
17
17
  return wrapper(_jsx(AppShellAuth, { ...resolveAuthConfig(props), children: props.children }));
18
18
  case 'workspace':
19
- return wrapper(_jsx(AppShellWorkspace, { iconRail: props.iconRail, contextRail: props.contextRail, rightPanel: props.rightPanel, globalActions: props.globalActions, children: props.children }));
19
+ return wrapper(_jsx(AppShellWorkspace, { iconRail: props.iconRail, contextRail: props.contextRail, rightPanel: props.rightPanel, workspace: props.workspace, globalActions: props.globalActions, children: props.children }));
20
20
  case 'chat':
21
21
  return wrapper(_jsx(AppShellChat, { globalActions: props.globalActions, children: props.children }));
22
22
  }
@@ -26,5 +26,5 @@ export type { MedaShellProviderProps } from './shell-provider.js';
26
26
  export { MedaShellProvider, useMedaShell, useShellSelection } from './shell-provider.js';
27
27
  export { DefaultThemeProvider, ThemeToggle, useTheme } from './theme.js';
28
28
  export { NextThemesAdapter } from './theme-next-themes.js';
29
- export type { AppDefinition, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellVariant, CommandDefinition, ContextItem, ContextModule, MobileBottomNavItem, PanelMode, PanelView, ShellLinkRenderArgs, ShellMainLayout, ShellRenderContext, ShellViewport, ThemeAdapter, WorkspaceDefinition, } from './types.js';
29
+ export type { AppDefinition, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellVariant, AppShellWorkspaceConfig, CommandDefinition, ContextItem, ContextModule, MobileBottomNavItem, PanelMode, PanelView, ShellLinkRenderArgs, ShellMainLayout, ShellRenderContext, ShellViewport, ThemeAdapter, WorkspaceDefinition, WorkspaceMenuItem, } from './types.js';
30
30
  export { useShellViewport } from './use-shell-viewport.js';
@@ -1,12 +1,34 @@
1
- import type { ReactNode } from 'react';
1
+ import { type ReactNode } from 'react';
2
+ import type { WorkspaceMenuItem } from './types.js';
2
3
  export interface WorkspaceSwitcherProps {
4
+ /**
5
+ * Configurable dropdown items. When provided, REPLACES the default
6
+ * "Manage workspaces / Settings / Profile / Sign out" entries. The theme
7
+ * toggle is still inserted automatically by meda — between the items and
8
+ * the footer — so consumers don't have to reimplement theme cycling.
9
+ *
10
+ * When omitted, the package-default items render (1.x behavior).
11
+ */
12
+ menuItems?: WorkspaceMenuItem[];
13
+ /**
14
+ * Extra content rendered after all items and the theme toggle. Backwards-
15
+ * compatible alias for the legacy `workspaceMenuFooter` prop.
16
+ */
17
+ menuFooter?: ReactNode;
18
+ /** @deprecated Use `menuFooter` instead. */
3
19
  workspaceMenuFooter?: ReactNode;
4
20
  }
5
- export declare function WorkspaceSwitcher({ workspaceMenuFooter }?: WorkspaceSwitcherProps): import("react/jsx-runtime").JSX.Element;
21
+ export declare function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter, }?: WorkspaceSwitcherProps): import("react/jsx-runtime").JSX.Element;
6
22
  export declare function AppTabs(): import("react/jsx-runtime").JSX.Element;
7
23
  export declare function PanelToggle(): import("react/jsx-runtime").JSX.Element;
8
24
  export interface ShellHeaderProps {
9
25
  globalActions?: ReactNode;
10
26
  className?: string;
27
+ /**
28
+ * Forwarded to the internal `<WorkspaceSwitcher>`. See
29
+ * `WorkspaceSwitcherProps` for the full shape.
30
+ */
31
+ workspaceMenuItems?: WorkspaceMenuItem[];
32
+ workspaceMenuFooter?: ReactNode;
11
33
  }
12
- export declare function ShellHeader({ globalActions, className }?: ShellHeaderProps): import("react/jsx-runtime").JSX.Element | null;
34
+ export declare function ShellHeader({ globalActions, className, workspaceMenuItems, workspaceMenuFooter, }?: ShellHeaderProps): import("react/jsx-runtime").JSX.Element | null;
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { ChevronDown, Monitor, Moon, PanelRightClose, PanelRightOpen, Sun } from 'lucide-react';
4
+ import { createElement, Fragment, isValidElement } from 'react';
4
5
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '../components/ui/dropdown-menu.js';
5
6
  import { cn } from '../lib/utils.js';
6
7
  import { useMedaShell } from './shell-provider.js';
@@ -18,9 +19,43 @@ function ThemeToggleMenuItem() {
18
19
  const Icon = THEME_ICON[theme];
19
20
  return (_jsxs(DropdownMenuItem, { onClick: () => setTheme(NEXT_THEME[theme]), children: [_jsx(Icon, { size: 16, "aria-hidden": "true" }), THEME_LABEL[theme]] }));
20
21
  }
21
- export function WorkspaceSwitcher({ workspaceMenuFooter } = {}) {
22
+ function renderConfiguredIcon(icon) {
23
+ if (icon == null)
24
+ return null;
25
+ if (isValidElement(icon))
26
+ return icon;
27
+ if (typeof icon === 'function') {
28
+ return createElement(icon, { size: 16, 'aria-hidden': true });
29
+ }
30
+ // forwardRef/memo components are objects carrying a `$$typeof` symbol —
31
+ // treat them like Lucide components. Plain ReactNode objects (arrays,
32
+ // iterables, promises) have no `$$typeof` and are rendered as-is so they
33
+ // don't crash createElement with "Element type is invalid".
34
+ if (typeof icon === 'object' && icon !== null) {
35
+ const candidate = icon;
36
+ if (candidate.$$typeof != null) {
37
+ return createElement(icon, {
38
+ size: 16,
39
+ 'aria-hidden': true,
40
+ });
41
+ }
42
+ }
43
+ return icon;
44
+ }
45
+ function renderConfiguredItem(item) {
46
+ const handleSelect = () => item.onClick?.();
47
+ // Childless anchor — Base UI merges the DropdownMenuItem's children into the
48
+ // cloned render element. Passing children here would override the icon +
49
+ // label children below and configured icons would silently disappear.
50
+ // biome-ignore lint/a11y/useAnchorContent: children are injected at render time by Base UI's `render` prop
51
+ const renderLink = item.href != null ? _jsx("a", { href: item.href }) : undefined;
52
+ return (_jsxs(DropdownMenuItem, { render: renderLink, "data-variant": item.variant ?? 'default', className: item.variant === 'destructive' ? 'text-destructive' : undefined, onClick: handleSelect, children: [renderConfiguredIcon(item.icon), item.label] }));
53
+ }
54
+ export function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter, } = {}) {
22
55
  const { workspace, workspaces } = useMedaShell();
23
- return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", className: "flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-accent" }), children: [workspace.icon != null && (_jsx("span", { className: "shrink-0", "aria-hidden": "true", children: workspace.icon })), _jsx("span", { children: workspace.name }), _jsx(ChevronDown, { size: 14, "aria-hidden": "true" })] }), _jsxs(DropdownMenuContent, { className: "min-w-[200px]", children: [workspaces.length > 0 && (_jsxs(_Fragment, { children: [workspaces.map((ws) => (_jsxs(DropdownMenuItem, { children: [ws.icon != null && _jsx("span", { "aria-hidden": "true", children: ws.icon }), ws.name] }, ws.id))), _jsx(DropdownMenuSeparator, {})] })), _jsx(DropdownMenuItem, { children: "Manage workspaces" }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { children: "Settings" }), _jsx(DropdownMenuItem, { children: "Profile" }), _jsx(DropdownMenuSeparator, {}), _jsx(ThemeToggleMenuItem, {}), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { children: "Sign out" }), workspaceMenuFooter] })] }));
56
+ const resolvedFooter = menuFooter ?? workspaceMenuFooter;
57
+ const useConfiguredItems = Array.isArray(menuItems);
58
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", className: "flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-accent" }), children: [workspace.icon != null && (_jsx("span", { className: "shrink-0", "aria-hidden": "true", children: workspace.icon })), _jsx("span", { children: workspace.name }), _jsx(ChevronDown, { size: 14, "aria-hidden": "true" })] }), _jsxs(DropdownMenuContent, { className: "min-w-[200px]", children: [workspaces.length > 0 && (_jsxs(_Fragment, { children: [workspaces.map((ws) => (_jsxs(DropdownMenuItem, { children: [ws.icon != null && _jsx("span", { "aria-hidden": "true", children: ws.icon }), ws.name] }, ws.id))), _jsx(DropdownMenuSeparator, {})] })), useConfiguredItems ? (menuItems.map((item) => (_jsxs(Fragment, { children: [renderConfiguredItem(item), item.separatorAfter && _jsx(DropdownMenuSeparator, {})] }, item.id)))) : (_jsxs(_Fragment, { children: [_jsx(DropdownMenuItem, { children: "Manage workspaces" }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { children: "Settings" }), _jsx(DropdownMenuItem, { children: "Profile" })] })), _jsx(DropdownMenuSeparator, {}), _jsx(ThemeToggleMenuItem, {}), !useConfiguredItems && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { children: "Sign out" })] })), resolvedFooter] })] }));
24
59
  }
25
60
  // ---------------------------------------------------------------------------
26
61
  // AppTabs
@@ -50,9 +85,9 @@ export function PanelToggle() {
50
85
  };
51
86
  return (_jsx("button", { type: "button", "aria-label": isOpen ? 'Close right panel' : 'Open right panel', onClick: handleClick, className: cn('inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors', isOpen ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent'), children: isOpen ? (_jsx(PanelRightClose, { size: 18, "aria-hidden": "true" })) : (_jsx(PanelRightOpen, { size: 18, "aria-hidden": "true" })) }));
52
87
  }
53
- export function ShellHeader({ globalActions, className } = {}) {
88
+ export function ShellHeader({ globalActions, className, workspaceMenuItems, workspaceMenuFooter, } = {}) {
54
89
  const band = useShellViewport();
55
90
  if (band === 'mobile')
56
91
  return null;
57
- return (_jsxs("header", { className: cn('flex h-[var(--shell-header-height)] w-full items-center justify-between', 'border-b border-border bg-background px-3', className), children: [_jsxs("div", { className: "flex items-center", children: [_jsx(WorkspaceSwitcher, {}), _jsx(AppTabs, {})] }), _jsxs("div", { className: "flex items-center gap-2", children: [globalActions, _jsx(PanelToggle, {})] })] }));
92
+ return (_jsxs("header", { className: cn('flex h-[var(--shell-header-height)] w-full items-center justify-between', 'border-b border-border bg-background px-3', className), children: [_jsxs("div", { className: "flex items-center", children: [_jsx(WorkspaceSwitcher, { menuItems: workspaceMenuItems, menuFooter: workspaceMenuFooter }), _jsx(AppTabs, {})] }), _jsxs("div", { className: "flex items-center gap-2", children: [globalActions, _jsx(PanelToggle, {})] })] }));
58
93
  }
@@ -93,6 +93,44 @@ export interface AppShellRightPanelConfig {
93
93
  panelViews: PanelView[];
94
94
  defaultView?: string;
95
95
  }
96
+ /**
97
+ * A single configurable item in the WorkspaceSwitcher dropdown.
98
+ * Either `href` (renders as a link) or `onClick` (renders as a button) MUST
99
+ * be provided; supplying both makes the item a button that fires `onClick`
100
+ * AND navigates to `href` afterwards.
101
+ */
102
+ interface WorkspaceMenuItemBase {
103
+ id: string;
104
+ label: ReactNode;
105
+ icon?: LucideIcon | ReactNode;
106
+ /** Insert a `<DropdownMenuSeparator />` immediately after this item. */
107
+ separatorAfter?: boolean;
108
+ /** Visual intent — `destructive` paints with the destructive token. */
109
+ variant?: 'default' | 'destructive';
110
+ }
111
+ export type WorkspaceMenuItem = WorkspaceMenuItemBase & ({
112
+ href: string;
113
+ onClick?: () => void;
114
+ } | {
115
+ onClick: () => void;
116
+ href?: string;
117
+ });
118
+ /**
119
+ * Workspace dropdown configuration for `<AppShell variant="workspace">`.
120
+ *
121
+ * When `menuItems` is provided, it REPLACES the package-default menu
122
+ * ("Manage workspaces", "Settings", "Profile", "Sign out"). The theme toggle
123
+ * is still inserted automatically by meda — between the items and the footer
124
+ * — so consumers don't have to reimplement theme cycling.
125
+ *
126
+ * When `menuItems` is omitted, the package defaults render unchanged (the
127
+ * existing 1.x behavior). This is fully additive and non-breaking.
128
+ */
129
+ export interface AppShellWorkspaceConfig {
130
+ menuItems?: WorkspaceMenuItem[];
131
+ /** Extra slot rendered after all items and the theme toggle. */
132
+ menuFooter?: ReactNode;
133
+ }
96
134
  export interface AppShellAuthConfig {
97
135
  title: ReactNode;
98
136
  description?: ReactNode;
@@ -108,3 +146,4 @@ export interface AppShellAuthBranding {
108
146
  appName?: ReactNode;
109
147
  tagline?: ReactNode;
110
148
  }
149
+ export {};
@@ -1,7 +1,27 @@
1
1
  @import "./tokens.css";
2
2
 
3
+ /* Tell Tailwind v4 to scan meda's compiled component output so utilities
4
+ like `h-full`, `mt-auto`, `py-3.5`, and `bg-shell-rail` (used by IconRail,
5
+ AppShellWorkspace, etc.) are generated even when the consumer app doesn't
6
+ reference them itself. Without this, consumers see broken layout where
7
+ meda components rely on classes the app doesn't otherwise use. Path is
8
+ resolved relative to this CSS file at build time, so it points to the
9
+ sibling component output regardless of where the package is installed. */
10
+ @source "../**/*.js";
11
+
3
12
  @custom-variant dark (&:is(.dark *, [data-theme="dark"] *));
4
13
 
14
+ @layer base {
15
+ :root {
16
+ color-scheme: light;
17
+ }
18
+
19
+ .dark,
20
+ [data-theme="dark"] {
21
+ color-scheme: dark;
22
+ }
23
+ }
24
+
5
25
  @theme inline {
6
26
  /* Color primitives — exposed as Tailwind color scales */
7
27
 
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ // Read theme.css via Node's runtime APIs without pulling in @types/node.
4
+ // Declared locally so the package-level tsconfig (which doesn't include
5
+ // node typings) still typechecks cleanly under jsdom Vitest runs.
6
+ declare const require: (id: string) => unknown;
7
+ declare const __dirname: string;
8
+
9
+ const fs = require('node:fs') as { readFileSync(p: string, enc: string): string };
10
+ const path = require('node:path') as { join(...parts: string[]): string };
11
+ const themeCss = fs.readFileSync(path.join(__dirname, 'theme.css'), 'utf8');
12
+
13
+ describe('theme.css', () => {
14
+ // Tailwind v4 only generates utility classes for class names it sees in
15
+ // scanned files. Consumer apps don't reference every utility used by meda
16
+ // components (e.g. `h-full`, `mt-auto`, `py-3.5`, `bg-shell-rail` on
17
+ // IconRail), so without an @source directive pointing at meda's compiled
18
+ // dist files, those classes silently disappear and component layouts
19
+ // collapse — most visibly, the IconRail's utility items fail to pin to
20
+ // the bottom of the viewport because `mt-auto` never gets generated.
21
+ //
22
+ // The directive is resolved relative to this CSS file at build time. Since
23
+ // src/styles/theme.css is copied verbatim to dist/styles/theme.css by the
24
+ // build script, `../**/*.js` from the dist location scans every emitted
25
+ // component module — exactly the surface consumers import from.
26
+ it('declares an @source directive that scans meda component output', () => {
27
+ expect(themeCss).toMatch(/@source\s+["']\.\.\/\*\*\/\*\.js["']/);
28
+ });
29
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medalsocial/meda",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Shared Meda UI shell and runtime package.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {