@parallelworks/ui 0.92.0 → 0.93.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 CHANGED
@@ -8,6 +8,7 @@ so an application ships only what it imports:
8
8
  | `.` | theme contract, primitives (buttons, inputs, tables, tooltips, modals, dropdowns, settings layouts, page header), `UIProvider` |
9
9
  | `./theme` | theme seeds and presets, `generateTheme`, `applyTheme`, `fitSeed` |
10
10
  | `./icons` | the icon barrel |
11
+ | `./app-frame` | inset application frame, resizable navigation, icon rail, and mobile drawer |
11
12
  | `./list` | list pages, tables, row menus and list controls |
12
13
  | `./form` | `DynamicForm` and its field registry |
13
14
  | `./graph` | job dependency graph and tree view, and the visual workflow editor: the graph, the run form as an inputs editor, and their dialogs |
@@ -16,7 +17,7 @@ so an application ships only what it imports:
16
17
  | `./file-explorer` | object storage explorer over a host-supplied provider |
17
18
  | `./access` | `AccessDrawer`: who has access to a resource, and granting roles to people and groups |
18
19
  | `./ai`, `./ai/openai`, `./ai/mock` | chat interface and adapters |
19
- | `./theme.css`, `./base.css`, `./fonts.css`, `./styles.css`, `./logviewer.css`, `./ai.css` | stylesheets |
20
+ | `./theme.css`, `./base.css`, `./fonts.css`, `./styles.css`, `./logviewer.css`, `./ai.css`, `./app-frame.css` | stylesheets |
20
21
 
21
22
  Hosts bind strings, navigation, data hooks and the workflow engine through
22
23
  `UIProvider`, which also shows the package's toasts:
@@ -1245,3 +1246,75 @@ colors and sizing can obscure labels or make controls hard to use. Keep trusted
1245
1246
  confirmation summaries, permissions, and security notices outside the authored
1246
1247
  layout. Full stylesheets or mutually untrusted embedded applications require a
1247
1248
  separate isolation design.
1249
+
1250
+ ## Application frame
1251
+
1252
+ `AppFrame` provides a sidebar and an inset content surface. The sidebar collapses
1253
+ to a 64px icon rail, resizes with a pointer or keyboard, and becomes a modal
1254
+ navigation drawer below 768px. Its header, scrolling navigation, and footer stay
1255
+ separate from the page's scrolling content. Long navigation labels truncate within
1256
+ the current sidebar width; badges remain visible. Navigation scrollbar tracks stay
1257
+ hidden while wheel and keyboard scrolling keep destinations reachable in short
1258
+ containers.
1259
+
1260
+ ```tsx
1261
+ import { AppFrame, AppFrameNavItem, AppFrameToggle } from '@parallelworks/ui/app-frame'
1262
+ import '@parallelworks/ui/app-frame.css'
1263
+
1264
+ <AppFrame
1265
+ navigationKey={pathname}
1266
+ brand={({ expanded }) => expanded ? <Wordmark /> : <Mark />}
1267
+ navigation={
1268
+ <nav aria-label="Main navigation">
1269
+ <AppFrameNavItem label="Projects" icon={<ProjectsIcon />} href="/projects" selected />
1270
+ </nav>
1271
+ }
1272
+ footer={({ isMobile }) => !isMobile && <AppFrameToggle />}
1273
+ header={<PageTools />}
1274
+ >
1275
+ <ProjectList />
1276
+ </AppFrame>
1277
+ ```
1278
+
1279
+ `PageTools` can use `useAppFrame().isMobile` to show an `AppFrameToggle` on small
1280
+ screens. The drawer supplies its own close button, traps focus, closes on Escape
1281
+ or an outside click, and returns focus to its opener. Navigation items close it
1282
+ on an ordinary click; modified links keep the current view in place. A changed
1283
+ `navigationKey` also dismisses it.
1284
+ Reopening mobile navigation restores its scroll position within the current
1285
+ section. Closing it unmounts its contents, including any nested popups.
1286
+ Use `navigationResetKey` for a section identifier when navigation scroll should
1287
+ survive route changes within that section. It defaults to `navigationKey`.
1288
+ `sidebarProps` passes host attributes, styling, and events to the desktop sidebar
1289
+ and mobile dialog, for example a navigation context menu. The frame retains its
1290
+ own layout, dialog semantics, and measured width.
1291
+
1292
+ The host owns routing, persistence, identity, permissions, and page tools. Links
1293
+ use `UIProvider`'s link slot. Pass `expanded`/`onExpandedChange` and
1294
+ `width`/`onWidthChange` to persist preferences, or use the uncontrolled defaults.
1295
+ Pointer drags preview their width locally and call `onWidthChange` once when the
1296
+ gesture ends, so persistence does not run on every pointer movement.
1297
+ `defaultWidth` is 240, bounded by `minWidth` (200) and `maxWidth` (400); compact
1298
+ applications can choose smaller bounds. The resize handle supports arrows, Home,
1299
+ End, drag-to-collapse, and double-click to restore `defaultWidth`. Logical layout
1300
+ and resizing support right-to-left direction. Reduced motion disables resizing
1301
+ and drawer transitions. `resizable={false}` removes the handle.
1302
+
1303
+ `brand`, `navigation`, and `footer` accept nodes or functions receiving the frame
1304
+ state. `header` sits above the main content; `banner` spans both columns.
1305
+ `fullBleed` removes the 8px inset and 12px rounded border. The frame fills the
1306
+ viewport by default; pass `style={{ height: '100%' }}` for a bounded container.
1307
+ The mobile drawer stays inside the frame, below its banner, and uses the chosen
1308
+ sidebar width, capped to leave 48px for dismissal. It slides from the navigation
1309
+ edge while the backdrop fades; both finish in 200ms and respect reduced motion.
1310
+
1311
+ The five `--theme-sidebar-*` tokens produced by `generateTheme` style navigation:
1312
+ `bg`, `text`, `hover`, `active-bg`, and `active-text`. Without them the frame uses
1313
+ the ordinary surface and accent tokens. Content uses `--theme-app-bg` and
1314
+ `--theme-border`. Supply translated `labels` for navigation, skip, open, close,
1315
+ expand, collapse, and resize actions. The frame owns the main landmark and skip
1316
+ link; put page content inside it without nesting a second `<main>`.
1317
+
1318
+ Frame styles use the `pw-ui` cascade layer. Put broad host element defaults
1319
+ (such as anchor colors and scrollbar styling) in a lower-priority layer declared
1320
+ before the package styles, so they do not replace navigation states.
@@ -0,0 +1,281 @@
1
+ @layer pw-ui {
2
+ /* The frame uses the same sidebar tokens as the theme generator. */
3
+ .pw-app-frame {
4
+ display: flex;
5
+ flex-direction: column;
6
+ height: 100dvh;
7
+ min-height: 0;
8
+ isolation: isolate;
9
+ background: var(--theme-sidebar-bg, var(--theme-muted-panel-bg));
10
+ color: var(--theme-app);
11
+ }
12
+ .pw-frame-body {
13
+ display: flex;
14
+ flex: 1;
15
+ min-height: 0;
16
+ position: relative;
17
+ overflow: hidden;
18
+ }
19
+ .pw-frame-sidebar,
20
+ .pw-frame-drawer {
21
+ display: flex;
22
+ flex-direction: column;
23
+ flex-shrink: 0;
24
+ min-height: 0;
25
+ background: var(--theme-sidebar-bg, var(--theme-muted-panel-bg));
26
+ color: var(--theme-sidebar-text, var(--theme-app));
27
+ }
28
+ .pw-frame-sidebar {
29
+ position: relative;
30
+ transition: width 200ms cubic-bezier(0.25, 0.1, 0.25, 1);
31
+ }
32
+ .pw-frame-sidebar[data-resizing] {
33
+ transition: none;
34
+ user-select: none;
35
+ }
36
+ .pw-frame-brand {
37
+ padding: 12px 12px 4px;
38
+ flex-shrink: 0;
39
+ min-width: 0;
40
+ overflow: hidden;
41
+ }
42
+ .pw-frame-links {
43
+ flex: 1;
44
+ overflow-x: hidden;
45
+ overflow-y: auto;
46
+ padding: 12px;
47
+ min-width: 0;
48
+ min-height: 0;
49
+ scrollbar-width: none;
50
+ }
51
+ .pw-frame-links::-webkit-scrollbar {
52
+ display: none;
53
+ }
54
+ .pw-frame-links nav {
55
+ display: grid;
56
+ grid-template-columns: minmax(0, 1fr);
57
+ gap: 4px;
58
+ }
59
+ .pw-frame-footer {
60
+ flex-shrink: 0;
61
+ padding: 8px 12px;
62
+ }
63
+ .pw-frame-nav-item {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 8px;
67
+ min-height: 36px;
68
+ min-width: 0;
69
+ width: 100%;
70
+ border: 0;
71
+ border-radius: 8px;
72
+ padding: 8px;
73
+ background: transparent;
74
+ color: inherit;
75
+ font: inherit;
76
+ font-size: 14px;
77
+ font-weight: 500;
78
+ line-height: 20px;
79
+ cursor: pointer;
80
+ text-decoration: none;
81
+ text-align: start;
82
+ transition: background-color 150ms ease-out;
83
+ }
84
+ .pw-frame-nav-item:hover,
85
+ .pw-frame-toggle:hover {
86
+ background: var(--theme-sidebar-hover, var(--theme-hover));
87
+ }
88
+ .pw-frame-nav-item[aria-current] {
89
+ background: var(--theme-sidebar-active-bg, var(--theme-accent-subtle));
90
+ color: var(--theme-sidebar-active-text, var(--theme-accent));
91
+ }
92
+ .pw-frame-nav-item:is(:disabled, [aria-disabled]) {
93
+ opacity: 0.5;
94
+ cursor: not-allowed;
95
+ }
96
+ .pw-frame-nav-icon {
97
+ display: flex;
98
+ flex: 0 0 18px;
99
+ align-items: center;
100
+ justify-content: center;
101
+ }
102
+ .pw-frame-nav-icon svg {
103
+ width: 18px;
104
+ height: 18px;
105
+ }
106
+ .pw-frame-nav-label {
107
+ flex: 1;
108
+ min-width: 0;
109
+ overflow: hidden;
110
+ text-overflow: ellipsis;
111
+ white-space: nowrap;
112
+ }
113
+ .pw-frame-nav-badge {
114
+ flex-shrink: 0;
115
+ font-size: 12px;
116
+ font-variant-numeric: tabular-nums;
117
+ }
118
+ [data-sidebar-mode="rail"] .pw-frame-nav-item {
119
+ padding-inline: 0;
120
+ justify-content: center;
121
+ height: 40px;
122
+ }
123
+ .pw-frame-toggle {
124
+ display: inline-flex;
125
+ align-items: center;
126
+ justify-content: center;
127
+ width: 36px;
128
+ height: 36px;
129
+ border: 0;
130
+ border-radius: 8px;
131
+ padding: 0;
132
+ color: inherit;
133
+ background: transparent;
134
+ cursor: pointer;
135
+ flex-shrink: 0;
136
+ }
137
+ .pw-frame-toggle svg {
138
+ width: 18px;
139
+ height: 18px;
140
+ }
141
+ .pw-frame-sidebar :focus-visible,
142
+ .pw-frame-drawer :focus-visible {
143
+ outline: 2px solid currentColor;
144
+ outline-offset: 2px;
145
+ }
146
+ .pw-frame-resize {
147
+ position: absolute;
148
+ z-index: 1;
149
+ inset-block: 0;
150
+ inset-inline-end: -4px;
151
+ width: 8px;
152
+ cursor: col-resize;
153
+ touch-action: none;
154
+ }
155
+ .pw-frame-resize::after {
156
+ content: "";
157
+ position: absolute;
158
+ inset-block: 8px;
159
+ inset-inline-start: 3px;
160
+ width: 2px;
161
+ border-radius: 2px;
162
+ }
163
+ .pw-frame-resize:hover::after,
164
+ .pw-frame-resize:focus-visible::after {
165
+ background: var(--theme-sidebar-text, var(--theme-accent));
166
+ opacity: 0.5;
167
+ }
168
+ .pw-frame-surface-wrap {
169
+ flex: 1;
170
+ display: flex;
171
+ flex-direction: column;
172
+ min-width: 0;
173
+ min-height: 0;
174
+ padding: 8px;
175
+ padding-inline-start: 0;
176
+ }
177
+ .pw-frame-surface {
178
+ flex: 1;
179
+ display: flex;
180
+ flex-direction: column;
181
+ min-width: 0;
182
+ min-height: 0;
183
+ overflow: hidden;
184
+ isolation: isolate;
185
+ border: 1px solid var(--theme-border);
186
+ border-radius: 12px;
187
+ background: var(--theme-app-bg);
188
+ background-clip: padding-box;
189
+ }
190
+ .pw-frame-header,
191
+ .pw-frame-banner {
192
+ flex-shrink: 0;
193
+ }
194
+ .pw-frame-main {
195
+ flex: 1;
196
+ min-width: 0;
197
+ min-height: 0;
198
+ overflow: auto;
199
+ }
200
+ .pw-app-frame[data-full-bleed] .pw-frame-surface-wrap {
201
+ padding: 0;
202
+ }
203
+ .pw-app-frame[data-full-bleed] .pw-frame-surface {
204
+ border: 0;
205
+ border-radius: 0;
206
+ }
207
+ .pw-app-frame[data-mobile] .pw-frame-surface-wrap {
208
+ padding: 8px;
209
+ }
210
+ .pw-app-frame[data-mobile][data-full-bleed] .pw-frame-surface-wrap {
211
+ padding: 0;
212
+ }
213
+ .pw-frame-backdrop {
214
+ position: absolute;
215
+ inset: 0;
216
+ z-index: var(--layer-drawer, 1100);
217
+ background: var(--theme-backdrop, rgb(0 0 0 / 0.35));
218
+ transition: opacity 200ms cubic-bezier(0.25, 0.1, 0.25, 1);
219
+ }
220
+ .pw-frame-backdrop[data-starting-style],
221
+ .pw-frame-backdrop[data-ending-style] {
222
+ opacity: 0;
223
+ }
224
+ .pw-frame-drawer {
225
+ position: absolute;
226
+ inset-block: 8px;
227
+ inset-inline-start: 8px;
228
+ max-width: calc(100% - 48px);
229
+ z-index: var(--layer-drawer, 1100);
230
+ border-radius: 12px;
231
+ box-shadow: 0 12px 32px rgb(0 0 0 / 0.2);
232
+ outline: none;
233
+ transform: translateX(0);
234
+ transition: transform 200ms cubic-bezier(0.25, 0.1, 0.25, 1);
235
+ }
236
+ .pw-frame-drawer[data-starting-style],
237
+ .pw-frame-drawer[data-ending-style] {
238
+ transform: translateX(calc(-100% - 16px));
239
+ }
240
+ .pw-frame-drawer:dir(rtl)[data-starting-style],
241
+ .pw-frame-drawer:dir(rtl)[data-ending-style] {
242
+ transform: translateX(calc(100% + 16px));
243
+ }
244
+ .pw-frame-mobile-close {
245
+ display: flex;
246
+ justify-content: flex-end;
247
+ padding: 4px 4px 0;
248
+ }
249
+ .pw-frame-drawer .pw-frame-brand {
250
+ padding-top: 0;
251
+ }
252
+ .pw-frame-drawer .pw-frame-nav-item,
253
+ .pw-app-frame[data-mobile] .pw-frame-toggle {
254
+ min-height: 44px;
255
+ }
256
+ .pw-app-frame[data-mobile] .pw-frame-toggle {
257
+ width: 44px;
258
+ height: 44px;
259
+ }
260
+ .pw-frame-skip {
261
+ position: fixed;
262
+ inset-block-start: -100px;
263
+ inset-inline-start: 8px;
264
+ padding: 8px 16px;
265
+ border-radius: 6px;
266
+ background: var(--theme-panel-bg);
267
+ color: var(--theme-app);
268
+ z-index: var(--layer-popover, 1400);
269
+ }
270
+ .pw-frame-skip:focus {
271
+ inset-block-start: 8px;
272
+ }
273
+ @media (prefers-reduced-motion: reduce) {
274
+ .pw-frame-sidebar,
275
+ .pw-frame-backdrop,
276
+ .pw-frame-drawer,
277
+ .pw-frame-nav-item {
278
+ transition: none;
279
+ }
280
+ }
281
+ }
@@ -0,0 +1,73 @@
1
+ import { type HTMLAttributes, type MouseEvent, type ReactNode } from 'react';
2
+ import { type LinkTarget } from '../components/AppLink.js';
3
+ export interface AppFrameLabels {
4
+ navigation: string;
5
+ skipToContent: string;
6
+ openNavigation: string;
7
+ closeNavigation: string;
8
+ expandSidebar: string;
9
+ collapseSidebar: string;
10
+ resizeSidebar: string;
11
+ }
12
+ export interface AppFrameState {
13
+ /** Labels stay visible in the mobile drawer even when the desktop rail is collapsed. */
14
+ expanded: boolean;
15
+ isMobile: boolean;
16
+ mobileOpen: boolean;
17
+ toggle: () => void;
18
+ closeNavigation: () => void;
19
+ }
20
+ export declare function useAppFrame(): AppFrameState;
21
+ type Slot = ReactNode | ((state: AppFrameState) => ReactNode);
22
+ export interface AppFrameProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
23
+ children: ReactNode;
24
+ /** Identity at the top of navigation. Render a compact mark when expanded is false. */
25
+ brand?: Slot;
26
+ navigation: Slot;
27
+ footer?: Slot;
28
+ /** Page tools above the independently scrolling main content. */
29
+ header?: ReactNode;
30
+ /** Optional notice above both navigation and content. */
31
+ banner?: ReactNode;
32
+ expanded?: boolean;
33
+ defaultExpanded?: boolean;
34
+ onExpandedChange?: (expanded: boolean) => void;
35
+ width?: number;
36
+ defaultWidth?: number;
37
+ /** Commits once when a drag ends, and on each keyboard or reset action. */
38
+ onWidthChange?: (width: number) => void;
39
+ minWidth?: number;
40
+ maxWidth?: number;
41
+ railWidth?: number;
42
+ resizable?: boolean;
43
+ /** Viewport width below which navigation becomes a modal drawer. */
44
+ mobileBreakpoint?: number;
45
+ /** A changed route key closes mobile navigation. */
46
+ navigationKey?: string;
47
+ /** Reset navigation scroll only when this section changes; defaults to navigationKey. */
48
+ navigationResetKey?: string;
49
+ /** Attributes shared by desktop navigation and the mobile dialog. */
50
+ sidebarProps?: HTMLAttributes<HTMLElement>;
51
+ /** Removes the inset and border around content. */
52
+ fullBleed?: boolean;
53
+ labels?: Partial<AppFrameLabels>;
54
+ mainId?: string;
55
+ mainClassName?: string;
56
+ }
57
+ /** An inset application surface beside a resizable icon rail or navigation column.
58
+ * Router, identity, persistence and permissions belong to the host. Import app-frame.css. */
59
+ export declare function AppFrame({ children, brand, navigation, footer, header, banner, expanded: controlledExpanded, defaultExpanded, onExpandedChange, width: controlledWidth, defaultWidth, onWidthChange, minWidth, maxWidth, railWidth, resizable, mobileBreakpoint, navigationKey, navigationResetKey, sidebarProps, fullBleed, labels: labelOverrides, mainId: suppliedMainId, mainClassName, className, style, ...props }: AppFrameProps): import("react").JSX.Element;
60
+ /** Put in the page header on mobile, and the sidebar footer on desktop. */
61
+ export declare function AppFrameToggle({ className, ...props }: Omit<HTMLAttributes<HTMLButtonElement>, 'children'>): import("react").JSX.Element;
62
+ export interface AppFrameNavItemProps extends LinkTarget {
63
+ label: string;
64
+ icon: ReactNode;
65
+ selected?: boolean;
66
+ badge?: ReactNode;
67
+ disabled?: boolean;
68
+ onClick?: (event: MouseEvent<HTMLElement>) => void;
69
+ className?: string;
70
+ }
71
+ /** Router-aware navigation that keeps icon-only rail items named and discoverable. */
72
+ export declare function AppFrameNavItem({ label, icon, selected, badge, disabled, onClick, className, ...target }: AppFrameNavItemProps): import("react").JSX.Element;
73
+ export {};
@@ -0,0 +1,369 @@
1
+ import { cx } from "../cx.js";
2
+ import { CloseIcon, SidebarCloseIcon, SidebarOpenIcon } from "../icons.js";
3
+ import { Tooltip } from "../components/Tooltip.js";
4
+ import { AppLink, isPlainClick } from "../components/AppLink.js";
5
+ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
6
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
+ import { Dialog } from "@base-ui/react/dialog";
8
+ //#region src/app-frame/index.tsx
9
+ var defaultLabels = {
10
+ navigation: "Main navigation",
11
+ skipToContent: "Skip to content",
12
+ openNavigation: "Open navigation",
13
+ closeNavigation: "Close navigation",
14
+ expandSidebar: "Expand sidebar",
15
+ collapseSidebar: "Collapse sidebar",
16
+ resizeSidebar: "Resize sidebar"
17
+ };
18
+ var Context = createContext(null);
19
+ function useAppFrame() {
20
+ const context = useContext(Context);
21
+ if (!context) throw new Error("useAppFrame requires AppFrame");
22
+ return context;
23
+ }
24
+ function useFrameContext() {
25
+ const context = useContext(Context);
26
+ if (!context) throw new Error("App frame controls require AppFrame");
27
+ return context;
28
+ }
29
+ function useMobile(breakpoint) {
30
+ const media = useMemo(() => typeof window === "undefined" ? null : window.matchMedia(`(max-width: ${breakpoint - 1}px)`), [breakpoint]);
31
+ return useSyncExternalStore(useMemo(() => (changed) => {
32
+ media?.addEventListener("change", changed);
33
+ return () => media?.removeEventListener("change", changed);
34
+ }, [media]), () => media?.matches ?? false, () => false);
35
+ }
36
+ function slot(value, state) {
37
+ return typeof value === "function" ? value(state) : value;
38
+ }
39
+ /** An inset application surface beside a resizable icon rail or navigation column.
40
+ * Router, identity, persistence and permissions belong to the host. Import app-frame.css. */
41
+ function AppFrame({ children, brand, navigation, footer, header, banner, expanded: controlledExpanded, defaultExpanded = true, onExpandedChange, width: controlledWidth, defaultWidth = 240, onWidthChange, minWidth = 200, maxWidth = 400, railWidth = 64, resizable = true, mobileBreakpoint = 768, navigationKey, navigationResetKey = navigationKey, sidebarProps, fullBleed = false, labels: labelOverrides, mainId: suppliedMainId, mainClassName, className, style, ...props }) {
42
+ const id = useId();
43
+ const mainId = suppliedMainId ?? `${id}-main`;
44
+ const navigationId = `${id}-navigation`;
45
+ const [ownExpanded, setOwnExpanded] = useState(defaultExpanded);
46
+ const [ownWidth, setOwnWidth] = useState(defaultWidth);
47
+ const [dragWidth, setDragWidth] = useState(null);
48
+ const [mobileOpen, setMobileOpen] = useState(false);
49
+ const [resizing, setResizing] = useState(false);
50
+ const desktopExpanded = controlledExpanded ?? ownExpanded;
51
+ const minimum = Math.max(railWidth, minWidth);
52
+ const maximum = Math.max(minimum, maxWidth);
53
+ const clamp = (n) => Math.min(maximum, Math.max(minimum, Number.isFinite(n) ? n : defaultWidth));
54
+ const width = clamp(dragWidth ?? controlledWidth ?? ownWidth);
55
+ const isMobile = useMobile(mobileBreakpoint);
56
+ const labels = {
57
+ ...defaultLabels,
58
+ ...labelOverrides
59
+ };
60
+ const navRef = useRef(null);
61
+ const scrollRef = useRef(null);
62
+ const savedScrollTop = useRef(0);
63
+ const restoreScroll = useCallback((node) => {
64
+ scrollRef.current = node;
65
+ if (node) node.scrollTop = savedScrollTop.current;
66
+ }, []);
67
+ const portalRef = useRef(null);
68
+ const drag = useRef(null);
69
+ useEffect(() => {
70
+ if (!isMobile && resizable) return;
71
+ drag.current = null;
72
+ setDragWidth(null);
73
+ setResizing(false);
74
+ }, [isMobile, resizable]);
75
+ const setExpanded = (next) => {
76
+ setOwnExpanded(next);
77
+ onExpandedChange?.(next);
78
+ };
79
+ const setWidth = (next) => {
80
+ const value = clamp(next);
81
+ setOwnWidth(value);
82
+ onWidthChange?.(value);
83
+ };
84
+ const closeNavigation = () => setMobileOpen(false);
85
+ const state = {
86
+ expanded: isMobile || desktopExpanded,
87
+ isMobile,
88
+ mobileOpen,
89
+ toggle: () => isMobile ? setMobileOpen(!mobileOpen) : setExpanded(!desktopExpanded),
90
+ closeNavigation,
91
+ labels,
92
+ navigationId
93
+ };
94
+ const previousLocation = useRef({
95
+ navigationKey,
96
+ navigationResetKey,
97
+ isMobile
98
+ });
99
+ useEffect(() => {
100
+ if (previousLocation.current.navigationKey === navigationKey && previousLocation.current.navigationResetKey === navigationResetKey && previousLocation.current.isMobile === isMobile) return;
101
+ if (previousLocation.current.navigationKey !== navigationKey || previousLocation.current.isMobile !== isMobile) setMobileOpen(false);
102
+ if (previousLocation.current.navigationResetKey !== navigationResetKey || previousLocation.current.isMobile !== isMobile) {
103
+ savedScrollTop.current = 0;
104
+ scrollRef.current?.scrollTo({ top: 0 });
105
+ }
106
+ previousLocation.current = {
107
+ navigationKey,
108
+ navigationResetKey,
109
+ isMobile
110
+ };
111
+ }, [
112
+ navigationKey,
113
+ navigationResetKey,
114
+ isMobile
115
+ ]);
116
+ const navContent = /* @__PURE__ */ jsxs(Fragment$1, { children: [
117
+ brand && /* @__PURE__ */ jsx("div", {
118
+ className: "pw-frame-brand",
119
+ children: slot(brand, state)
120
+ }),
121
+ /* @__PURE__ */ jsx("div", {
122
+ className: "pw-frame-links",
123
+ ref: restoreScroll,
124
+ onScroll: (event) => {
125
+ savedScrollTop.current = event.currentTarget.scrollTop;
126
+ },
127
+ children: slot(navigation, state)
128
+ }),
129
+ footer && /* @__PURE__ */ jsx("div", {
130
+ className: "pw-frame-footer",
131
+ children: slot(footer, state)
132
+ })
133
+ ] });
134
+ const resize = (clientX, pointerId) => {
135
+ const current = drag.current;
136
+ if (!current || current.pointerId !== pointerId) return;
137
+ const next = (clientX - current.origin) * current.direction;
138
+ if (desktopExpanded && next < minimum - 80) setExpanded(false);
139
+ else if (!desktopExpanded && next >= minimum - 50) {
140
+ setExpanded(true);
141
+ current.width = clamp(next);
142
+ setDragWidth(current.width);
143
+ } else if (desktopExpanded) {
144
+ current.width = clamp(next);
145
+ setDragWidth(current.width);
146
+ }
147
+ };
148
+ const endResize = (event) => {
149
+ const ended = drag.current;
150
+ if (!ended || ended.pointerId !== event.pointerId) return;
151
+ drag.current = null;
152
+ setResizing(false);
153
+ setDragWidth(null);
154
+ if (desktopExpanded && ended.width !== ended.startWidth) setWidth(ended.width);
155
+ };
156
+ return /* @__PURE__ */ jsx(Context, {
157
+ value: state,
158
+ children: /* @__PURE__ */ jsxs("div", {
159
+ ...props,
160
+ className: cx("pw-app-frame", className),
161
+ "data-mobile": isMobile || void 0,
162
+ "data-full-bleed": fullBleed || void 0,
163
+ style,
164
+ children: [
165
+ /* @__PURE__ */ jsx("a", {
166
+ className: "pw-frame-skip",
167
+ href: `#${mainId}`,
168
+ onClick: (event) => {
169
+ event.preventDefault();
170
+ document.getElementById(mainId)?.focus();
171
+ },
172
+ children: labels.skipToContent
173
+ }),
174
+ banner && /* @__PURE__ */ jsx("div", {
175
+ className: "pw-frame-banner",
176
+ children: banner
177
+ }),
178
+ /* @__PURE__ */ jsxs("div", {
179
+ className: "pw-frame-body",
180
+ children: [
181
+ !isMobile && /* @__PURE__ */ jsxs("aside", {
182
+ ...sidebarProps,
183
+ ref: navRef,
184
+ id: navigationId,
185
+ className: cx("pw-frame-sidebar", sidebarProps?.className),
186
+ "data-sidebar-mode": desktopExpanded ? "expanded" : "rail",
187
+ "data-resizing": resizing || void 0,
188
+ style: {
189
+ ...sidebarProps?.style,
190
+ width: desktopExpanded ? width : railWidth
191
+ },
192
+ children: [navContent, resizable && /* @__PURE__ */ jsx("div", {
193
+ role: "separator",
194
+ tabIndex: 0,
195
+ "aria-orientation": "vertical",
196
+ "aria-label": labels.resizeSidebar,
197
+ "aria-controls": navigationId,
198
+ "aria-valuemin": railWidth,
199
+ "aria-valuemax": maximum,
200
+ "aria-valuenow": Math.round(desktopExpanded ? width : railWidth),
201
+ className: "pw-frame-resize",
202
+ onPointerDown: (event) => {
203
+ if (event.button !== 0 || event.isPrimary === false || drag.current) return;
204
+ event.preventDefault();
205
+ const rect = navRef.current?.getBoundingClientRect();
206
+ if (!rect) return;
207
+ const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
208
+ drag.current = {
209
+ origin: rtl ? rect.right : rect.left,
210
+ direction: rtl ? -1 : 1,
211
+ pointerId: event.pointerId,
212
+ startWidth: width,
213
+ width
214
+ };
215
+ event.currentTarget.setPointerCapture(event.pointerId);
216
+ setResizing(true);
217
+ },
218
+ onPointerMove: (event) => resize(event.clientX, event.pointerId),
219
+ onPointerUp: endResize,
220
+ onPointerCancel: endResize,
221
+ onLostPointerCapture: endResize,
222
+ onDoubleClick: () => {
223
+ setExpanded(true);
224
+ setWidth(defaultWidth);
225
+ },
226
+ onKeyDown: (event) => {
227
+ const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
228
+ const smaller = rtl ? "ArrowRight" : "ArrowLeft";
229
+ if (![
230
+ smaller,
231
+ rtl ? "ArrowLeft" : "ArrowRight",
232
+ "Home",
233
+ "End"
234
+ ].includes(event.key)) return;
235
+ event.preventDefault();
236
+ if (event.key === "Home") {
237
+ setExpanded(true);
238
+ setWidth(minimum);
239
+ } else if (event.key === "End") {
240
+ setExpanded(true);
241
+ setWidth(maximum);
242
+ } else if (event.key === smaller) {
243
+ if (width <= minimum) setExpanded(false);
244
+ else if (desktopExpanded) setWidth(width - 16);
245
+ } else if (!desktopExpanded) setExpanded(true);
246
+ else setWidth(width + 16);
247
+ }
248
+ })]
249
+ }),
250
+ /* @__PURE__ */ jsx("div", {
251
+ className: "pw-frame-surface-wrap",
252
+ children: /* @__PURE__ */ jsxs("div", {
253
+ className: "pw-frame-surface",
254
+ children: [header && /* @__PURE__ */ jsx("div", {
255
+ className: "pw-frame-header",
256
+ children: header
257
+ }), /* @__PURE__ */ jsx("main", {
258
+ id: mainId,
259
+ tabIndex: -1,
260
+ className: cx("pw-frame-main", mainClassName),
261
+ children
262
+ })]
263
+ })
264
+ }),
265
+ /* @__PURE__ */ jsx("div", { ref: portalRef })
266
+ ]
267
+ }),
268
+ isMobile && /* @__PURE__ */ jsx(Dialog.Root, {
269
+ open: mobileOpen,
270
+ onOpenChange: setMobileOpen,
271
+ children: /* @__PURE__ */ jsxs(Dialog.Portal, {
272
+ container: portalRef,
273
+ children: [/* @__PURE__ */ jsx(Dialog.Backdrop, { className: "pw-frame-backdrop" }), /* @__PURE__ */ jsxs(Dialog.Popup, {
274
+ ...sidebarProps,
275
+ id: navigationId,
276
+ className: cx("pw-frame-drawer", sidebarProps?.className),
277
+ "aria-label": labels.navigation,
278
+ "aria-modal": "true",
279
+ "data-sidebar-mode": "drawer",
280
+ style: {
281
+ ...sidebarProps?.style,
282
+ width
283
+ },
284
+ children: [/* @__PURE__ */ jsx("div", {
285
+ className: "pw-frame-mobile-close",
286
+ children: /* @__PURE__ */ jsx(Dialog.Close, {
287
+ className: "pw-frame-toggle",
288
+ "aria-label": labels.closeNavigation,
289
+ children: /* @__PURE__ */ jsx(CloseIcon, {})
290
+ })
291
+ }), navContent]
292
+ })]
293
+ })
294
+ })
295
+ ]
296
+ })
297
+ });
298
+ }
299
+ /** Put in the page header on mobile, and the sidebar footer on desktop. */
300
+ function AppFrameToggle({ className, ...props }) {
301
+ const { expanded, isMobile, mobileOpen, toggle, labels, navigationId } = useFrameContext();
302
+ const label = isMobile ? mobileOpen ? labels.closeNavigation : labels.openNavigation : expanded ? labels.collapseSidebar : labels.expandSidebar;
303
+ return /* @__PURE__ */ jsx(Tooltip, {
304
+ content: label,
305
+ side: isMobile ? "bottom" : "right",
306
+ children: /* @__PURE__ */ jsx("button", {
307
+ ...props,
308
+ type: "button",
309
+ className: cx("pw-frame-toggle", className),
310
+ "aria-label": label,
311
+ "aria-expanded": isMobile ? mobileOpen : expanded,
312
+ "aria-controls": navigationId,
313
+ onClick: (event) => {
314
+ props.onClick?.(event);
315
+ if (!event.defaultPrevented) toggle();
316
+ },
317
+ children: expanded && !isMobile ? /* @__PURE__ */ jsx(SidebarCloseIcon, {}) : /* @__PURE__ */ jsx(SidebarOpenIcon, {})
318
+ })
319
+ });
320
+ }
321
+ /** Router-aware navigation that keeps icon-only rail items named and discoverable. */
322
+ function AppFrameNavItem({ label, icon, selected, badge, disabled, onClick, className, ...target }) {
323
+ const { expanded, closeNavigation } = useFrameContext();
324
+ const props = {
325
+ className: cx("pw-frame-nav-item", className),
326
+ "aria-current": selected ? "page" : void 0,
327
+ onClick: (event) => {
328
+ if (disabled) {
329
+ event.preventDefault();
330
+ return;
331
+ }
332
+ onClick?.(event);
333
+ if (!event.defaultPrevented && isPlainClick(event)) closeNavigation();
334
+ },
335
+ children: /* @__PURE__ */ jsxs(Fragment$1, { children: [
336
+ /* @__PURE__ */ jsx("span", {
337
+ className: "pw-frame-nav-icon",
338
+ "aria-hidden": "true",
339
+ children: icon
340
+ }),
341
+ /* @__PURE__ */ jsx("span", {
342
+ className: expanded ? "pw-frame-nav-label" : "sr-only",
343
+ children: label
344
+ }),
345
+ expanded && badge != null && /* @__PURE__ */ jsx("span", {
346
+ className: "pw-frame-nav-badge",
347
+ "aria-hidden": "true",
348
+ children: badge
349
+ })
350
+ ] })
351
+ };
352
+ const item = target.href ? /* @__PURE__ */ jsx(AppLink, {
353
+ ...target,
354
+ ...props,
355
+ disabled
356
+ }) : /* @__PURE__ */ jsx("button", {
357
+ ...props,
358
+ type: "button",
359
+ disabled
360
+ });
361
+ return expanded ? item : /* @__PURE__ */ jsx(Tooltip, {
362
+ content: label,
363
+ side: "right",
364
+ delay: 0,
365
+ children: item
366
+ });
367
+ }
368
+ //#endregion
369
+ export { AppFrame, AppFrameNavItem, AppFrameToggle, useAppFrame };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parallelworks/ui",
3
- "version": "0.92.0",
3
+ "version": "0.93.0",
4
4
  "description": "React UI components: a theme contract, icons, brand logos, primitives, lists, forms, a job graph, a code editor, a log viewer, a file explorer and an AI chat, each on its own subpath",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -76,7 +76,12 @@
76
76
  "types": "./dist/ai/adapter/mock/index.d.ts",
77
77
  "import": "./dist/ai/adapter/mock/index.js"
78
78
  },
79
- "./ai.css": "./dist/ai/ai.css"
79
+ "./ai.css": "./dist/ai/ai.css",
80
+ "./app-frame": {
81
+ "types": "./dist/app-frame/index.d.ts",
82
+ "import": "./dist/app-frame/index.js"
83
+ },
84
+ "./app-frame.css": "./dist/app-frame/app-frame.css"
80
85
  },
81
86
  "publishConfig": {
82
87
  "registry": "https://registry.npmjs.org"
@@ -148,7 +153,7 @@
148
153
  "scripts": {
149
154
  "build": "vite build && pnpm build:types && pnpm build:css",
150
155
  "build:types": "tsc -p tsconfig.build.json && node scripts/declaration-extensions.mjs",
151
- "build:css": "mkdir -p dist/styles dist/logviewer dist/ai && cp src/styles/theme.css src/styles/base.css src/styles/fonts.css src/styles/*.woff2 dist/styles/ && tailwindcss -i ./src/styles.css -o ./dist/styles.css --minify && cp src/logviewer/logviewer.css dist/logviewer/ && cp src/ai/ai.css dist/ai/",
156
+ "build:css": "mkdir -p dist/styles dist/logviewer dist/ai && cp src/styles/theme.css src/styles/base.css src/styles/fonts.css src/styles/*.woff2 dist/styles/ && tailwindcss -i ./src/styles.css -o ./dist/styles.css --minify && cp src/logviewer/logviewer.css dist/logviewer/ && cp src/ai/ai.css dist/ai/ && mkdir -p dist/app-frame && cp src/app-frame/app-frame.css dist/app-frame/",
152
157
  "clean": "rm -rf dist",
153
158
  "test": "vitest run",
154
159
  "lint:package": "publint --strict",