@medalsocial/meda 2.7.2 → 2.8.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 +56 -0
- package/dist/shell/app-shell-workspace.d.ts +8 -2
- package/dist/shell/app-shell-workspace.js +7 -3
- package/dist/shell/app-shell.d.ts +12 -2
- package/dist/shell/app-shell.js +1 -1
- package/dist/shell/index.d.ts +2 -2
- package/dist/shell/internal/mobile-dock.d.ts +7 -1
- package/dist/shell/internal/mobile-dock.js +26 -11
- package/dist/shell/internal/mobile-workspace-sheet.d.ts +15 -1
- package/dist/shell/internal/mobile-workspace-sheet.js +70 -3
- package/dist/shell/shell-header.d.ts +41 -4
- package/dist/shell/shell-header.js +29 -5
- package/dist/shell/types.d.ts +38 -0
- package/package.json +1 -1
- package/skills/shell/SKILL.md +86 -1
package/README.md
CHANGED
|
@@ -107,6 +107,62 @@ Workspace shells also expose chrome-level composition slots:
|
|
|
107
107
|
</AppShell>
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
+
### Rail header (`headerLayout="rail"`)
|
|
111
|
+
|
|
112
|
+
By default the desktop header is a `split` grid: the workspace switcher and `headerLeading` share
|
|
113
|
+
the left region, and `headerCenter` owns the middle. Because the switcher is as wide as the
|
|
114
|
+
workspace name, the leading region both moves per workspace and never gets more than about half
|
|
115
|
+
the window. `headerLayout="rail"` swaps in a three-column grid instead — rail column · fill ·
|
|
116
|
+
actions:
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
<AppShell
|
|
120
|
+
variant="workspace"
|
|
121
|
+
headerLayout="rail"
|
|
122
|
+
iconRail={{ mainItems, labelVisibility: 'visible' }}
|
|
123
|
+
headerLeading={<SectionTabs />} // owns the whole fill column
|
|
124
|
+
// Keep the panel views, drop the header's toggle:
|
|
125
|
+
rightPanel={{ panelViews, showToggle: false }}
|
|
126
|
+
globalActions={<NewButton />}
|
|
127
|
+
>
|
|
128
|
+
{children}
|
|
129
|
+
</AppShell>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- Column 1 is exactly the icon rail's width (`--shell-rail-label-width` when
|
|
133
|
+
`iconRail.labelVisibility` is `visible`, `--shell-rail-width` otherwise) and holds the workspace
|
|
134
|
+
switcher in its `tile` variant: the mark centred on the rail's axis with the workspace name
|
|
135
|
+
beneath it, in the icon-rail label type. The dropdown is the chip's, unchanged.
|
|
136
|
+
- Column 2 is `headerLeading`, `min-w-0`, and gets every remaining pixel.
|
|
137
|
+
- Column 3 is `globalActions` plus the panel toggle.
|
|
138
|
+
- `headerCenter` is **ignored** in this layout — there is no centre column.
|
|
139
|
+
|
|
140
|
+
The tile is metered to **44px** — a 28px mark, a 2px gap and ONE 14px label line, with no vertical
|
|
141
|
+
padding — and carries `max-h-full` inside an `overflow-hidden` column. So it fits whatever you set
|
|
142
|
+
`--shell-header-height` to, down to 52px (the web app runs 52px under its desktop window-tab
|
|
143
|
+
strip), and can never paint past the header:
|
|
144
|
+
|
|
145
|
+
```css
|
|
146
|
+
/* a tighter header — the tile follows it */
|
|
147
|
+
:root { --shell-header-height: 52px; }
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Because the label is truncated to one line — and hidden entirely below 700px viewport height, the
|
|
151
|
+
same tier at which every icon-rail label hides — the full workspace name is always recoverable two
|
|
152
|
+
other ways: the tile's tooltip, and its accessible name (`"<workspace> workspace menu"`).
|
|
153
|
+
|
|
154
|
+
Both layouts put `data-meda-shell-header` on the `<header>` (with
|
|
155
|
+
`data-meda-header-layout="split" | "rail"`), so consumer CSS and tests can target the header
|
|
156
|
+
without depending on its structure.
|
|
157
|
+
|
|
158
|
+
### Mobile nav: light by app
|
|
159
|
+
|
|
160
|
+
`mobileNav.activeTo` marks the exact address. Pass `mobileNav.activeId` (the active APP's id) as
|
|
161
|
+
well and the dock lights whenever `item.id === activeId`, so it stays lit on every route inside
|
|
162
|
+
that app rather than only on its first tab; the workspace sheet then opens with that row expanded
|
|
163
|
+
and scrolled into view. `mobileNav.currentFirst` additionally hoists that row to the top of its
|
|
164
|
+
group. Omit `activeId` and both surfaces keep comparing against `activeTo` as before.
|
|
165
|
+
|
|
110
166
|
`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
167
|
|
|
112
168
|
`ContextRail` is usable for both navigation rails and custom rendered rails. Navigation rails show Meda's label header by default. Custom rendered rails hide the automatic visible header by default so consumers can render their own heading without duplication. Rail bodies scroll vertically by default; use `contextRail={{ header: "visible" }}` or `contextRail={{ scroll: "none" }}` when you need explicit control.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
|
-
import type { AppShellAppTabsConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellMobileNavConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig, ShellMainLayout } from './types.js';
|
|
2
|
+
import type { AppShellAppTabsConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellMobileNavConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig, ShellHeaderLayout, ShellMainLayout } from './types.js';
|
|
3
3
|
export interface AppShellWorkspaceProps {
|
|
4
4
|
iconRail?: AppShellIconRailConfig;
|
|
5
5
|
contextRail?: AppShellContextRailConfig;
|
|
@@ -9,6 +9,12 @@ export interface AppShellWorkspaceProps {
|
|
|
9
9
|
globalActions?: ReactNode;
|
|
10
10
|
headerCenter?: ReactNode;
|
|
11
11
|
headerLeading?: ReactNode;
|
|
12
|
+
/**
|
|
13
|
+
* Desktop header grid — `split` (default, unchanged) or `rail`. See
|
|
14
|
+
* `ShellHeaderLayout`. In `rail` the header's first column is sized from
|
|
15
|
+
* `iconRail.labelVisibility` so it lines up with the rail below it.
|
|
16
|
+
*/
|
|
17
|
+
headerLayout?: ShellHeaderLayout;
|
|
12
18
|
banners?: ReactNode;
|
|
13
19
|
mainLayout?: ShellMainLayout;
|
|
14
20
|
mainClassName?: string;
|
|
@@ -27,4 +33,4 @@ export interface AppShellWorkspaceProps {
|
|
|
27
33
|
mobileNav?: AppShellMobileNavConfig;
|
|
28
34
|
children: ReactNode;
|
|
29
35
|
}
|
|
30
|
-
export declare function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, appTabs, globalActions, headerCenter, headerLeading, banners, mainLayout, mainClassName, builtInCommandPalette, mobileNav, children, }: AppShellWorkspaceProps): import("react/jsx-runtime").JSX.Element;
|
|
36
|
+
export declare function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, appTabs, globalActions, headerCenter, headerLeading, headerLayout, banners, mainLayout, mainClassName, builtInCommandPalette, mobileNav, children, }: AppShellWorkspaceProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -17,11 +17,15 @@ import { ShellHeader } from './shell-header.js';
|
|
|
17
17
|
import { ShellMain } from './shell-main.js';
|
|
18
18
|
import { useShellViewport } from './use-shell-viewport.js';
|
|
19
19
|
const EMPTY_PANEL_VIEWS = [];
|
|
20
|
-
export function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, appTabs, globalActions, headerCenter, headerLeading, banners, mainLayout, mainClassName, builtInCommandPalette = true, mobileNav, children, }) {
|
|
20
|
+
export function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, appTabs, globalActions, headerCenter, headerLeading, headerLayout, banners, mainLayout, mainClassName, builtInCommandPalette = true, mobileNav, children, }) {
|
|
21
21
|
const viewport = useShellViewport();
|
|
22
22
|
const isMobile = viewport === 'mobile';
|
|
23
23
|
const staticPanelViews = rightPanel?.panelViews ?? EMPTY_PANEL_VIEWS;
|
|
24
24
|
const resolvedRightPanel = useResolvedPanelViews(staticPanelViews, rightPanel?.defaultView);
|
|
25
|
+
// The header toggle still requires at least one panel view — `showToggle`
|
|
26
|
+
// only lets a consumer KEEP the views and drop the control, never the
|
|
27
|
+
// reverse (a toggle with nothing behind it is a dead end).
|
|
28
|
+
const showPanelToggle = (rightPanel?.showToggle ?? true) && resolvedRightPanel.panelViews.length > 0;
|
|
25
29
|
// Derive the bottom-nav items from the variant config so each button maps
|
|
26
30
|
// to a drawer that actually has content. Menu is always available because
|
|
27
31
|
// the mobile drawer now carries workspace actions and the theme toggle.
|
|
@@ -42,12 +46,12 @@ export function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace
|
|
|
42
46
|
// <AppShell> wrapper already enforces h-screen for the workspace variant, so
|
|
43
47
|
// nested viewport-height divs collapse cleanly — no double-scroll.
|
|
44
48
|
const commandRegistry = useContext(CommandRegistryContext);
|
|
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:
|
|
49
|
+
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, headerLayout: headerLayout, railLabelVisibility: iconRail?.labelVisibility, appTabsRenderLink: appTabs?.renderLink, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter, showPanelToggle: showPanelToggle, 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
50
|
// Pad the scroll area so the dock never covers content. 64px is
|
|
47
51
|
// the bar-variant dock's intrinsic height (py-2 + 25px icon +
|
|
48
52
|
// label) — the previous 80px over-reserved 16px of permanently
|
|
49
53
|
// 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 }))] }))] }));
|
|
54
|
+
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, activeId: mobileNav.activeId, renderLink: mobileNav.renderLink, variant: mobileNav.variant }), _jsx(MobileWorkspaceSheet, { tree: mobileNav.tree, activeTo: mobileNav.activeTo, activeId: mobileNav.activeId, currentFirst: mobileNav.currentFirst, 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 }))] }))] }));
|
|
51
55
|
// Backwards-compatible default: meda provides the built-in CommandPalette
|
|
52
56
|
// (and its CommandRegistryContext) so consumers calling useCommands()/
|
|
53
57
|
// useCommandGroup() under the workspace shell keep working. When a registry is
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import type { AppShellAppTabsConfig, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellMobileNavConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig, ShellMainLayout } from './types.js';
|
|
2
|
+
import type { AppShellAppTabsConfig, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellMobileNavConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig, ShellHeaderLayout, ShellMainLayout } from './types.js';
|
|
3
3
|
interface AppShellBaseProps {
|
|
4
4
|
children: ReactNode;
|
|
5
5
|
className?: string;
|
|
@@ -29,14 +29,24 @@ export type AppShellProps = AppShellBaseProps & ({
|
|
|
29
29
|
globalActions?: ReactNode;
|
|
30
30
|
/**
|
|
31
31
|
* Optional center-region header content. Replaces the default
|
|
32
|
-
* application tabs when provided.
|
|
32
|
+
* application tabs when provided. Ignored when
|
|
33
|
+
* `headerLayout="rail"` — that layout has no centre column.
|
|
33
34
|
*/
|
|
34
35
|
headerCenter?: ReactNode;
|
|
35
36
|
/**
|
|
36
37
|
* Optional leading content rendered in the LEFT header region immediately
|
|
37
38
|
* after the workspace switcher. On mobile, surfaced inside the menu drawer.
|
|
39
|
+
* Under `headerLayout="rail"` it owns the header's whole fill column.
|
|
38
40
|
*/
|
|
39
41
|
headerLeading?: ReactNode;
|
|
42
|
+
/**
|
|
43
|
+
* Desktop header grid. `split` (the default) is the pre-2.8 layout,
|
|
44
|
+
* byte-for-byte unchanged. `rail` switches to
|
|
45
|
+
* `[rail column | fill | actions]`: the workspace switcher renders as a
|
|
46
|
+
* tile the width of the icon rail, and `headerLeading` gets every
|
|
47
|
+
* remaining pixel. See `ShellHeaderLayout`.
|
|
48
|
+
*/
|
|
49
|
+
headerLayout?: ShellHeaderLayout;
|
|
40
50
|
/**
|
|
41
51
|
* Optional chrome-level content rendered below the header and above
|
|
42
52
|
* the workspace rail row.
|
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, workspace: props.workspace, appTabs: props.appTabs, globalActions: props.globalActions, headerCenter: props.headerCenter, headerLeading: props.headerLeading, banners: props.banners, mainLayout: props.mainLayout, mainClassName: props.mainClassName, builtInCommandPalette: props.builtInCommandPalette, mobileNav: props.mobileNav, 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, headerLeading: props.headerLeading, headerLayout: props.headerLayout, banners: props.banners, mainLayout: props.mainLayout, mainClassName: props.mainClassName, builtInCommandPalette: props.builtInCommandPalette, mobileNav: props.mobileNav, 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,12 +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, PanelToggleProps } from './shell-header.js';
|
|
23
|
+
export type { AppTabsProps, PanelToggleProps, ShellHeaderProps, WorkspaceSwitcherProps, WorkspaceSwitcherVariant, } from './shell-header.js';
|
|
24
24
|
export { AppTabs, PanelToggle, ShellHeader, WorkspaceSwitcher } from './shell-header.js';
|
|
25
25
|
export { ShellMain } from './shell-main.js';
|
|
26
26
|
export type { MedaShellProviderProps } from './shell-provider.js';
|
|
27
27
|
export { MedaShellProvider, useMedaShell, useShellSelection } from './shell-provider.js';
|
|
28
28
|
export { DefaultThemeProvider, ThemeToggle, useTheme } from './theme.js';
|
|
29
29
|
export { NextThemesAdapter } from './theme-next-themes.js';
|
|
30
|
-
export type { AppDefinition, AppShellAppTabsConfig, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellMobileNavConfig, AppShellRightPanelConfig, AppShellVariant, AppShellWorkspaceConfig, AppTabRenderLinkArgs, CommandDefinition, ContextItem, ContextModule, ContextRailHeader, ContextRailScroll, MobileBottomNavItem, MobileDockItem, MobileNavGroup, MobileNavItem, MobileNavLinkArgs, MobileNavTree, MobileNavView, PanelMode, PanelView, ShellLinkRenderArgs, ShellMainLayout, ShellRenderContext, ShellViewport, ThemeAdapter, WorkspaceDefinition, WorkspaceMenuItem, } from './types.js';
|
|
30
|
+
export type { AppDefinition, AppShellAppTabsConfig, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellMobileNavConfig, AppShellRightPanelConfig, AppShellVariant, AppShellWorkspaceConfig, AppTabRenderLinkArgs, CommandDefinition, ContextItem, ContextModule, ContextRailHeader, ContextRailScroll, MobileBottomNavItem, MobileDockItem, MobileNavGroup, MobileNavItem, MobileNavLinkArgs, MobileNavTree, MobileNavView, PanelMode, PanelView, ShellHeaderLayout, ShellLinkRenderArgs, ShellMainLayout, ShellRenderContext, ShellViewport, ThemeAdapter, WorkspaceDefinition, WorkspaceMenuItem, } from './types.js';
|
|
31
31
|
export { useShellViewport } from './use-shell-viewport.js';
|
|
@@ -5,6 +5,12 @@ export declare const WORKSPACE_SHEET_KEY = "workspace-sheet";
|
|
|
5
5
|
export interface MobileDockProps {
|
|
6
6
|
items: MobileDockItem[];
|
|
7
7
|
activeTo?: string;
|
|
8
|
+
/**
|
|
9
|
+
* The active APP's id. When set, a slot is active iff `item.id === activeId`
|
|
10
|
+
* — so the dock stays lit everywhere inside that app, not only on the exact
|
|
11
|
+
* address in `activeTo`. Omit to keep the `to === activeTo` comparison.
|
|
12
|
+
*/
|
|
13
|
+
activeId?: string;
|
|
8
14
|
renderLink?: (args: MobileNavLinkArgs) => ReactNode;
|
|
9
15
|
className?: string;
|
|
10
16
|
/** `pill` (default) floating dock, or `bar` full-width labeled bottom bar. */
|
|
@@ -21,4 +27,4 @@ export interface MobileDockProps {
|
|
|
21
27
|
*
|
|
22
28
|
* Hidden on non-mobile viewports and when the right panel is fullscreen.
|
|
23
29
|
*/
|
|
24
|
-
export declare function MobileDock({ items, activeTo, renderLink, className, variant, }: MobileDockProps): import("react/jsx-runtime").JSX.Element | null;
|
|
30
|
+
export declare function MobileDock({ items, activeTo, activeId, renderLink, className, variant, }: MobileDockProps): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -28,7 +28,7 @@ function renderIcon(icon, size) {
|
|
|
28
28
|
*
|
|
29
29
|
* Hidden on non-mobile viewports and when the right panel is fullscreen.
|
|
30
30
|
*/
|
|
31
|
-
export function MobileDock({ items, activeTo, renderLink, className, variant = 'pill', }) {
|
|
31
|
+
export function MobileDock({ items, activeTo, activeId, renderLink, className, variant = 'pill', }) {
|
|
32
32
|
const ctx = useMedaShell();
|
|
33
33
|
const band = useShellViewport();
|
|
34
34
|
if (band !== 'mobile')
|
|
@@ -37,6 +37,9 @@ export function MobileDock({ items, activeTo, renderLink, className, variant = '
|
|
|
37
37
|
return null;
|
|
38
38
|
if (items.length === 0)
|
|
39
39
|
return null;
|
|
40
|
+
// `activeId` wins when supplied; otherwise fall back to the exact-address
|
|
41
|
+
// comparison the dock has always used.
|
|
42
|
+
const isItemActive = (item) => activeId != null ? item.id === activeId : Boolean(item.to && activeTo && item.to === activeTo);
|
|
40
43
|
const runAction = (item) => {
|
|
41
44
|
if (item.action === 'open-sheet')
|
|
42
45
|
ctx.mobileDrawer.setOpen(WORKSPACE_SHEET_KEY);
|
|
@@ -49,14 +52,17 @@ export function MobileDock({ items, activeTo, renderLink, className, variant = '
|
|
|
49
52
|
if (variant === 'bar') {
|
|
50
53
|
const renderBarSlot = (item) => {
|
|
51
54
|
const label = typeof item.label === 'function' ? item.label() : item.label;
|
|
52
|
-
const isActive =
|
|
55
|
+
const isActive = isItemActive(item);
|
|
53
56
|
const isBrand = item.emphasis === 'brand';
|
|
54
|
-
const iconEl = isBrand ? (_jsx("span", { className:
|
|
57
|
+
const iconEl = isBrand ? (_jsx("span", { className: cn('flex size-8 items-center justify-center rounded-full bg-[#5B2D8C] text-white', isActive && 'ring-2 ring-primary ring-offset-2 ring-offset-card'), children: renderIcon(item.icon, 20) })) : (renderIcon(item.icon, 25));
|
|
58
|
+
// Active wins over brand: a slot carrying `aria-current="page"` must
|
|
59
|
+
// have a visible treatment too. The label goes primary; the glyph in the
|
|
60
|
+
// disc stays white because the disc sets its own colour.
|
|
55
61
|
let toneClass = 'text-muted-foreground hover:text-foreground';
|
|
56
|
-
if (
|
|
57
|
-
toneClass = 'text-muted-foreground';
|
|
58
|
-
else if (isActive)
|
|
62
|
+
if (isActive)
|
|
59
63
|
toneClass = 'font-medium text-primary';
|
|
64
|
+
else if (isBrand)
|
|
65
|
+
toneClass = 'text-muted-foreground';
|
|
60
66
|
const slotClass = cn('flex w-full flex-col items-center justify-center gap-1 py-2 text-[11px] leading-none transition-colors', toneClass);
|
|
61
67
|
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
68
|
if (item.to && !item.action) {
|
|
@@ -86,12 +92,18 @@ export function MobileDock({ items, activeTo, renderLink, className, variant = '
|
|
|
86
92
|
const brandItems = items.filter((item) => item.emphasis === 'brand');
|
|
87
93
|
const renderSlot = (item, size) => {
|
|
88
94
|
const label = typeof item.label === 'function' ? item.label() : item.label;
|
|
89
|
-
const isActive =
|
|
95
|
+
const isActive = isItemActive(item);
|
|
90
96
|
const icon = renderIcon(item.icon, size);
|
|
91
97
|
const badge = item.badge ? (_jsx("span", { className: "absolute top-0 right-0 size-2 rounded-full bg-primary ring-2 ring-card" })) : null;
|
|
92
98
|
// Route link
|
|
93
99
|
if (item.to && !item.action) {
|
|
94
|
-
const className = cn('relative flex items-center justify-center rounded-full p-1 transition-colors',
|
|
100
|
+
const className = cn('relative flex items-center justify-center rounded-full p-1 transition-colors',
|
|
101
|
+
// A brand slot is rendered inside a filled brand disc that already
|
|
102
|
+
// sets `text-white`; a colour class here would override it and paint
|
|
103
|
+
// the glyph muted-grey on purple. Inherit instead, and let the disc
|
|
104
|
+
// itself carry the active ring (see the brandItems map below).
|
|
105
|
+
item.emphasis === 'brand' && 'text-inherit', item.emphasis !== 'brand' &&
|
|
106
|
+
(isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'));
|
|
95
107
|
const children = (_jsxs(_Fragment, { children: [icon, badge] }));
|
|
96
108
|
if (renderLink) {
|
|
97
109
|
return renderLink({
|
|
@@ -110,8 +122,11 @@ export function MobileDock({ items, activeTo, renderLink, className, variant = '
|
|
|
110
122
|
}
|
|
111
123
|
return (_jsx("a", { href: item.to, className: className, "aria-label": label, "aria-current": isActive ? 'page' : undefined, children: children }));
|
|
112
124
|
}
|
|
113
|
-
// Action slot
|
|
114
|
-
|
|
125
|
+
// Action slot. Before `activeId` these never carried an active state, so
|
|
126
|
+
// the `activeTo` fallback deliberately does NOT light them — only an
|
|
127
|
+
// explicit `activeId` match does.
|
|
128
|
+
const actionActive = activeId != null && item.id === activeId;
|
|
129
|
+
return (_jsxs("button", { type: "button", onClick: () => runAction(item), "aria-label": label, className: cn('relative flex items-center justify-center rounded-full p-1 transition-colors', actionActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'), children: [icon, badge] }));
|
|
115
130
|
};
|
|
116
|
-
return (_jsxs("div", { "data-testid": "mobile-dock", className: cn('pointer-events-none absolute inset-x-0 bottom-0 z-30 flex items-center justify-center gap-2.5 pb-[calc(env(safe-area-inset-bottom)+12px)] pt-2', className), children: [_jsx("nav", { "aria-label": "Dock", className: "pointer-events-auto flex items-center gap-[18px] rounded-full border border-border bg-card px-4 py-2.5 shadow-[0_6px_20px_rgba(20,18,60,0.14)]", children: pillItems.map((item) => (_jsx("span", { className: "flex", children: renderSlot(item, 22) }, item.id))) }), brandItems.map((item) => (_jsx("span", { className:
|
|
131
|
+
return (_jsxs("div", { "data-testid": "mobile-dock", className: cn('pointer-events-none absolute inset-x-0 bottom-0 z-30 flex items-center justify-center gap-2.5 pb-[calc(env(safe-area-inset-bottom)+12px)] pt-2', className), children: [_jsx("nav", { "aria-label": "Dock", className: "pointer-events-auto flex items-center gap-[18px] rounded-full border border-border bg-card px-4 py-2.5 shadow-[0_6px_20px_rgba(20,18,60,0.14)]", children: pillItems.map((item) => (_jsx("span", { className: "flex", children: renderSlot(item, 22) }, item.id))) }), brandItems.map((item) => (_jsx("span", { className: cn('pointer-events-auto flex size-11 items-center justify-center rounded-full bg-[#5B2D8C] text-white shadow-[0_6px_20px_rgba(91,45,140,0.35)]', isItemActive(item) && 'ring-2 ring-primary ring-offset-2 ring-offset-background'), children: renderSlot(item, 22) }, item.id)))] }));
|
|
117
132
|
}
|
|
@@ -3,6 +3,16 @@ import type { MobileNavLinkArgs, MobileNavTree, WorkspaceMenuItem } from '../typ
|
|
|
3
3
|
export interface MobileWorkspaceSheetProps {
|
|
4
4
|
tree: MobileNavTree;
|
|
5
5
|
activeTo?: string;
|
|
6
|
+
/**
|
|
7
|
+
* The active APP's id — a row id in `tree`. When supplied it, not
|
|
8
|
+
* `activeTo`, decides which row the sheet opens on.
|
|
9
|
+
*/
|
|
10
|
+
activeId?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Render the current row first inside its group. Defaults to `false`, i.e.
|
|
13
|
+
* the tree's own order.
|
|
14
|
+
*/
|
|
15
|
+
currentFirst?: boolean;
|
|
6
16
|
renderLink?: (args: MobileNavLinkArgs) => ReactNode;
|
|
7
17
|
workspaceMenuItems?: WorkspaceMenuItem[];
|
|
8
18
|
workspaceMenuFooter?: ReactNode;
|
|
@@ -14,5 +24,9 @@ export interface MobileWorkspaceSheetProps {
|
|
|
14
24
|
* preset views (accordion, one open at a time). Filters, layout, and saved
|
|
15
25
|
* views deliberately stay on the page, not here. Opened from the dock's
|
|
16
26
|
* workspace-selector slot; a workspace header on top surfaces the account menu.
|
|
27
|
+
*
|
|
28
|
+
* The sheet opens WHERE YOU ARE: the current row (see `activeId` / `activeTo`)
|
|
29
|
+
* is expanded and scrolled into view on every open, and the user can still
|
|
30
|
+
* collapse it.
|
|
17
31
|
*/
|
|
18
|
-
export declare function MobileWorkspaceSheet({ tree, activeTo, renderLink, workspaceMenuItems, workspaceMenuFooter, }: MobileWorkspaceSheetProps): import("react/jsx-runtime").JSX.Element;
|
|
32
|
+
export declare function MobileWorkspaceSheet({ tree, activeTo, activeId, currentFirst, renderLink, workspaceMenuItems, workspaceMenuFooter, }: MobileWorkspaceSheetProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { ChevronDown, ChevronRight, ChevronUp, Monitor, Moon, Search, Sun, } from 'lucide-react';
|
|
4
|
-
import { createElement, Fragment, isValidElement, useState } from 'react';
|
|
4
|
+
import { createElement, Fragment, isValidElement, useEffect, useRef, useState, } from 'react';
|
|
5
5
|
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, } from '../../components/ui/drawer.js';
|
|
6
6
|
import { cn } from '../../lib/utils.js';
|
|
7
7
|
import { useMedaShell } from '../shell-provider.js';
|
|
@@ -18,6 +18,36 @@ function renderIcon(icon, size) {
|
|
|
18
18
|
return null;
|
|
19
19
|
}
|
|
20
20
|
const rowClass = 'flex w-full items-center gap-3 rounded-[10px] px-2 py-2.5 text-left text-[13.5px] transition-colors hover:bg-accent';
|
|
21
|
+
/** Every row in the tree, groups first then footer rows, in render order. */
|
|
22
|
+
function allNavItems(tree) {
|
|
23
|
+
return [...tree.groups.flatMap((group) => group.items), ...(tree.footerItems ?? [])];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The row the user is currently "in".
|
|
27
|
+
*
|
|
28
|
+
* `activeId` is authoritative when supplied. Otherwise the row is derived from
|
|
29
|
+
* `activeTo`: an exact `to` match first, then the row that owns `activeTo`
|
|
30
|
+
* among its preset `views`.
|
|
31
|
+
*/
|
|
32
|
+
function resolveCurrentItem(tree, activeId, activeTo) {
|
|
33
|
+
const items = allNavItems(tree);
|
|
34
|
+
if (activeId != null)
|
|
35
|
+
return items.find((item) => item.id === activeId) ?? null;
|
|
36
|
+
if (activeTo == null)
|
|
37
|
+
return null;
|
|
38
|
+
return (items.find((item) => item.to === activeTo) ??
|
|
39
|
+
items.find((item) => (item.views ?? []).some((view) => view.to === activeTo)) ??
|
|
40
|
+
null);
|
|
41
|
+
}
|
|
42
|
+
/** Move `currentId` to the front of `items` (used by `currentFirst`). */
|
|
43
|
+
function withCurrentFirst(items, currentId) {
|
|
44
|
+
if (currentId == null)
|
|
45
|
+
return items;
|
|
46
|
+
const index = items.findIndex((item) => item.id === currentId);
|
|
47
|
+
if (index <= 0)
|
|
48
|
+
return items;
|
|
49
|
+
return [items[index], ...items.slice(0, index), ...items.slice(index + 1)];
|
|
50
|
+
}
|
|
21
51
|
/**
|
|
22
52
|
* The one mobile workspace sheet — a calm, scannable list of the whole nav
|
|
23
53
|
* tree that replaces the four drawers. Modules are light group labels,
|
|
@@ -25,13 +55,50 @@ const rowClass = 'flex w-full items-center gap-3 rounded-[10px] px-2 py-2.5 text
|
|
|
25
55
|
* preset views (accordion, one open at a time). Filters, layout, and saved
|
|
26
56
|
* views deliberately stay on the page, not here. Opened from the dock's
|
|
27
57
|
* workspace-selector slot; a workspace header on top surfaces the account menu.
|
|
58
|
+
*
|
|
59
|
+
* The sheet opens WHERE YOU ARE: the current row (see `activeId` / `activeTo`)
|
|
60
|
+
* is expanded and scrolled into view on every open, and the user can still
|
|
61
|
+
* collapse it.
|
|
28
62
|
*/
|
|
29
|
-
export function MobileWorkspaceSheet({ tree, activeTo, renderLink, workspaceMenuItems, workspaceMenuFooter, }) {
|
|
63
|
+
export function MobileWorkspaceSheet({ tree, activeTo, activeId, currentFirst = false, renderLink, workspaceMenuItems, workspaceMenuFooter, }) {
|
|
30
64
|
const ctx = useMedaShell();
|
|
31
65
|
const open = ctx.mobileDrawer.open === WORKSPACE_SHEET_KEY;
|
|
32
66
|
const close = () => ctx.mobileDrawer.setOpen(null);
|
|
33
67
|
const [expandedId, setExpandedId] = useState(null);
|
|
34
68
|
const [accountOpen, setAccountOpen] = useState(false);
|
|
69
|
+
// Which row we have already scrolled to for the CURRENT open session, so a
|
|
70
|
+
// re-render does not keep yanking a sheet the user has scrolled by hand.
|
|
71
|
+
const scrolledToRef = useRef(null);
|
|
72
|
+
const currentItem = resolveCurrentItem(tree, activeId, activeTo);
|
|
73
|
+
const currentItemId = currentItem?.id ?? null;
|
|
74
|
+
const currentHasViews = (currentItem?.views ?? []).length > 0;
|
|
75
|
+
// Reset the accordion to the current row on every open. Primitive deps, so
|
|
76
|
+
// a re-render with the same current row does not fight a manual collapse.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!open)
|
|
79
|
+
return;
|
|
80
|
+
setExpandedId(currentHasViews ? currentItemId : null);
|
|
81
|
+
}, [open, currentItemId, currentHasViews]);
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!open)
|
|
84
|
+
scrolledToRef.current = null;
|
|
85
|
+
}, [open]);
|
|
86
|
+
// Bring the current row into view as soon as it is in the DOM, so a long
|
|
87
|
+
// tree never opens scrolled away from where the user actually is. Done from
|
|
88
|
+
// the ref callback rather than an effect because the drawer portals its
|
|
89
|
+
// content in a later commit than the one that flips `open`.
|
|
90
|
+
const registerRow = (id) => (el) => {
|
|
91
|
+
if (el == null)
|
|
92
|
+
return;
|
|
93
|
+
if (!open || id !== currentItemId || scrolledToRef.current === id)
|
|
94
|
+
return;
|
|
95
|
+
scrolledToRef.current = id;
|
|
96
|
+
// `scrollIntoView` is absent in some non-browser DOM implementations.
|
|
97
|
+
if (typeof el.scrollIntoView === 'function') {
|
|
98
|
+
el.scrollIntoView({ block: 'center' });
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const orderItems = (items) => currentFirst ? withCurrentFirst(items, currentItemId) : items;
|
|
35
102
|
const navLink = (to, label, className, children, isActive) => {
|
|
36
103
|
if (renderLink) {
|
|
37
104
|
return renderLink({
|
|
@@ -72,7 +139,7 @@ export function MobileWorkspaceSheet({ tree, activeTo, renderLink, workspaceMenu
|
|
|
72
139
|
return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && close(), direction: "bottom", children: _jsxs(DrawerContent, { showOverlay: true, className: "max-h-[86vh] rounded-t-[22px] p-0 data-[vaul-drawer-direction=bottom]:mt-0 data-[vaul-drawer-direction=bottom]:max-h-[86vh]", children: [_jsxs(DrawerHeader, { className: "sr-only", children: [_jsx(DrawerTitle, { children: "Navigation" }), _jsx(DrawerDescription, { children: "Jump to any area of the workspace." })] }), _jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto px-3 pt-1 pb-[calc(env(safe-area-inset-bottom)+12px)]", children: [_jsxs("button", { type: "button", onClick: () => setAccountOpen((v) => !v), "aria-expanded": accountOpen, className: "flex w-full items-center gap-2.5 rounded-[10px] px-1.5 py-2 text-left hover:bg-accent", children: [_jsx("span", { className: "inline-flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted ring-1 ring-border/70", children: ctx.workspace.icon }), _jsx("span", { className: "min-w-0 flex-1 truncate font-medium text-sm", children: ctx.workspace.name }), accountOpen ? (_jsx(ChevronUp, { className: "size-4 text-muted-foreground", "aria-hidden": "true" })) : (_jsx(ChevronDown, { className: "size-4 text-muted-foreground", "aria-hidden": "true" }))] }), accountOpen ? (_jsxs("div", { className: "mb-1 flex flex-col gap-0.5 border-b pb-2", children: [workspaceMenuItems?.map((item) => (_jsxs(Fragment, { children: [_jsx(WorkspaceMenuRow, { item: item, onClose: close }), item.separatorAfter ? _jsx("div", { className: "my-1 h-px bg-border" }) : null] }, item.id))), _jsx(ThemeRow, {}), workspaceMenuFooter] })) : null, _jsxs("button", { type: "button", onClick: () => {
|
|
73
140
|
close();
|
|
74
141
|
ctx.commandPalette.setOpen(true);
|
|
75
|
-
}, className: "my-2 flex w-full items-center gap-2 rounded-[10px] border px-3 py-2.5 text-left text-muted-foreground text-sm", children: [_jsx(Search, { className: "size-4", "aria-hidden": "true" }), "Search\u2026"] }), tree.groups.map((group) => (_jsxs("section", { "aria-label": group.label, children: [_jsx("p", { className: "px-2 pt-3 pb-1 text-[11px] text-muted-foreground", children: group.label }), _jsx("div", { className: "flex flex-col gap-0.5", children: group.items.map((item) => (_jsx(
|
|
142
|
+
}, className: "my-2 flex w-full items-center gap-2 rounded-[10px] border px-3 py-2.5 text-left text-muted-foreground text-sm", children: [_jsx(Search, { className: "size-4", "aria-hidden": "true" }), "Search\u2026"] }), tree.groups.map((group) => (_jsxs("section", { "aria-label": group.label, children: [_jsx("p", { className: "px-2 pt-3 pb-1 text-[11px] text-muted-foreground", children: group.label }), _jsx("div", { className: "flex flex-col gap-0.5", children: orderItems(group.items).map((item) => (_jsx("div", { ref: registerRow(item.id), "data-meda-nav-item": item.id, children: renderRow(item) }, item.id))) })] }, group.id))), tree.footerItems && tree.footerItems.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "mx-2 my-2 h-px bg-border" }), _jsx("div", { className: "flex flex-col gap-0.5", children: orderItems(tree.footerItems).map((item) => (_jsx("div", { ref: registerRow(item.id), "data-meda-nav-item": item.id, children: renderRow(item) }, item.id))) })] })) : null] })] }) }));
|
|
76
143
|
}
|
|
77
144
|
function WorkspaceMenuRow({ item, onClose }) {
|
|
78
145
|
const icon = renderIcon(item.icon, 16);
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
|
-
import type {
|
|
2
|
+
import type { IconRailLabelVisibility } from './icon-rail.js';
|
|
3
|
+
import type { AppShellAppTabsConfig, PanelView, ShellHeaderLayout, WorkspaceMenuItem } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* WorkspaceSwitcher trigger shape.
|
|
6
|
+
*
|
|
7
|
+
* - `chip` (default, unchanged) — a horizontal button: mark · name · chevron.
|
|
8
|
+
* - `tile` — a full-width, rail-column button: the mark centred on the rail's
|
|
9
|
+
* axis with the workspace name beneath it in the icon-rail label type. Used
|
|
10
|
+
* by `<ShellHeader headerLayout="rail">`.
|
|
11
|
+
*/
|
|
12
|
+
export type WorkspaceSwitcherVariant = 'chip' | 'tile';
|
|
3
13
|
export interface WorkspaceSwitcherProps {
|
|
4
14
|
/**
|
|
5
15
|
* Configurable dropdown items. When provided, REPLACES the default
|
|
@@ -17,8 +27,20 @@ export interface WorkspaceSwitcherProps {
|
|
|
17
27
|
menuFooter?: ReactNode;
|
|
18
28
|
/** @deprecated Use `menuFooter` instead. */
|
|
19
29
|
workspaceMenuFooter?: ReactNode;
|
|
30
|
+
/**
|
|
31
|
+
* Trigger presentation. Defaults to `chip` — the 1.x/2.x horizontal button.
|
|
32
|
+
* The dropdown content and behaviour are identical in both variants.
|
|
33
|
+
*/
|
|
34
|
+
variant?: WorkspaceSwitcherVariant;
|
|
35
|
+
/**
|
|
36
|
+
* `tile` variant only — render the workspace name under the mark. Pass
|
|
37
|
+
* `false` when the icon rail is icon-only (`labelVisibility: 'tooltip'`) so
|
|
38
|
+
* the tile matches the narrow rail. Ignored by the `chip` variant, whose
|
|
39
|
+
* name is never hidden. Defaults to `true`.
|
|
40
|
+
*/
|
|
41
|
+
showLabel?: boolean;
|
|
20
42
|
}
|
|
21
|
-
export declare function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter, }?: WorkspaceSwitcherProps): import("react/jsx-runtime").JSX.Element;
|
|
43
|
+
export declare function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter, variant, showLabel, }?: WorkspaceSwitcherProps): import("react/jsx-runtime").JSX.Element;
|
|
22
44
|
export interface AppTabsProps extends AppShellAppTabsConfig {
|
|
23
45
|
}
|
|
24
46
|
export declare function AppTabs({ renderLink }?: AppTabsProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -31,7 +53,8 @@ export interface ShellHeaderProps {
|
|
|
31
53
|
globalActions?: ReactNode;
|
|
32
54
|
/**
|
|
33
55
|
* Optional center-region content. Replaces the default application tabs when
|
|
34
|
-
* provided.
|
|
56
|
+
* provided. IGNORED when `headerLayout="rail"` — that layout has no centre
|
|
57
|
+
* column; put the content in `headerLeading` instead.
|
|
35
58
|
*/
|
|
36
59
|
headerCenter?: ReactNode;
|
|
37
60
|
/**
|
|
@@ -50,5 +73,19 @@ export interface ShellHeaderProps {
|
|
|
50
73
|
showPanelToggle?: boolean;
|
|
51
74
|
/** Panel views forwarded to the header `<PanelToggle>` dropdown. */
|
|
52
75
|
panelViews?: PanelView[];
|
|
76
|
+
/**
|
|
77
|
+
* Header grid. `split` (default) is the pre-2.8 markup, unchanged. `rail`
|
|
78
|
+
* switches to `[rail column | fill | actions]` — see `ShellHeaderLayout`.
|
|
79
|
+
*/
|
|
80
|
+
headerLayout?: ShellHeaderLayout;
|
|
81
|
+
/**
|
|
82
|
+
* The icon rail's label mode, so the `rail` layout can size its first column
|
|
83
|
+
* to the rail below it (`--shell-rail-label-width` when the rail shows
|
|
84
|
+
* labels, `--shell-rail-width` when it does not) and hide the tile's
|
|
85
|
+
* workspace name in icon-only mode. Mirror `iconRail.labelVisibility`.
|
|
86
|
+
* Defaults to `tooltip`, matching `IconRail`'s own default. Unused by the
|
|
87
|
+
* `split` layout.
|
|
88
|
+
*/
|
|
89
|
+
railLabelVisibility?: IconRailLabelVisibility;
|
|
53
90
|
}
|
|
54
|
-
export declare function ShellHeader({ globalActions, headerCenter, headerLeading, appTabsRenderLink, className, workspaceMenuItems, workspaceMenuFooter, showPanelToggle, panelViews, }?: ShellHeaderProps): import("react/jsx-runtime").JSX.Element | null;
|
|
91
|
+
export declare function ShellHeader({ globalActions, headerCenter, headerLeading, appTabsRenderLink, className, workspaceMenuItems, workspaceMenuFooter, showPanelToggle, panelViews, headerLayout, railLabelVisibility, }?: ShellHeaderProps): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { ChevronDown, Monitor, Moon, PanelRight, Sun } from 'lucide-react';
|
|
4
4
|
import { createElement, Fragment, isValidElement } from 'react';
|
|
5
5
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '../components/ui/dropdown-menu.js';
|
|
6
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '../components/ui/tooltip.js';
|
|
6
7
|
import { cn } from '../lib/utils.js';
|
|
7
8
|
import { useMedaShell } from './shell-provider.js';
|
|
8
9
|
import { useTheme } from './theme.js';
|
|
@@ -52,12 +53,27 @@ function renderConfiguredItem(item) {
|
|
|
52
53
|
const renderLink = item.href != null ? _jsx("a", { href: item.href }) : undefined;
|
|
53
54
|
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] }));
|
|
54
55
|
}
|
|
56
|
+
/** Mark fallback for the tile variant when the workspace carries no icon. */
|
|
57
|
+
function workspaceInitial(name) {
|
|
58
|
+
return name.trim().charAt(0).toUpperCase();
|
|
59
|
+
}
|
|
55
60
|
/* v8 ignore next — v8 phantom duplicate function record for WorkspaceSwitcher (default params) */
|
|
56
|
-
export function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter, } = {}) {
|
|
61
|
+
export function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter, variant = 'chip', showLabel = true, } = {}) {
|
|
57
62
|
const { workspace, workspaces } = useMedaShell();
|
|
58
63
|
const resolvedFooter = menuFooter ?? workspaceMenuFooter;
|
|
59
64
|
const useConfiguredItems = Array.isArray(menuItems);
|
|
60
|
-
|
|
65
|
+
// The tile sits in the header's rail column: the mark is the only
|
|
66
|
+
// flow-level child, so it stays centred on the rail's axis, and the chevron
|
|
67
|
+
// hangs off it absolutely rather than pushing it sideways.
|
|
68
|
+
//
|
|
69
|
+
// Its intrinsic height is deliberately bounded — mark 28 + gap 2 + one 14px
|
|
70
|
+
// label line = 44px, with NO vertical padding — so it fits inside
|
|
71
|
+
// `--shell-header-height` even when a consumer tightens that token (the web
|
|
72
|
+
// app runs a 52px header under its window-tab strip). `max-h-full` plus the
|
|
73
|
+
// rail column's `overflow-hidden` is the belt-and-braces guard.
|
|
74
|
+
const tileTrigger = (_jsxs(DropdownMenuTrigger, { "data-meda-workspace-switcher": "tile", render: _jsx("button", { type: "button", "aria-label": `${workspace.name} workspace menu`, className: "flex max-h-full w-full min-w-0 flex-col items-center justify-center gap-0.5 rounded-lg px-1 hover:bg-accent" }), children: [_jsxs("span", { className: "relative inline-flex shrink-0", "aria-hidden": "true", children: [_jsx("span", { className: "inline-flex size-7 items-center justify-center overflow-hidden rounded-lg bg-muted text-[13px] text-foreground font-semibold ring-1 ring-border/70", children: workspace.icon ?? workspaceInitial(workspace.name) }), _jsx(ChevronDown, { size: 11, "aria-hidden": "true", className: "pointer-events-none absolute -right-1.5 -bottom-1 rounded-full bg-background text-muted-foreground" })] }), showLabel && (_jsx("span", { "data-slot": "icon-rail-label", className: "max-w-full truncate text-center font-medium text-[11px] leading-[14px] [@media(max-height:700px)]:hidden", children: workspace.name }))] }));
|
|
75
|
+
const trigger = variant === 'tile' ? (_jsx(TooltipProvider, { children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx("span", { className: "flex max-h-full w-full min-w-0" }), children: tileTrigger }), _jsx(TooltipContent, { side: "bottom", children: workspace.name })] }) })) : (_jsxs(DropdownMenuTrigger, { "data-meda-workspace-switcher": "chip", render: _jsx("button", { type: "button", className: "flex min-w-0 items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-semibold hover:bg-accent" }), children: [workspace.icon != null && (_jsx("span", { className: "inline-flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground ring-1 ring-border/70", "aria-hidden": "true", children: workspace.icon })), _jsx("span", { className: "max-w-[13rem] truncate", children: workspace.name }), _jsx(ChevronDown, { size: 16, "aria-hidden": "true" })] }));
|
|
76
|
+
return (_jsxs(DropdownMenu, { children: [trigger, _jsxs(DropdownMenuContent, { className: "min-w-[240px]", children: [!useConfiguredItems && workspaces.length > 0 && (_jsxs(_Fragment, { children: [workspaces.map((ws) => (_jsxs(DropdownMenuItem, { children: [ws.icon != null && (_jsx("span", { className: "inline-flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted text-foreground ring-1 ring-border/70", "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] })] }));
|
|
61
77
|
}
|
|
62
78
|
/* v8 ignore next — v8 phantom duplicate function record for AppTabs (default params) */
|
|
63
79
|
export function AppTabs({ renderLink } = {}) {
|
|
@@ -117,7 +133,7 @@ export function PanelToggle({ panelViews = [] } = {}) {
|
|
|
117
133
|
})] })] }));
|
|
118
134
|
}
|
|
119
135
|
/* v8 ignore next — v8 phantom duplicate function record for ShellHeader (default params) */
|
|
120
|
-
export function ShellHeader({ globalActions, headerCenter, headerLeading, appTabsRenderLink, className, workspaceMenuItems, workspaceMenuFooter, showPanelToggle = true, panelViews = [], } = {}) {
|
|
136
|
+
export function ShellHeader({ globalActions, headerCenter, headerLeading, appTabsRenderLink, className, workspaceMenuItems, workspaceMenuFooter, showPanelToggle = true, panelViews = [], headerLayout = 'split', railLabelVisibility = 'tooltip', } = {}) {
|
|
121
137
|
const band = useShellViewport();
|
|
122
138
|
if (band === 'mobile')
|
|
123
139
|
return null;
|
|
@@ -125,8 +141,16 @@ export function ShellHeader({ globalActions, headerCenter, headerLeading, appTab
|
|
|
125
141
|
// the WorkspaceSwitcher — separated by whitespace only (no divider line),
|
|
126
142
|
// matching the design prototype's topbar spacing.
|
|
127
143
|
const leadingRegion = headerLeading != null ? (_jsx("div", { className: "ml-2 flex min-w-0 items-center", children: headerLeading })) : null;
|
|
144
|
+
// Rail layout: column 1 is exactly the icon rail's width, so the switcher
|
|
145
|
+
// tile sits on the rail's axis and `headerLeading` starts precisely where
|
|
146
|
+
// the main region starts below. No left padding, `pr-4` on the right.
|
|
147
|
+
if (headerLayout === 'rail') {
|
|
148
|
+
return (_jsxs("header", { "data-meda-shell-header": "", "data-meda-header-layout": "rail", className: cn('grid h-[var(--shell-header-height)] w-full items-center bg-background pr-4', railLabelVisibility === 'visible'
|
|
149
|
+
? 'grid-cols-[var(--shell-rail-label-width)_minmax(0,1fr)_auto]'
|
|
150
|
+
: 'grid-cols-[var(--shell-rail-width)_minmax(0,1fr)_auto]', className), children: [_jsx("div", { className: "flex h-full min-w-0 items-center justify-center overflow-hidden px-1", children: _jsx(WorkspaceSwitcher, { menuItems: workspaceMenuItems, menuFooter: workspaceMenuFooter, variant: "tile", showLabel: railLabelVisibility === 'visible' }) }), _jsx("div", { className: "flex min-w-0 items-center", children: headerLeading }), _jsxs("div", { className: "flex shrink-0 items-center gap-2 justify-self-end pl-4", children: [globalActions, showPanelToggle && _jsx(PanelToggle, { panelViews: panelViews })] })] }));
|
|
151
|
+
}
|
|
128
152
|
if (headerCenter !== undefined) {
|
|
129
|
-
return (_jsxs("header", { className: cn('grid h-[var(--shell-header-height)] w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center', 'gap-4 bg-background px-4', className), children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2 justify-self-start", children: [_jsx(WorkspaceSwitcher, { menuItems: workspaceMenuItems, menuFooter: workspaceMenuFooter }), leadingRegion] }), _jsx("div", { className: "flex min-w-0 items-center justify-center justify-self-center", children: headerCenter }), _jsxs("div", { className: "flex min-w-0 items-center justify-end gap-2 justify-self-end", children: [globalActions, showPanelToggle && _jsx(PanelToggle, { panelViews: panelViews })] })] }));
|
|
153
|
+
return (_jsxs("header", { "data-meda-shell-header": "", "data-meda-header-layout": "split", className: cn('grid h-[var(--shell-header-height)] w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center', 'gap-4 bg-background px-4', className), children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2 justify-self-start", children: [_jsx(WorkspaceSwitcher, { menuItems: workspaceMenuItems, menuFooter: workspaceMenuFooter }), leadingRegion] }), _jsx("div", { className: "flex min-w-0 items-center justify-center justify-self-center", children: headerCenter }), _jsxs("div", { className: "flex min-w-0 items-center justify-end gap-2 justify-self-end", children: [globalActions, showPanelToggle && _jsx(PanelToggle, { panelViews: panelViews })] })] }));
|
|
130
154
|
}
|
|
131
|
-
return (_jsxs("header", { className: cn('flex h-[var(--shell-header-height)] w-full items-center justify-between', 'gap-4 bg-background px-4', className), children: [_jsxs("div", { className: "flex min-w-0 shrink-0 items-center gap-2", children: [_jsx(WorkspaceSwitcher, { menuItems: workspaceMenuItems, menuFooter: workspaceMenuFooter }), leadingRegion] }), _jsx("div", { className: "flex min-w-0 flex-1 items-center", children: _jsx(AppTabs, { renderLink: appTabsRenderLink }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [globalActions, showPanelToggle && _jsx(PanelToggle, { panelViews: panelViews })] })] }));
|
|
155
|
+
return (_jsxs("header", { "data-meda-shell-header": "", "data-meda-header-layout": "split", className: cn('flex h-[var(--shell-header-height)] w-full items-center justify-between', 'gap-4 bg-background px-4', className), children: [_jsxs("div", { className: "flex min-w-0 shrink-0 items-center gap-2", children: [_jsx(WorkspaceSwitcher, { menuItems: workspaceMenuItems, menuFooter: workspaceMenuFooter }), leadingRegion] }), _jsx("div", { className: "flex min-w-0 flex-1 items-center", children: _jsx(AppTabs, { renderLink: appTabsRenderLink }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [globalActions, showPanelToggle && _jsx(PanelToggle, { panelViews: panelViews })] })] }));
|
|
132
156
|
}
|
package/dist/shell/types.d.ts
CHANGED
|
@@ -49,6 +49,19 @@ export interface PanelView {
|
|
|
49
49
|
}
|
|
50
50
|
export type PanelMode = 'closed' | 'panel' | 'expanded' | 'fullscreen';
|
|
51
51
|
export type ShellMainLayout = 'workspace' | 'centered' | 'fullbleed';
|
|
52
|
+
/**
|
|
53
|
+
* Desktop header grid.
|
|
54
|
+
*
|
|
55
|
+
* - `split` (default, unchanged) — the workspace switcher and `headerLeading`
|
|
56
|
+
* share the LEFT region; `headerCenter`, when supplied, owns the middle and
|
|
57
|
+
* the leading region is capped at roughly half the window.
|
|
58
|
+
* - `rail` — a three-column grid `[rail column | fill | actions]`. Column 1 is
|
|
59
|
+
* exactly as wide as the icon rail below it and holds the workspace switcher
|
|
60
|
+
* in its `tile` variant; column 2 is `headerLeading` and gets ALL remaining
|
|
61
|
+
* width; column 3 is `globalActions` (plus the panel toggle). `headerCenter`
|
|
62
|
+
* is ignored in this layout.
|
|
63
|
+
*/
|
|
64
|
+
export type ShellHeaderLayout = 'split' | 'rail';
|
|
52
65
|
export type ShellViewport = 'mobile' | 'tablet' | 'desktop' | 'wide' | 'ultrawide';
|
|
53
66
|
export type ContextRailHeader = 'auto' | 'visible' | 'hidden';
|
|
54
67
|
export type ContextRailScroll = 'auto' | 'none';
|
|
@@ -116,6 +129,23 @@ export interface AppShellMobileNavConfig {
|
|
|
116
129
|
tree: MobileNavTree;
|
|
117
130
|
/** The `to` of the currently-active row, for highlighting. */
|
|
118
131
|
activeTo?: string;
|
|
132
|
+
/**
|
|
133
|
+
* The `id` of the currently-active APP (a `dock` item id and/or a
|
|
134
|
+
* `tree` row id). When set it wins over `activeTo` for:
|
|
135
|
+
*
|
|
136
|
+
* - the dock — a slot lights when `item.id === activeId`, so the dock stays
|
|
137
|
+
* lit on every route inside that app instead of only on its first tab;
|
|
138
|
+
* - the workspace sheet — that row is the one expanded and scrolled into
|
|
139
|
+
* view when the sheet opens.
|
|
140
|
+
*
|
|
141
|
+
* Omit it to keep the pre-2.8 behaviour (both derive from `activeTo`).
|
|
142
|
+
*/
|
|
143
|
+
activeId?: string;
|
|
144
|
+
/**
|
|
145
|
+
* Render the current row (see `activeId` / `activeTo`) first inside its
|
|
146
|
+
* group in the workspace sheet. Defaults to `false` — the tree order.
|
|
147
|
+
*/
|
|
148
|
+
currentFirst?: boolean;
|
|
119
149
|
/** Render dock/sheet targets as router links (else plain `<a>`). */
|
|
120
150
|
renderLink?: (args: MobileNavLinkArgs) => ReactNode;
|
|
121
151
|
/**
|
|
@@ -183,6 +213,14 @@ export interface AppShellContextRailConfig {
|
|
|
183
213
|
export interface AppShellRightPanelConfig {
|
|
184
214
|
panelViews: PanelView[];
|
|
185
215
|
defaultView?: string;
|
|
216
|
+
/**
|
|
217
|
+
* Whether the header renders its `PanelToggle`. Defaults to `true`, i.e. the
|
|
218
|
+
* toggle shows whenever at least one panel view is registered (the pre-2.8
|
|
219
|
+
* behaviour). Set `false` to keep the panel views — and every other way of
|
|
220
|
+
* opening them, such as `useMedaShell().panel.focus(id)` or a command — while
|
|
221
|
+
* dropping the header control.
|
|
222
|
+
*/
|
|
223
|
+
showToggle?: boolean;
|
|
186
224
|
}
|
|
187
225
|
/**
|
|
188
226
|
* A single configurable item in the WorkspaceSwitcher dropdown.
|
package/package.json
CHANGED
package/skills/shell/SKILL.md
CHANGED
|
@@ -21,11 +21,69 @@ The package exports a single `<AppShell>` component with a discriminated `varian
|
|
|
21
21
|
| Variant | When to use | Key config |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `'auth'` | Sign-in / sign-up / password reset / OAuth callbacks. Lets the form scroll past viewport (dense forms, high zoom). | `auth`, `branding`, optional `preview` (right-side art) + `actions` (top-right) |
|
|
24
|
-
| `'workspace'` | Logged-in product shell. Has icon rail + context rail + header + main + optional right panel. | `iconRail`, `contextRail`, `rightPanel`, `workspace` (menu items override), `appTabs` (router integration), `headerCenter`, `banners`, `mainLayout`, `globalActions` |
|
|
24
|
+
| `'workspace'` | Logged-in product shell. Has icon rail + context rail + header + main + optional right panel. | `iconRail`, `contextRail`, `rightPanel`, `workspace` (menu items override), `appTabs` (router integration), `headerCenter`, `headerLeading`, `headerLayout`, `banners`, `mainLayout`, `globalActions`, `mobileNav` |
|
|
25
25
|
| `'chat'` | Chat-first surfaces (full-bleed messaging UI; no rails). | `globalActions` |
|
|
26
26
|
|
|
27
27
|
`AppShellWorkspace.workspace.menuItems` REPLACES the default workspace dropdown ("Manage workspaces / Settings / Profile / Sign out") when provided. **The theme toggle is preserved automatically** — consumers do not have to re-implement theme cycling.
|
|
28
28
|
|
|
29
|
+
## Header layout — `split` (default) vs `rail`
|
|
30
|
+
|
|
31
|
+
`headerLayout` picks the desktop header grid. `split` is the historic layout and stays the default.
|
|
32
|
+
|
|
33
|
+
| | `split` (default) | `rail` |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| Grid | `[1fr, auto, 1fr]` when `headerCenter` is set, else flex | `[rail width, minmax(0,1fr), auto]` |
|
|
36
|
+
| Column 1 | workspace switcher (`chip`) + `headerLeading` | workspace switcher (`tile`), centred on the rail's axis |
|
|
37
|
+
| Column 2 | `headerCenter` | `headerLeading`, `min-w-0`, **all** remaining width |
|
|
38
|
+
| Column 3 | `globalActions` + `PanelToggle` | `globalActions` + `PanelToggle` |
|
|
39
|
+
| `headerCenter` | rendered | **ignored** |
|
|
40
|
+
| Padding | `px-4` | none on the left, `pr-4` |
|
|
41
|
+
|
|
42
|
+
Reach for `rail` when the app's section tabs live in the header: in `split` they are capped at
|
|
43
|
+
roughly half the window AND they shift horizontally with the workspace name, because the switcher
|
|
44
|
+
sizes to that name. In `rail` the switcher is boxed into the rail column, so the tabs start at a
|
|
45
|
+
fixed x — the same x the main region starts at below.
|
|
46
|
+
|
|
47
|
+
Column 1's width comes from `iconRail.labelVisibility`: `--shell-rail-label-width` for `visible`,
|
|
48
|
+
`--shell-rail-width` for the default `tooltip`. `AppShell` wires this for you; if you render
|
|
49
|
+
`<ShellHeader>` by hand, mirror it into `railLabelVisibility` yourself or the header and the rail
|
|
50
|
+
will disagree.
|
|
51
|
+
|
|
52
|
+
**Target the header with `data-meda-shell-header`**, present on both layouts (alongside
|
|
53
|
+
`data-meda-header-layout="split" | "rail"`). Do NOT reach for it structurally — a selector like
|
|
54
|
+
`.flex.h-svh > header` breaks silently on any markup change.
|
|
55
|
+
|
|
56
|
+
## WorkspaceSwitcher variants
|
|
57
|
+
|
|
58
|
+
`variant="chip"` (default) is the horizontal mark · name · chevron button. `variant="tile"` is the
|
|
59
|
+
rail-column shape the `rail` header uses: a full-width button with the mark on the rail axis, the
|
|
60
|
+
name beneath it in `data-slot="icon-rail-label"` type, and a small chevron hung off the mark so
|
|
61
|
+
the mark itself never leaves the axis. Its accessible name is `"<workspace name> workspace menu"`.
|
|
62
|
+
Pass `showLabel={false}` in icon-only rail mode. The dropdown — items, theme toggle, footer,
|
|
63
|
+
keyboard and dismiss behaviour — is identical in both.
|
|
64
|
+
|
|
65
|
+
**The tile's height is a budget, not a suggestion.** It has to fit inside
|
|
66
|
+
`--shell-header-height`, which consumers retune (the web app runs 52px under its desktop
|
|
67
|
+
window-tab strip) and which is a fixed `height` — so anything taller overlaps the chrome around
|
|
68
|
+
it rather than growing the header. The tile spends 28px on the mark + a 2px gap + ONE 14px label
|
|
69
|
+
line = 44px, with **no vertical padding**, plus `max-h-full` and an `overflow-hidden` rail column.
|
|
70
|
+
Change any of those and re-check the sum against 52px, not 64px.
|
|
71
|
+
|
|
72
|
+
That one-line label truncates, and hides entirely below 700px viewport height like every other
|
|
73
|
+
rail label. So the tile always renders a tooltip carrying the full workspace name — the same
|
|
74
|
+
`Tooltip` primitive the icon rail uses, rendered unconditionally rather than on the rail's
|
|
75
|
+
`!showLabel || isShortViewport` rule, because unlike a rail item the tile can also be
|
|
76
|
+
visible-but-truncated. **Any new rail-width control whose label can truncate or hide needs the
|
|
77
|
+
same tooltip:** an accessible name alone is not a substitute for a sighted user.
|
|
78
|
+
|
|
79
|
+
## Keeping panel views without the header toggle
|
|
80
|
+
|
|
81
|
+
`rightPanel.showToggle: false` drops the header's `PanelToggle` while the views stay registered and
|
|
82
|
+
openable from anywhere else (`useMedaShell().panel.focus(id)`, a command, a route). It cannot do
|
|
83
|
+
the reverse — a toggle with no views is still hidden, because it would be a dead end. Do NOT hide
|
|
84
|
+
the toggle with consumer CSS such as `[data-meda-global-actions] + * { display: none }`; that is
|
|
85
|
+
what this prop replaces.
|
|
86
|
+
|
|
29
87
|
## MedaShellProvider — the runtime root
|
|
30
88
|
|
|
31
89
|
Wrap your app once with `<MedaShellProvider>` (typically in the root layout). Props:
|
|
@@ -129,6 +187,26 @@ Each hook auto-handles register-on-mount and unregister-on-unmount via `useEffec
|
|
|
129
187
|
|
|
130
188
|
The default palette hotkey is `'mod+k'` — override via `MedaShellProvider.commandPaletteHotkey`. Hotkey matching is strict modifier-aware: `'mod+k'` does NOT fire on `mod+shift+k`. Use `'mod'` (resolves to ⌘ on macOS, Ctrl on Windows/Linux), not platform-specific keywords.
|
|
131
189
|
|
|
190
|
+
## Mobile dock + workspace sheet — light by app, open where you are
|
|
191
|
+
|
|
192
|
+
`mobileNav.activeTo` is the exact address of the active row. On its own it makes the dock go dark
|
|
193
|
+
as soon as the user leaves an app's first tab, because a dock slot only lights on
|
|
194
|
+
`item.to === activeTo`. Pass `mobileNav.activeId` — the active APP's id — alongside it:
|
|
195
|
+
|
|
196
|
+
- **Dock:** a slot is active iff `item.id === activeId`. Action slots (`open-sheet`, `open-ai`,
|
|
197
|
+
`open-command-palette`) light only through `activeId`, never through the `activeTo` fallback.
|
|
198
|
+
An `emphasis: 'brand'` slot keeps its brand disc when active and gains a ring plus a primary
|
|
199
|
+
label — **active always outranks brand.** A slot that carries `aria-current="page"` must carry a
|
|
200
|
+
visible treatment too; a variant branch that reassigns the tone after the active check (the
|
|
201
|
+
`let toneClass` ladder in `mobile-dock.tsx`) is exactly how that regresses.
|
|
202
|
+
- **Workspace sheet:** that row is expanded and scrolled into view on every open. The user can
|
|
203
|
+
still collapse it, and it re-expands on the next open. With no `activeId` the sheet derives the
|
|
204
|
+
row from `activeTo` — an exact `to` match first, then the row that owns `activeTo` among its
|
|
205
|
+
preset `views`.
|
|
206
|
+
- **`mobileNav.currentFirst`:** renders that row first inside its group.
|
|
207
|
+
|
|
208
|
+
Omit `activeId` and both surfaces behave exactly as they did before it existed.
|
|
209
|
+
|
|
132
210
|
## Right panel patterns
|
|
133
211
|
|
|
134
212
|
Use a single `RightPanel` per shell. Don't build a parallel right-side surface — multiple right panels create state and dismiss-behavior conflicts. For a stacked detail experience, register multiple `PanelView`s with the existing `PanelViewsProvider` (`src/shell/panel-views-provider.tsx`).
|
|
@@ -149,3 +227,10 @@ Use a single `RightPanel` per shell. Don't build a parallel right-side surface
|
|
|
149
227
|
| Multiple `RightPanel`s in one shell | Dismiss/state conflicts | Use `PanelViewsProvider` for stacked detail |
|
|
150
228
|
| Forking `MedaShellProvider` per app | Loses cross-consumer parity | Compose around it; pass a custom `ThemeAdapter` for theme integration |
|
|
151
229
|
| Hard-coded modifier in hotkey strings (`'cmd+k'`) | Breaks on Windows/Linux | Use `'mod+k'` — resolves per-platform |
|
|
230
|
+
| Selecting the header structurally (`.flex.h-svh > header`) | Breaks silently on any markup change | Target `[data-meda-shell-header]` |
|
|
231
|
+
| Hiding the panel toggle with consumer CSS | Depends on internal sibling order | `rightPanel={{ showToggle: false }}` |
|
|
232
|
+
| Passing `headerCenter` together with `headerLayout="rail"` | The rail grid has no centre column; the node never renders | Put the content in `headerLeading` |
|
|
233
|
+
| Lighting the mobile dock from `activeTo` alone | Goes dark on every route past an app's first tab | Also pass `mobileNav.activeId` |
|
|
234
|
+
| A rail-width control whose label truncates or hides, with no tooltip | The name becomes unrecoverable for sighted users | Add the `Tooltip` the icon rail and switcher tile use |
|
|
235
|
+
| Letting a variant branch (brand, emphasis) reassign the tone after the active check | `aria-current` with no visible state | Test the active state of every variant, not just the default one |
|
|
236
|
+
| Adding vertical padding or a second label line to the switcher tile | Overflows a 52px `--shell-header-height` | Keep the tile inside its 44px budget |
|