@medalsocial/meda 1.4.0 → 1.6.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.
- package/README.md +46 -0
- package/dist/recipes/next.js +33 -1
- package/dist/shell/app-shell-auth.js +1 -1
- package/dist/shell/app-shell-workspace.d.ts +9 -3
- package/dist/shell/app-shell-workspace.js +13 -11
- package/dist/shell/app-shell.d.ts +31 -1
- package/dist/shell/app-shell.js +1 -1
- package/dist/shell/index.d.ts +2 -1
- package/dist/shell/index.js +0 -1
- package/dist/shell/internal/mobile-drawers.d.ts +6 -2
- package/dist/shell/internal/mobile-drawers.js +53 -6
- package/dist/shell/shell-header.d.ts +34 -4
- package/dist/shell/shell-header.js +63 -16
- package/dist/shell/shell-provider.js +2 -3
- package/dist/shell/theme-next-themes.d.ts +1 -1
- package/dist/shell/theme-next-themes.js +107 -16
- package/dist/shell/types.d.ts +56 -1
- package/dist/styles/theme.css +27 -0
- package/dist/styles/theme.test.ts +41 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,28 @@ Peer deps: `react >= 19`, `react-dom >= 19`, and `lucide-react`.
|
|
|
17
17
|
Meda ships a `styles.css` with its design tokens. Import it once in your entry stylesheet or entry script:
|
|
18
18
|
|
|
19
19
|
```css
|
|
20
|
+
@import 'tailwindcss';
|
|
21
|
+
@import '@medalsocial/meda/styles.css';
|
|
22
|
+
|
|
23
|
+
:root {
|
|
24
|
+
/* Consumer overrides go after meda so equal-specificity tokens win by source order. */
|
|
25
|
+
--color-brand-500: oklch(0.62 0.18 245);
|
|
26
|
+
--auth-gradient-primary: var(--color-brand-500);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.dark {
|
|
30
|
+
--color-brand-500: oklch(0.72 0.16 245);
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Avoid placing token overrides before the Meda import; `tokens.css` defines the package defaults and later declarations are what override them:
|
|
35
|
+
|
|
36
|
+
```css
|
|
37
|
+
/* Wrong: meda's imported defaults overwrite this block. */
|
|
38
|
+
:root {
|
|
39
|
+
--color-brand-500: oklch(0.62 0.18 245);
|
|
40
|
+
}
|
|
41
|
+
|
|
20
42
|
@import '@medalsocial/meda/styles.css';
|
|
21
43
|
```
|
|
22
44
|
|
|
@@ -58,11 +80,35 @@ import Link from 'next/link';
|
|
|
58
80
|
mainItems,
|
|
59
81
|
renderLink: ({ item, linkProps }) => <Link {...linkProps} href={item.to} prefetch />,
|
|
60
82
|
}}
|
|
83
|
+
appTabs={{
|
|
84
|
+
renderLink: ({ app, linkProps }) =>
|
|
85
|
+
app.to ? <Link {...linkProps} href={app.to} prefetch /> : <a {...linkProps} />,
|
|
86
|
+
}}
|
|
61
87
|
>
|
|
62
88
|
{children}
|
|
63
89
|
</AppShell>;
|
|
64
90
|
```
|
|
65
91
|
|
|
92
|
+
Workspace shells also expose chrome-level composition slots:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
<AppShell
|
|
96
|
+
variant="workspace"
|
|
97
|
+
headerCenter={<SectionTabs />}
|
|
98
|
+
banners={<SystemHealthBanner />}
|
|
99
|
+
mainLayout="fullbleed"
|
|
100
|
+
mainClassName="marketing-main"
|
|
101
|
+
workspace={{
|
|
102
|
+
menuItems: [{ id: 'settings', label: 'Settings', href: '/settings' }],
|
|
103
|
+
menuFooter: <AccountSwitcher />,
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{children}
|
|
107
|
+
</AppShell>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`workspace.menuItems`, `workspace.menuFooter`, and the theme toggle are available from the mobile Menu drawer. Use `mainLayout`/`mainClassName` when a workspace shell needs the same mobile chrome but a custom main scroll region, such as a full-bleed marketing page. `useCommands()` works from workspace descendants without manually mounting `CommandPalette`; lower-level primitive compositions can still mount `CommandPalette` directly.
|
|
111
|
+
|
|
66
112
|
For app-scoped brand tokens:
|
|
67
113
|
|
|
68
114
|
```ts
|
package/dist/recipes/next.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const nextAppShellRecipe = {
|
|
2
2
|
name: 'meda-next-app-shell',
|
|
3
3
|
title: 'Meda Next AppShell',
|
|
4
|
-
description: 'Copyable Next.js App Router shell adapter with next/link routing, route-owned panel views, and auth controls.',
|
|
4
|
+
description: 'Copyable Next.js App Router shell adapter with next/link routing, route-owned panel views, mobile workspace menus, full-bleed main layout hooks, and auth controls.',
|
|
5
5
|
dependencies: ['@medalsocial/meda', 'lucide-react'],
|
|
6
6
|
peerDependencies: ['next', 'react', 'react-dom'],
|
|
7
7
|
cssVars: ['@medalsocial/meda/styles.css'],
|
|
@@ -25,9 +25,12 @@ import {
|
|
|
25
25
|
MedaShellProvider,
|
|
26
26
|
PanelViewsProvider,
|
|
27
27
|
type AppDefinition,
|
|
28
|
+
type AppShellAppTabsConfig,
|
|
28
29
|
type IconRailItem,
|
|
29
30
|
type PanelView,
|
|
31
|
+
type ShellMainLayout,
|
|
30
32
|
type WorkspaceDefinition,
|
|
33
|
+
type WorkspaceMenuItem,
|
|
31
34
|
} from '@medalsocial/meda/shell'
|
|
32
35
|
|
|
33
36
|
export function MedaNextWorkspaceShell({
|
|
@@ -37,6 +40,13 @@ export function MedaNextWorkspaceShell({
|
|
|
37
40
|
activeIconId,
|
|
38
41
|
panelViews = [],
|
|
39
42
|
defaultPanelView,
|
|
43
|
+
appTabs,
|
|
44
|
+
headerCenter,
|
|
45
|
+
banners,
|
|
46
|
+
mainLayout,
|
|
47
|
+
mainClassName,
|
|
48
|
+
workspaceMenuItems,
|
|
49
|
+
workspaceMenuFooter,
|
|
40
50
|
children,
|
|
41
51
|
}: {
|
|
42
52
|
workspace: WorkspaceDefinition
|
|
@@ -45,6 +55,13 @@ export function MedaNextWorkspaceShell({
|
|
|
45
55
|
activeIconId?: string
|
|
46
56
|
panelViews?: PanelView[]
|
|
47
57
|
defaultPanelView?: string
|
|
58
|
+
appTabs?: AppShellAppTabsConfig
|
|
59
|
+
headerCenter?: ReactNode
|
|
60
|
+
banners?: ReactNode
|
|
61
|
+
mainLayout?: ShellMainLayout
|
|
62
|
+
mainClassName?: string
|
|
63
|
+
workspaceMenuItems?: WorkspaceMenuItem[]
|
|
64
|
+
workspaceMenuFooter?: ReactNode
|
|
48
65
|
children: ReactNode
|
|
49
66
|
}) {
|
|
50
67
|
return (
|
|
@@ -58,6 +75,17 @@ export function MedaNextWorkspaceShell({
|
|
|
58
75
|
<Link {...linkProps} href={item.to} prefetch />
|
|
59
76
|
),
|
|
60
77
|
}}
|
|
78
|
+
appTabs={
|
|
79
|
+
appTabs ?? {
|
|
80
|
+
renderLink: ({ app, linkProps }) =>
|
|
81
|
+
app.to ? <Link {...linkProps} href={app.to} prefetch /> : <a {...linkProps} />,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
headerCenter={headerCenter}
|
|
85
|
+
banners={banners}
|
|
86
|
+
mainLayout={mainLayout}
|
|
87
|
+
mainClassName={mainClassName}
|
|
88
|
+
workspace={{ menuItems: workspaceMenuItems, menuFooter: workspaceMenuFooter }}
|
|
61
89
|
rightPanel={{ panelViews, defaultView: defaultPanelView }}
|
|
62
90
|
>
|
|
63
91
|
<PanelViewsProvider views={panelViews} defaultView={defaultPanelView}>
|
|
@@ -114,6 +142,7 @@ export function MedaNextAuthShell({
|
|
|
114
142
|
accessibility: [
|
|
115
143
|
'Every drawer and panel keeps its accessible name from AppShell and RightPanel.',
|
|
116
144
|
'Custom link renderers must forward all linkProps to preserve aria-current, labels, handlers, and className.',
|
|
145
|
+
'Workspace menu items render in desktop and mobile chrome, so critical actions stay reachable across viewports.',
|
|
117
146
|
'Auth provider buttons keep the visible provider affordance separate from the accessible button name.',
|
|
118
147
|
'Route-owned panel views should expose headings inside their rendered panel content.',
|
|
119
148
|
'Reduced-motion behavior remains delegated to Meda shell motion tokens.',
|
|
@@ -121,6 +150,9 @@ export function MedaNextAuthShell({
|
|
|
121
150
|
composition: [
|
|
122
151
|
'MedaShellProvider owns workspace and app context for the copied shell adapter.',
|
|
123
152
|
'AppShell receives route-owned rightPanel views on first render to avoid delayed panel UI.',
|
|
153
|
+
'AppShellWorkspace mounts CommandPalette internally; route children can call useCommands without an extra shell-level mount.',
|
|
154
|
+
'headerCenter and banners keep route-level chrome in the shell band instead of inside the main pane.',
|
|
155
|
+
'mainLayout and mainClassName let full-bleed routes reuse AppShell mobile chrome without dropping down to primitives.',
|
|
124
156
|
'PanelViewsProvider wraps children with the same panelViews and defaultPanelView for nested route registrations.',
|
|
125
157
|
'renderLink composes Next Link by forwarding Meda linkProps before setting framework-specific props.',
|
|
126
158
|
],
|
|
@@ -7,7 +7,7 @@ function DefaultBrandMark() {
|
|
|
7
7
|
}
|
|
8
8
|
export function AppShellAuth({ children, title, description, brandName = 'Meda', brandMark, eyebrow, preview, actions, }) {
|
|
9
9
|
const resolvedBrandMark = brandMark ?? _jsx(DefaultBrandMark, {});
|
|
10
|
-
return (_jsxs("section", { "data-testid": "app-shell-auth", className: "grid min-h-screen overflow-hidden bg-background text-foreground lg:grid-cols-[minmax(0,1.08fr)_minmax(420px,0.92fr)]", children: [_jsxs("div", { className: "relative flex min-h-[24rem] flex-col overflow-hidden bg-[radial-gradient(circle_at_24%_18%,var(--
|
|
10
|
+
return (_jsxs("section", { "data-testid": "app-shell-auth", className: "grid min-h-screen overflow-hidden bg-background text-foreground lg:grid-cols-[minmax(0,1.08fr)_minmax(420px,0.92fr)]", children: [_jsxs("div", { "data-meda-auth-marketing-panel": "", className: "relative flex min-h-[24rem] flex-col overflow-hidden bg-[radial-gradient(circle_at_24%_18%,var(--auth-gradient-primary)_0,transparent_25%),radial-gradient(circle_at_84%_24%,var(--auth-gradient-secondary)_0,transparent_28%),linear-gradient(135deg,var(--auth-gradient-base),var(--auth-gradient-secondary)_42%,var(--auth-gradient-primary))] px-6 py-6 text-white sm:min-h-[30rem] sm:px-8 lg:min-h-screen lg:px-10 lg:py-8", children: [_jsx("div", { className: "pointer-events-none absolute inset-0 opacity-[0.07]", style: {
|
|
11
11
|
backgroundImage: "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")",
|
|
12
12
|
} }), _jsxs("div", { className: "relative z-10 flex items-center gap-3", children: [resolvedBrandMark, _jsx("span", { className: "text-lg font-semibold tracking-normal", children: brandName })] }), _jsxs("div", { className: "relative z-10 flex flex-1 flex-col justify-center gap-8 py-10 lg:py-12", children: [_jsxs("div", { className: "max-w-2xl", children: [eyebrow && (_jsx("p", { className: "mb-4 text-sm font-semibold uppercase tracking-[0.12em] text-white/70", children: eyebrow })), _jsx("h1", { className: "text-4xl font-bold leading-tight tracking-normal text-white sm:text-5xl", children: title }), description && (_jsx("p", { className: "mt-4 max-w-xl text-base leading-7 text-white/72 sm:text-lg", children: description }))] }), _jsx("div", { className: "w-full max-w-3xl", children: preview ?? _jsx(DefaultAuthPreview, {}) })] })] }), _jsxs("div", { className: "relative flex min-h-screen flex-col bg-background px-6 py-6 sm:px-8 lg:px-12", children: [actions && _jsx("div", { className: "flex justify-end", children: actions }), _jsxs("div", { className: "mx-auto flex w-full max-w-md flex-1 flex-col justify-center py-10", children: [_jsx("div", { className: "mb-8 flex justify-center lg:hidden", children: resolvedBrandMark }), children] })] })] }));
|
|
13
13
|
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type { AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig } from './types.js';
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { AppShellAppTabsConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig, ShellMainLayout } from './types.js';
|
|
3
3
|
export interface AppShellWorkspaceProps {
|
|
4
4
|
iconRail?: AppShellIconRailConfig;
|
|
5
5
|
contextRail?: AppShellContextRailConfig;
|
|
6
6
|
rightPanel?: AppShellRightPanelConfig;
|
|
7
|
+
workspace?: AppShellWorkspaceConfig;
|
|
8
|
+
appTabs?: AppShellAppTabsConfig;
|
|
7
9
|
globalActions?: ReactNode;
|
|
10
|
+
headerCenter?: ReactNode;
|
|
11
|
+
banners?: ReactNode;
|
|
12
|
+
mainLayout?: ShellMainLayout;
|
|
13
|
+
mainClassName?: string;
|
|
8
14
|
children: ReactNode;
|
|
9
15
|
}
|
|
10
|
-
export declare function AppShellWorkspace({ iconRail, contextRail, rightPanel, globalActions, children, }: AppShellWorkspaceProps): import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
export declare function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, appTabs, globalActions, headerCenter, banners, mainLayout, mainClassName, children, }: AppShellWorkspaceProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { LayoutGrid, Menu, PanelTop, Sparkles } from 'lucide-react';
|
|
4
|
+
import { useContext } from 'react';
|
|
5
|
+
import { CommandPalette, CommandRegistryContext } from './command-palette.js';
|
|
4
6
|
import { ContextRail } from './context-rail.js';
|
|
5
7
|
import { IconRail } from './icon-rail.js';
|
|
6
8
|
import { MobileBottomNav } from './internal/mobile-bottom-nav.js';
|
|
@@ -12,16 +14,15 @@ import { ShellHeader } from './shell-header.js';
|
|
|
12
14
|
import { ShellMain } from './shell-main.js';
|
|
13
15
|
import { useShellViewport } from './use-shell-viewport.js';
|
|
14
16
|
const EMPTY_PANEL_VIEWS = [];
|
|
15
|
-
export function AppShellWorkspace({ iconRail, contextRail, rightPanel, globalActions, children, }) {
|
|
17
|
+
export function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, appTabs, globalActions, headerCenter, banners, mainLayout, mainClassName, children, }) {
|
|
16
18
|
const viewport = useShellViewport();
|
|
17
19
|
const isMobile = viewport === 'mobile';
|
|
18
20
|
const staticPanelViews = rightPanel?.panelViews ?? EMPTY_PANEL_VIEWS;
|
|
19
21
|
const resolvedRightPanel = useResolvedPanelViews(staticPanelViews, rightPanel?.defaultView);
|
|
20
22
|
// Derive the bottom-nav items from the variant config so each button maps
|
|
21
|
-
// to a drawer that actually has content.
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
const navItems = buildMobileNavItems(iconRail, contextRail, resolvedRightPanel.panelViews);
|
|
23
|
+
// to a drawer that actually has content. Menu is always available because
|
|
24
|
+
// the mobile drawer now carries workspace actions and the theme toggle.
|
|
25
|
+
const navItems = buildMobileNavItems(contextRail, resolvedRightPanel.panelViews);
|
|
25
26
|
const hasDrawerContent = navItems.length > 0;
|
|
26
27
|
// Mobile menu drawer needs both main and utility items — desktop IconRail
|
|
27
28
|
// shows both, so dropping utilityItems here would orphan items like Help/
|
|
@@ -37,13 +38,14 @@ export function AppShellWorkspace({ iconRail, contextRail, rightPanel, globalAct
|
|
|
37
38
|
// rendered without an explicit-height ancestor (tests, direct imports). The
|
|
38
39
|
// <AppShell> wrapper already enforces h-screen for the workspace variant, so
|
|
39
40
|
// nested viewport-height divs collapse cleanly — no double-scroll.
|
|
40
|
-
|
|
41
|
+
const commandRegistry = useContext(CommandRegistryContext);
|
|
42
|
+
const shell = (_jsxs("div", { className: "flex h-screen flex-col", children: [isMobile ? (_jsx(MobileHeader, { globalActions: globalActions })) : (_jsx(ShellHeader, { globalActions: globalActions, headerCenter: headerCenter, appTabsRenderLink: appTabs?.renderLink, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter })), 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 })), !isMobile && contextRail && (_jsx(ContextRail, { appId: contextRail.appId, module: contextRail.module, activeItemId: contextRail.activeItemId })), _jsx(ShellMain, { layout: mainLayout ?? 'workspace', className: mainClassName, 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, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter, module: contextRail?.module, moduleAppId: contextRail?.appId, panelViews: resolvedRightPanel.panelViews, defaultView: resolvedRightPanel.defaultView }))] }));
|
|
43
|
+
return commandRegistry ? shell : _jsx(CommandPalette, { children: shell });
|
|
41
44
|
}
|
|
42
|
-
function buildMobileNavItems(
|
|
43
|
-
const items = [
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
45
|
+
function buildMobileNavItems(contextRail, panelViews) {
|
|
46
|
+
const items = [
|
|
47
|
+
{ id: 'menu', label: 'Menu', icon: Menu, opens: 'menu-drawer' },
|
|
48
|
+
];
|
|
47
49
|
if (contextRail?.module &&
|
|
48
50
|
((contextRail.module.items ?? []).length > 0 || Boolean(contextRail.module.render))) {
|
|
49
51
|
items.push({ id: 'module', label: 'Module', icon: LayoutGrid, opens: 'module-drawer' });
|
|
@@ -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 { AppShellAppTabsConfig, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig, ShellMainLayout } from './types.js';
|
|
3
3
|
interface AppShellBaseProps {
|
|
4
4
|
children: ReactNode;
|
|
5
5
|
className?: string;
|
|
@@ -15,7 +15,37 @@ 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;
|
|
24
|
+
/**
|
|
25
|
+
* Optional application-tab rendering config, used for router-specific
|
|
26
|
+
* link integration.
|
|
27
|
+
*/
|
|
28
|
+
appTabs?: AppShellAppTabsConfig;
|
|
18
29
|
globalActions?: ReactNode;
|
|
30
|
+
/**
|
|
31
|
+
* Optional center-region header content. Replaces the default
|
|
32
|
+
* application tabs when provided.
|
|
33
|
+
*/
|
|
34
|
+
headerCenter?: ReactNode;
|
|
35
|
+
/**
|
|
36
|
+
* Optional chrome-level content rendered below the header and above
|
|
37
|
+
* the workspace rail row.
|
|
38
|
+
*/
|
|
39
|
+
banners?: ReactNode;
|
|
40
|
+
/**
|
|
41
|
+
* Layout passed through to the workspace shell's main scroll region.
|
|
42
|
+
* Defaults to `workspace`.
|
|
43
|
+
*/
|
|
44
|
+
mainLayout?: ShellMainLayout;
|
|
45
|
+
/**
|
|
46
|
+
* Optional className for the workspace shell's main scroll region.
|
|
47
|
+
*/
|
|
48
|
+
mainClassName?: string;
|
|
19
49
|
} | {
|
|
20
50
|
variant: 'chat';
|
|
21
51
|
globalActions?: ReactNode;
|
package/dist/shell/app-shell.js
CHANGED
|
@@ -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, appTabs: props.appTabs, globalActions: props.globalActions, headerCenter: props.headerCenter, banners: props.banners, mainLayout: props.mainLayout, mainClassName: props.mainClassName, children: props.children }));
|
|
20
20
|
case 'chat':
|
|
21
21
|
return wrapper(_jsx(AppShellChat, { globalActions: props.globalActions, children: props.children }));
|
|
22
22
|
}
|
package/dist/shell/index.d.ts
CHANGED
|
@@ -20,11 +20,12 @@ export type { RailDropZonesProps } from './rail-drop-zones.js';
|
|
|
20
20
|
export { RailDropZones } from './rail-drop-zones.js';
|
|
21
21
|
export { ResizableHandle, ResizableShell, ResizableShellPanel } from './resizable-shell.js';
|
|
22
22
|
export { RightPanel } from './right-panel.js';
|
|
23
|
+
export type { AppTabsProps } from './shell-header.js';
|
|
23
24
|
export { AppTabs, PanelToggle, ShellHeader, WorkspaceSwitcher } from './shell-header.js';
|
|
24
25
|
export { ShellMain } from './shell-main.js';
|
|
25
26
|
export type { MedaShellProviderProps } from './shell-provider.js';
|
|
26
27
|
export { MedaShellProvider, useMedaShell, useShellSelection } from './shell-provider.js';
|
|
27
28
|
export { DefaultThemeProvider, ThemeToggle, useTheme } from './theme.js';
|
|
28
29
|
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';
|
|
30
|
+
export type { AppDefinition, AppShellAppTabsConfig, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellVariant, AppShellWorkspaceConfig, AppTabRenderLinkArgs, CommandDefinition, ContextItem, ContextModule, MobileBottomNavItem, PanelMode, PanelView, ShellLinkRenderArgs, ShellMainLayout, ShellRenderContext, ShellViewport, ThemeAdapter, WorkspaceDefinition, WorkspaceMenuItem, } from './types.js';
|
|
30
31
|
export { useShellViewport } from './use-shell-viewport.js';
|
package/dist/shell/index.js
CHANGED
|
@@ -24,7 +24,6 @@ export { RailDropSlot } from './rail-drop-slot.js';
|
|
|
24
24
|
export { RailDropZones } from './rail-drop-zones.js';
|
|
25
25
|
export { ResizableHandle, ResizableShell, ResizableShellPanel } from './resizable-shell.js';
|
|
26
26
|
export { RightPanel } from './right-panel.js';
|
|
27
|
-
// Header (and its individual children for advanced composition)
|
|
28
27
|
export { AppTabs, PanelToggle, ShellHeader, WorkspaceSwitcher } from './shell-header.js';
|
|
29
28
|
export { ShellMain } from './shell-main.js';
|
|
30
29
|
// Provider + hooks
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import type { IconRailItem, IconRailProps } from '../icon-rail.js';
|
|
3
|
-
import type { ContextModule, PanelView } from '../types.js';
|
|
3
|
+
import type { ContextModule, PanelView, WorkspaceMenuItem } from '../types.js';
|
|
4
4
|
export interface MobileDrawersProps {
|
|
5
5
|
/** Menu drawer source (icon-rail items). */
|
|
6
6
|
menuItems?: IconRailItem[];
|
|
@@ -8,6 +8,10 @@ export interface MobileDrawersProps {
|
|
|
8
8
|
menuActiveId?: string;
|
|
9
9
|
/** Custom menu link renderer, sourced from icon rail config. */
|
|
10
10
|
menuRenderLink?: IconRailProps['renderLink'];
|
|
11
|
+
/** Workspace-level menu items rendered after icon rail links on mobile. */
|
|
12
|
+
workspaceMenuItems?: WorkspaceMenuItem[];
|
|
13
|
+
/** Workspace-level footer rendered after workspace items and the theme toggle. */
|
|
14
|
+
workspaceMenuFooter?: ReactNode;
|
|
11
15
|
/** Module drawer source (current app's context-rail module). */
|
|
12
16
|
module?: ContextModule;
|
|
13
17
|
/** App id used when rendering module custom content. */
|
|
@@ -29,4 +33,4 @@ export interface MobileDrawersProps {
|
|
|
29
33
|
* any custom-content drawers. Mount once near the AppShell root; drawers
|
|
30
34
|
* open/close via `ctx.mobileDrawer.open` provider state.
|
|
31
35
|
*/
|
|
32
|
-
export declare function MobileDrawers({ menuItems, menuActiveId, menuRenderLink, module, moduleAppId, panelViews, defaultView, customContent, }: MobileDrawersProps): import("react/jsx-runtime").JSX.Element;
|
|
36
|
+
export declare function MobileDrawers({ menuItems, menuActiveId, menuRenderLink, workspaceMenuItems, workspaceMenuFooter, module, moduleAppId, panelViews, defaultView, customContent, }: MobileDrawersProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
3
|
+
import { Monitor, Moon, Sun } from 'lucide-react';
|
|
4
|
+
import { cloneElement, createElement, Fragment, isValidElement, useEffect, } from 'react';
|
|
4
5
|
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, } from '../../components/ui/drawer.js';
|
|
5
6
|
import { cn } from '../../lib/utils.js';
|
|
6
7
|
import { useMedaShell } from '../shell-provider.js';
|
|
8
|
+
import { useTheme } from '../theme.js';
|
|
7
9
|
/**
|
|
8
10
|
* Renders all four mobile drawer slots (Menu / Module / Panels / AI) plus
|
|
9
11
|
* any custom-content drawers. Mount once near the AppShell root; drawers
|
|
10
12
|
* open/close via `ctx.mobileDrawer.open` provider state.
|
|
11
13
|
*/
|
|
12
|
-
export function MobileDrawers({ menuItems = [], menuActiveId, menuRenderLink, module, moduleAppId, panelViews = [], defaultView, customContent = {}, }) {
|
|
14
|
+
export function MobileDrawers({ menuItems = [], menuActiveId, menuRenderLink, workspaceMenuItems, workspaceMenuFooter, module, moduleAppId, panelViews = [], defaultView, customContent = {}, }) {
|
|
13
15
|
const ctx = useMedaShell();
|
|
14
16
|
const open = ctx.mobileDrawer.open;
|
|
15
17
|
const setOpen = ctx.mobileDrawer.setOpen;
|
|
@@ -22,13 +24,15 @@ export function MobileDrawers({ menuItems = [], menuActiveId, menuRenderLink, mo
|
|
|
22
24
|
workspaceId: ctx.workspace.id,
|
|
23
25
|
appId: moduleAppId ?? ctx.activeAppId,
|
|
24
26
|
};
|
|
25
|
-
return (_jsxs(_Fragment, { children: [_jsx(MenuDrawer, { open: open === 'menu-drawer', onClose: close, items: menuItems, activeId: menuActiveId, renderLink: menuRenderLink }), _jsx(ModuleDrawer, { open: open === 'module-drawer', onClose: close, module: module, renderCtx: moduleRenderCtx }), _jsx(PanelsDrawer, { open: open === 'panels-drawer', onClose: close, panelViews: panelViews, defaultView: defaultView, renderCtx: renderCtx }), _jsx(AiDrawer, { open: open === 'ai-drawer', onClose: close, panelViews: panelViews, renderCtx: renderCtx }), Object.entries(customContent).map(([id, renderFn]) => (_jsx(Drawer, { open: open === id, onOpenChange: (o) => !o && close(), direction: "bottom", children: _jsx(DrawerContent, { children: renderFn(close) }) }, id)))] }));
|
|
27
|
+
return (_jsxs(_Fragment, { children: [_jsx(MenuDrawer, { open: open === 'menu-drawer', onClose: close, items: menuItems, activeId: menuActiveId, renderLink: menuRenderLink, workspaceItems: workspaceMenuItems, workspaceFooter: workspaceMenuFooter }), _jsx(ModuleDrawer, { open: open === 'module-drawer', onClose: close, module: module, renderCtx: moduleRenderCtx }), _jsx(PanelsDrawer, { open: open === 'panels-drawer', onClose: close, panelViews: panelViews, defaultView: defaultView, renderCtx: renderCtx }), _jsx(AiDrawer, { open: open === 'ai-drawer', onClose: close, panelViews: panelViews, renderCtx: renderCtx }), Object.entries(customContent).map(([id, renderFn]) => (_jsx(Drawer, { open: open === id, onOpenChange: (o) => !o && close(), direction: "bottom", children: _jsx(DrawerContent, { children: renderFn(close) }) }, id)))] }));
|
|
26
28
|
}
|
|
27
29
|
// ---------------------------------------------------------------------------
|
|
28
30
|
// Internal sub-drawers
|
|
29
31
|
// ---------------------------------------------------------------------------
|
|
30
|
-
function MenuDrawer({ open, onClose, items, activeId, renderLink, }) {
|
|
31
|
-
|
|
32
|
+
function MenuDrawer({ open, onClose, items, activeId, renderLink, workspaceItems, workspaceFooter, }) {
|
|
33
|
+
const hasIconItems = items.length > 0;
|
|
34
|
+
const hasWorkspaceItems = Array.isArray(workspaceItems) && workspaceItems.length > 0;
|
|
35
|
+
return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "left", children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: "Menu" }), _jsx(DrawerDescription, { className: "sr-only", children: "Switch between primary app areas." })] }), _jsxs("div", { className: "flex flex-col gap-2 p-2", children: [hasIconItems && (_jsx("nav", { "aria-label": "Primary navigation", className: "flex flex-col gap-0.5", children: items.map((item) => (_jsx(MenuDrawerItem, { item: item, isActive: item.id === activeId, onClose: onClose, renderLink: renderLink }, item.id))) })), (hasIconItems || hasWorkspaceItems) && _jsx("div", { className: "h-px bg-border" }), _jsxs("nav", { "aria-label": "Workspace menu", className: "flex flex-col gap-0.5", children: [workspaceItems?.map((item) => (_jsxs(Fragment, { children: [_jsx(WorkspaceMenuDrawerItem, { item: item, onClose: onClose }), item.separatorAfter && _jsx("div", { className: "my-1 h-px bg-border" })] }, item.id))), _jsx(MobileThemeMenuItem, { onClose: onClose })] }), workspaceFooter] })] }) }));
|
|
32
36
|
}
|
|
33
37
|
const menuItemClassName = 'flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground';
|
|
34
38
|
function MenuDrawerItem({ item, isActive, onClose, renderLink, }) {
|
|
@@ -63,6 +67,49 @@ function closeAfterLinkClick(link, onClose) {
|
|
|
63
67
|
},
|
|
64
68
|
});
|
|
65
69
|
}
|
|
70
|
+
function renderWorkspaceIcon(icon) {
|
|
71
|
+
if (icon == null)
|
|
72
|
+
return null;
|
|
73
|
+
if (isValidElement(icon))
|
|
74
|
+
return icon;
|
|
75
|
+
if (typeof icon === 'function') {
|
|
76
|
+
return createElement(icon, { size: 18, 'aria-hidden': true });
|
|
77
|
+
}
|
|
78
|
+
if (typeof icon === 'object') {
|
|
79
|
+
const candidate = icon;
|
|
80
|
+
if (candidate.$$typeof != null) {
|
|
81
|
+
return createElement(icon, { size: 18, 'aria-hidden': true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return icon;
|
|
85
|
+
}
|
|
86
|
+
function WorkspaceMenuDrawerItem({ item, onClose, }) {
|
|
87
|
+
const children = (_jsxs(_Fragment, { children: [renderWorkspaceIcon(item.icon), _jsx("span", { children: item.label })] }));
|
|
88
|
+
const className = cn(menuItemClassName, item.variant === 'destructive' && 'text-destructive hover:text-destructive');
|
|
89
|
+
const handleClick = () => {
|
|
90
|
+
item.onClick?.();
|
|
91
|
+
onClose();
|
|
92
|
+
};
|
|
93
|
+
if (item.href != null) {
|
|
94
|
+
return closeAfterLinkClick(_jsx("a", { href: item.href, className: className, "data-variant": item.variant ?? 'default', onClick: () => item.onClick?.(), children: children }), onClose);
|
|
95
|
+
}
|
|
96
|
+
return (_jsx("button", { type: "button", "data-variant": item.variant ?? 'default', className: className, onClick: handleClick, children: children }));
|
|
97
|
+
}
|
|
98
|
+
const NEXT_THEME = { light: 'dark', dark: 'system', system: 'light' };
|
|
99
|
+
const THEME_ICON = { light: Sun, dark: Moon, system: Monitor };
|
|
100
|
+
const THEME_LABEL = {
|
|
101
|
+
light: 'Switch to dark theme',
|
|
102
|
+
dark: 'Switch to system theme',
|
|
103
|
+
system: 'Switch to light theme',
|
|
104
|
+
};
|
|
105
|
+
function MobileThemeMenuItem({ onClose }) {
|
|
106
|
+
const { theme, setTheme } = useTheme();
|
|
107
|
+
const Icon = THEME_ICON[theme];
|
|
108
|
+
return (_jsxs("button", { type: "button", "aria-label": THEME_LABEL[theme], className: menuItemClassName, onClick: () => {
|
|
109
|
+
setTheme(NEXT_THEME[theme]);
|
|
110
|
+
onClose();
|
|
111
|
+
}, children: [_jsx(Icon, { size: 18, "aria-hidden": "true" }), _jsx("span", { children: THEME_LABEL[theme] })] }));
|
|
112
|
+
}
|
|
66
113
|
function ModuleDrawer({ open, onClose, module, renderCtx, }) {
|
|
67
114
|
const items = module?.items ?? [];
|
|
68
115
|
if (!module || (items.length === 0 && !module.render))
|
|
@@ -90,7 +137,7 @@ function PanelsDrawer({ open, onClose, panelViews, defaultView, renderCtx, }) {
|
|
|
90
137
|
const active = panelViews.find((v) => v.id === activeView) ??
|
|
91
138
|
(defaultView ? panelViews.find((v) => v.id === defaultView) : undefined) ??
|
|
92
139
|
panelViews[0];
|
|
93
|
-
return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "bottom", children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: active?.label ?? 'Panels' }), _jsx(DrawerDescription, { className: "sr-only", children: "Contextual panels for the current module." })] }), panelViews.length > 1 && (_jsx("div", { className: "flex items-center gap-1 border-b border-border px-3 py-2", children: panelViews.map((view) => (_jsx("button", { type: "button", onClick: () => ctx.panel.setActiveView(view.id), "aria-current": view.id === activeView ? 'true' : undefined, className: cn('rounded-md px-2 py-1 text-
|
|
140
|
+
return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "bottom", children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: active?.label ?? 'Panels' }), _jsx(DrawerDescription, { className: "sr-only", children: "Contextual panels for the current module." })] }), panelViews.length > 1 && (_jsx("div", { className: "flex items-center gap-1 border-b border-border px-3 py-2", children: panelViews.map((view) => (_jsx("button", { type: "button", onClick: () => ctx.panel.setActiveView(view.id), "aria-current": view.id === activeView ? 'true' : undefined, className: cn('rounded-md px-2 py-1 text-sm', view.id === activeView
|
|
94
141
|
? 'bg-accent text-accent-foreground'
|
|
95
142
|
: 'text-muted-foreground hover:bg-accent'), children: view.label }, view.id))) })), _jsx("div", { className: "flex-1 overflow-y-auto p-3", children: active?.render(renderCtx) })] }) }));
|
|
96
143
|
}
|
|
@@ -1,12 +1,42 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { AppShellAppTabsConfig, 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;
|
|
6
|
-
export
|
|
21
|
+
export declare function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter, }?: WorkspaceSwitcherProps): import("react/jsx-runtime").JSX.Element;
|
|
22
|
+
export interface AppTabsProps extends AppShellAppTabsConfig {
|
|
23
|
+
}
|
|
24
|
+
export declare function AppTabs({ renderLink }?: AppTabsProps): import("react/jsx-runtime").JSX.Element;
|
|
7
25
|
export declare function PanelToggle(): import("react/jsx-runtime").JSX.Element;
|
|
8
26
|
export interface ShellHeaderProps {
|
|
9
27
|
globalActions?: ReactNode;
|
|
28
|
+
/**
|
|
29
|
+
* Optional center-region content. Replaces the default application tabs when
|
|
30
|
+
* provided.
|
|
31
|
+
*/
|
|
32
|
+
headerCenter?: ReactNode;
|
|
33
|
+
appTabsRenderLink?: AppShellAppTabsConfig['renderLink'];
|
|
10
34
|
className?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Forwarded to the internal `<WorkspaceSwitcher>`. See
|
|
37
|
+
* `WorkspaceSwitcherProps` for the full shape.
|
|
38
|
+
*/
|
|
39
|
+
workspaceMenuItems?: WorkspaceMenuItem[];
|
|
40
|
+
workspaceMenuFooter?: ReactNode;
|
|
11
41
|
}
|
|
12
|
-
export declare function ShellHeader({ globalActions, className }?: ShellHeaderProps): import("react/jsx-runtime").JSX.Element | null;
|
|
42
|
+
export declare function ShellHeader({ globalActions, headerCenter, appTabsRenderLink, 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,25 +19,71 @@ 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
|
-
|
|
22
|
+
function renderShellIcon(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: [renderShellIcon(item.icon), item.label] }));
|
|
53
|
+
}
|
|
54
|
+
export function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter, } = {}) {
|
|
22
55
|
const { workspace, workspaces } = useMedaShell();
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
// AppTabs
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
export function AppTabs() {
|
|
60
|
+
export function AppTabs({ renderLink } = {}) {
|
|
29
61
|
const { apps, activeAppId, setActiveApp } = useMedaShell();
|
|
30
62
|
return (_jsx("nav", { "aria-label": "Applications", className: "flex items-center", children: apps.map((app) => {
|
|
31
63
|
const isActive = app.id === activeAppId;
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
64
|
+
const className = cn('flex items-center gap-1.5 px-3 py-2 text-sm font-medium transition-colors', isActive
|
|
65
|
+
? 'border-b-2 border-primary text-foreground'
|
|
66
|
+
: 'text-muted-foreground hover:text-foreground');
|
|
67
|
+
const children = (_jsxs(_Fragment, { children: [renderShellIcon(app.icon), app.label] }));
|
|
68
|
+
const handleClick = () => {
|
|
69
|
+
setActiveApp(app.id);
|
|
70
|
+
};
|
|
71
|
+
if (renderLink && app.to) {
|
|
72
|
+
return (_jsx(Fragment, { children: renderLink({
|
|
73
|
+
app,
|
|
74
|
+
isActive,
|
|
75
|
+
className,
|
|
76
|
+
children,
|
|
77
|
+
linkProps: {
|
|
78
|
+
href: app.to,
|
|
79
|
+
className,
|
|
80
|
+
children,
|
|
81
|
+
'aria-current': isActive ? 'page' : undefined,
|
|
82
|
+
onClick: handleClick,
|
|
83
|
+
},
|
|
84
|
+
}) }, app.id));
|
|
85
|
+
}
|
|
86
|
+
return (_jsx("button", { type: "button", "aria-current": isActive ? 'page' : undefined, onClick: handleClick, onMouseEnter: () => { }, className: className, children: children }, app.id));
|
|
40
87
|
}) }));
|
|
41
88
|
}
|
|
42
89
|
// ---------------------------------------------------------------------------
|
|
@@ -50,9 +97,9 @@ export function PanelToggle() {
|
|
|
50
97
|
};
|
|
51
98
|
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
99
|
}
|
|
53
|
-
export function ShellHeader({ globalActions, className } = {}) {
|
|
100
|
+
export function ShellHeader({ globalActions, headerCenter, appTabsRenderLink, className, workspaceMenuItems, workspaceMenuFooter, } = {}) {
|
|
54
101
|
const band = useShellViewport();
|
|
55
102
|
if (band === 'mobile')
|
|
56
103
|
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: [
|
|
104
|
+
return (_jsxs("header", { className: cn('flex h-[var(--shell-header-height)] w-full items-center justify-between', 'gap-3 border-b border-border bg-background px-3', className), children: [_jsx("div", { className: "flex shrink-0 items-center", children: _jsx(WorkspaceSwitcher, { menuItems: workspaceMenuItems, menuFooter: workspaceMenuFooter }) }), _jsx("div", { className: "flex min-w-0 flex-1 items-center", children: headerCenter !== undefined ? headerCenter : _jsx(AppTabs, { renderLink: appTabsRenderLink }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [globalActions, _jsx(PanelToggle, {})] })] }));
|
|
58
105
|
}
|
|
@@ -8,9 +8,8 @@ import { useShellViewport } from './use-shell-viewport.js';
|
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
// Theme adapter wiring
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
|
-
// Lazy-loaded so
|
|
12
|
-
//
|
|
13
|
-
// the import.
|
|
11
|
+
// Lazy-loaded so default-adapter consumers do not pay for the compatibility
|
|
12
|
+
// bridge used when themeAdapter='next-themes'.
|
|
14
13
|
const NextThemesAdapter = lazy(() => import('./theme-next-themes.js').then((m) => ({ default: m.NextThemesAdapter })));
|
|
15
14
|
function CustomThemeBridge({ adapter, children }) {
|
|
16
15
|
return _jsx(ThemeCtx.Provider, { value: adapter, children: children });
|
|
@@ -1,24 +1,115 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
3
|
+
import { useCallback, useEffect, useInsertionEffect, useMemo, useState, } from 'react';
|
|
4
4
|
import { ThemeCtx } from './theme.js';
|
|
5
|
+
const STORAGE_KEY = 'theme';
|
|
6
|
+
const MEDIA_QUERY = '(prefers-color-scheme: dark)';
|
|
7
|
+
const THEMES = ['light', 'dark'];
|
|
5
8
|
function narrow(value) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return
|
|
12
|
-
}
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
9
|
+
return value === 'light' || value === 'dark' || value === 'system' ? value : 'system';
|
|
10
|
+
}
|
|
11
|
+
function getMediaQueryList() {
|
|
12
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
13
|
+
return undefined;
|
|
14
|
+
return window.matchMedia(MEDIA_QUERY);
|
|
15
|
+
}
|
|
16
|
+
function resolveSystemTheme(mql) {
|
|
17
|
+
if (mql)
|
|
18
|
+
return mql.matches ? 'dark' : 'light';
|
|
19
|
+
return getMediaQueryList()?.matches ? 'dark' : 'light';
|
|
20
|
+
}
|
|
21
|
+
function getStoredTheme() {
|
|
22
|
+
if (typeof window === 'undefined')
|
|
23
|
+
return 'system';
|
|
24
|
+
try {
|
|
25
|
+
return narrow(window.localStorage.getItem(STORAGE_KEY));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return 'system';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function applyTheme(theme, resolvedTheme) {
|
|
32
|
+
if (typeof document === 'undefined')
|
|
33
|
+
return;
|
|
34
|
+
const root = document.documentElement;
|
|
35
|
+
const applied = theme === 'system' ? resolvedTheme : theme;
|
|
36
|
+
root.classList.remove(...THEMES);
|
|
37
|
+
root.classList.add(applied);
|
|
38
|
+
root.style.colorScheme = applied;
|
|
39
|
+
}
|
|
40
|
+
function getClientThemeSnapshot() {
|
|
41
|
+
const theme = getStoredTheme();
|
|
42
|
+
return {
|
|
43
|
+
theme,
|
|
44
|
+
resolvedTheme: theme === 'system' ? resolveSystemTheme() : theme,
|
|
19
45
|
};
|
|
20
|
-
|
|
46
|
+
}
|
|
47
|
+
function subscribeToSystemTheme(mql, listener) {
|
|
48
|
+
if (!mql)
|
|
49
|
+
return () => { };
|
|
50
|
+
if (typeof mql.addEventListener === 'function') {
|
|
51
|
+
mql.addEventListener('change', listener);
|
|
52
|
+
return () => mql.removeEventListener('change', listener);
|
|
53
|
+
}
|
|
54
|
+
if (typeof mql.addListener === 'function') {
|
|
55
|
+
mql.addListener(listener);
|
|
56
|
+
return () => mql.removeListener?.(listener);
|
|
57
|
+
}
|
|
58
|
+
return () => { };
|
|
21
59
|
}
|
|
22
60
|
export function NextThemesAdapter({ children }) {
|
|
23
|
-
|
|
61
|
+
const [theme, setThemeState] = useState('system');
|
|
62
|
+
const [resolvedTheme, setResolvedTheme] = useState('light');
|
|
63
|
+
const [hydrated, setHydrated] = useState(false);
|
|
64
|
+
const setTheme = useCallback((next) => {
|
|
65
|
+
setThemeState(next);
|
|
66
|
+
const resolved = next === 'system' ? resolveSystemTheme() : next;
|
|
67
|
+
setResolvedTheme(resolved);
|
|
68
|
+
applyTheme(next, resolved);
|
|
69
|
+
try {
|
|
70
|
+
if (typeof window !== 'undefined')
|
|
71
|
+
window.localStorage.setItem(STORAGE_KEY, next);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Ignore unavailable storage, matching next-themes' best-effort behavior.
|
|
75
|
+
}
|
|
76
|
+
}, []);
|
|
77
|
+
useInsertionEffect(() => {
|
|
78
|
+
const snapshot = getClientThemeSnapshot();
|
|
79
|
+
applyTheme(snapshot.theme, snapshot.resolvedTheme);
|
|
80
|
+
}, []);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const stored = getClientThemeSnapshot();
|
|
83
|
+
setThemeState(stored.theme);
|
|
84
|
+
setResolvedTheme(stored.resolvedTheme);
|
|
85
|
+
setHydrated(true);
|
|
86
|
+
applyTheme(stored.theme, stored.resolvedTheme);
|
|
87
|
+
}, []);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!hydrated)
|
|
90
|
+
return;
|
|
91
|
+
const mql = getMediaQueryList();
|
|
92
|
+
const sync = (_event) => {
|
|
93
|
+
const resolved = theme === 'system' ? resolveSystemTheme(mql) : theme;
|
|
94
|
+
setResolvedTheme(resolved);
|
|
95
|
+
applyTheme(theme, resolved);
|
|
96
|
+
};
|
|
97
|
+
sync();
|
|
98
|
+
return subscribeToSystemTheme(mql, sync);
|
|
99
|
+
}, [hydrated, theme]);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const handleStorage = (event) => {
|
|
102
|
+
if (event.key !== STORAGE_KEY)
|
|
103
|
+
return;
|
|
104
|
+
setThemeState(narrow(event.newValue));
|
|
105
|
+
};
|
|
106
|
+
window.addEventListener('storage', handleStorage);
|
|
107
|
+
return () => window.removeEventListener('storage', handleStorage);
|
|
108
|
+
}, []);
|
|
109
|
+
const value = useMemo(() => ({
|
|
110
|
+
theme,
|
|
111
|
+
setTheme,
|
|
112
|
+
resolvedTheme,
|
|
113
|
+
}), [theme, setTheme, resolvedTheme]);
|
|
114
|
+
return _jsx(ThemeCtx.Provider, { value: value, children: children });
|
|
24
115
|
}
|
package/dist/shell/types.d.ts
CHANGED
|
@@ -3,7 +3,23 @@ import type { AnchorHTMLAttributes, ReactNode } from 'react';
|
|
|
3
3
|
export interface AppDefinition {
|
|
4
4
|
id: string;
|
|
5
5
|
label: string;
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Either a Lucide-style icon component or a rendered React node such as a
|
|
8
|
+
* custom brand SVG.
|
|
9
|
+
*/
|
|
10
|
+
icon: LucideIcon | ReactNode;
|
|
11
|
+
/** Optional route target for consumers that render app tabs as links. */
|
|
12
|
+
to?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface AppTabRenderLinkArgs {
|
|
15
|
+
app: AppDefinition;
|
|
16
|
+
isActive: boolean;
|
|
17
|
+
className: string;
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
linkProps: AnchorHTMLAttributes<HTMLAnchorElement>;
|
|
20
|
+
}
|
|
21
|
+
export interface AppShellAppTabsConfig {
|
|
22
|
+
renderLink?: (args: AppTabRenderLinkArgs) => ReactNode;
|
|
7
23
|
}
|
|
8
24
|
export interface WorkspaceDefinition {
|
|
9
25
|
id: string;
|
|
@@ -93,6 +109,44 @@ export interface AppShellRightPanelConfig {
|
|
|
93
109
|
panelViews: PanelView[];
|
|
94
110
|
defaultView?: string;
|
|
95
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* A single configurable item in the WorkspaceSwitcher dropdown.
|
|
114
|
+
* Either `href` (renders as a link) or `onClick` (renders as a button) MUST
|
|
115
|
+
* be provided; supplying both makes the item a button that fires `onClick`
|
|
116
|
+
* AND navigates to `href` afterwards.
|
|
117
|
+
*/
|
|
118
|
+
interface WorkspaceMenuItemBase {
|
|
119
|
+
id: string;
|
|
120
|
+
label: ReactNode;
|
|
121
|
+
icon?: LucideIcon | ReactNode;
|
|
122
|
+
/** Insert a `<DropdownMenuSeparator />` immediately after this item. */
|
|
123
|
+
separatorAfter?: boolean;
|
|
124
|
+
/** Visual intent — `destructive` paints with the destructive token. */
|
|
125
|
+
variant?: 'default' | 'destructive';
|
|
126
|
+
}
|
|
127
|
+
export type WorkspaceMenuItem = WorkspaceMenuItemBase & ({
|
|
128
|
+
href: string;
|
|
129
|
+
onClick?: () => void;
|
|
130
|
+
} | {
|
|
131
|
+
onClick: () => void;
|
|
132
|
+
href?: string;
|
|
133
|
+
});
|
|
134
|
+
/**
|
|
135
|
+
* Workspace dropdown configuration for `<AppShell variant="workspace">`.
|
|
136
|
+
*
|
|
137
|
+
* When `menuItems` is provided, it REPLACES the package-default menu
|
|
138
|
+
* ("Manage workspaces", "Settings", "Profile", "Sign out"). The theme toggle
|
|
139
|
+
* is still inserted automatically by meda — between the items and the footer
|
|
140
|
+
* — so consumers don't have to reimplement theme cycling.
|
|
141
|
+
*
|
|
142
|
+
* When `menuItems` is omitted, the package defaults render unchanged (the
|
|
143
|
+
* existing 1.x behavior). This is fully additive and non-breaking.
|
|
144
|
+
*/
|
|
145
|
+
export interface AppShellWorkspaceConfig {
|
|
146
|
+
menuItems?: WorkspaceMenuItem[];
|
|
147
|
+
/** Extra slot rendered after all items and the theme toggle. */
|
|
148
|
+
menuFooter?: ReactNode;
|
|
149
|
+
}
|
|
96
150
|
export interface AppShellAuthConfig {
|
|
97
151
|
title: ReactNode;
|
|
98
152
|
description?: ReactNode;
|
|
@@ -108,3 +162,4 @@ export interface AppShellAuthBranding {
|
|
|
108
162
|
appName?: ReactNode;
|
|
109
163
|
tagline?: ReactNode;
|
|
110
164
|
}
|
|
165
|
+
export {};
|
package/dist/styles/theme.css
CHANGED
|
@@ -1,7 +1,34 @@
|
|
|
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
|
+
--auth-gradient-primary: var(--color-brand-500);
|
|
18
|
+
--auth-gradient-secondary: var(--color-brand-700);
|
|
19
|
+
--auth-gradient-base: var(--color-brand-800);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.dark,
|
|
23
|
+
[data-theme="dark"] {
|
|
24
|
+
color-scheme: dark;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
[data-vaul-drawer][data-state="open"] {
|
|
29
|
+
will-change: auto;
|
|
30
|
+
}
|
|
31
|
+
|
|
5
32
|
@theme inline {
|
|
6
33
|
/* Color primitives — exposed as Tailwind color scales */
|
|
7
34
|
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
|
|
30
|
+
it('declares dedicated auth gradient tokens for consumer overrides', () => {
|
|
31
|
+
expect(themeCss).toContain('--auth-gradient-primary: var(--color-brand-500);');
|
|
32
|
+
expect(themeCss).toContain('--auth-gradient-secondary: var(--color-brand-700);');
|
|
33
|
+
expect(themeCss).toContain('--auth-gradient-base: var(--color-brand-800);');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('neutralizes vaul drawer layer hints after mobile drawers are open', () => {
|
|
37
|
+
expect(themeCss).toMatch(
|
|
38
|
+
/\[data-vaul-drawer\]\[data-state=["']open["']\]\s*\{[^}]*will-change:\s*auto;/s
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
});
|