@parallelworks/ui 0.92.0 → 0.94.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:
@@ -227,6 +228,14 @@ A page declares its header in one place, and the shell shows it:
227
228
  such as a log's follow and refresh: its buttons are outlined, and `primary`
228
229
  fills the one, if any, the panel leads to.
229
230
  **`toolbar`** is a row under the bar, such as tabs or a time range.
231
+ - A list's controls may sit in **`actions`**: a `ListToolbar` there, with its
232
+ filter, display options and search, leaves the list's count under its rows.
233
+ Where its search field, opened, wouldn't fit beside the trail, as on a phone,
234
+ the open search takes the whole bar until it closes; the rest of the bar
235
+ stays as it was underneath, hidden and `inert`, and focus returns to the
236
+ search button. While a search term narrows the list with the field closed,
237
+ the button shows a dot and its name says so (`searchActive`). Nothing to set
238
+ up: the bar measures it.
230
239
  - **`hidden`** takes the bar away while the page is mounted, for a page that
231
240
  fills the window.
232
241
 
@@ -1245,3 +1254,75 @@ colors and sizing can obscure labels or make controls hard to use. Keep trusted
1245
1254
  confirmation summaries, permissions, and security notices outside the authored
1246
1255
  layout. Full stylesheets or mutually untrusted embedded applications require a
1247
1256
  separate isolation design.
1257
+
1258
+ ## Application frame
1259
+
1260
+ `AppFrame` provides a sidebar and an inset content surface. The sidebar collapses
1261
+ to a 64px icon rail, resizes with a pointer or keyboard, and becomes a modal
1262
+ navigation drawer below 768px. Its header, scrolling navigation, and footer stay
1263
+ separate from the page's scrolling content. Long navigation labels truncate within
1264
+ the current sidebar width; badges remain visible. Navigation scrollbar tracks stay
1265
+ hidden while wheel and keyboard scrolling keep destinations reachable in short
1266
+ containers.
1267
+
1268
+ ```tsx
1269
+ import { AppFrame, AppFrameNavItem, AppFrameToggle } from '@parallelworks/ui/app-frame'
1270
+ import '@parallelworks/ui/app-frame.css'
1271
+
1272
+ <AppFrame
1273
+ navigationKey={pathname}
1274
+ brand={({ expanded }) => expanded ? <Wordmark /> : <Mark />}
1275
+ navigation={
1276
+ <nav aria-label="Main navigation">
1277
+ <AppFrameNavItem label="Projects" icon={<ProjectsIcon />} href="/projects" selected />
1278
+ </nav>
1279
+ }
1280
+ footer={({ isMobile }) => !isMobile && <AppFrameToggle />}
1281
+ header={<PageTools />}
1282
+ >
1283
+ <ProjectList />
1284
+ </AppFrame>
1285
+ ```
1286
+
1287
+ `PageTools` can use `useAppFrame().isMobile` to show an `AppFrameToggle` on small
1288
+ screens. The drawer supplies its own close button, traps focus, closes on Escape
1289
+ or an outside click, and returns focus to its opener. Navigation items close it
1290
+ on an ordinary click; modified links keep the current view in place. A changed
1291
+ `navigationKey` also dismisses it.
1292
+ Reopening mobile navigation restores its scroll position within the current
1293
+ section. Closing it unmounts its contents, including any nested popups.
1294
+ Use `navigationResetKey` for a section identifier when navigation scroll should
1295
+ survive route changes within that section. It defaults to `navigationKey`.
1296
+ `sidebarProps` passes host attributes, styling, and events to the desktop sidebar
1297
+ and mobile dialog, for example a navigation context menu. The frame retains its
1298
+ own layout, dialog semantics, and measured width.
1299
+
1300
+ The host owns routing, persistence, identity, permissions, and page tools. Links
1301
+ use `UIProvider`'s link slot. Pass `expanded`/`onExpandedChange` and
1302
+ `width`/`onWidthChange` to persist preferences, or use the uncontrolled defaults.
1303
+ Pointer drags preview their width locally and call `onWidthChange` once when the
1304
+ gesture ends, so persistence does not run on every pointer movement.
1305
+ `defaultWidth` is 240, bounded by `minWidth` (200) and `maxWidth` (400); compact
1306
+ applications can choose smaller bounds. The resize handle supports arrows, Home,
1307
+ End, drag-to-collapse, and double-click to restore `defaultWidth`. Logical layout
1308
+ and resizing support right-to-left direction. Reduced motion disables resizing
1309
+ and drawer transitions. `resizable={false}` removes the handle.
1310
+
1311
+ `brand`, `navigation`, and `footer` accept nodes or functions receiving the frame
1312
+ state. `header` sits above the main content; `banner` spans both columns.
1313
+ `fullBleed` removes the 8px inset and 12px rounded border. The frame fills the
1314
+ viewport by default; pass `style={{ height: '100%' }}` for a bounded container.
1315
+ The mobile drawer stays inside the frame, below its banner, and uses the chosen
1316
+ sidebar width, capped to leave 48px for dismissal. It slides from the navigation
1317
+ edge while the backdrop fades; both finish in 200ms and respect reduced motion.
1318
+
1319
+ The five `--theme-sidebar-*` tokens produced by `generateTheme` style navigation:
1320
+ `bg`, `text`, `hover`, `active-bg`, and `active-text`. Without them the frame uses
1321
+ the ordinary surface and accent tokens. Content uses `--theme-app-bg` and
1322
+ `--theme-border`. Supply translated `labels` for navigation, skip, open, close,
1323
+ expand, collapse, and resize actions. The frame owns the main landmark and skip
1324
+ link; put page content inside it without nesting a second `<main>`.
1325
+
1326
+ Frame styles use the `pw-ui` cascade layer. Put broad host element defaults
1327
+ (such as anchor colors and scrollbar styling) in a lower-priority layer declared
1328
+ 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 {};