@pablozaiden/webapp 0.3.1 → 0.3.3
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/docs/getting-started.md +7 -2
- package/docs/ui-guidelines.md +4 -0
- package/package.json +1 -1
- package/src/web/WebAppRoot.tsx +97 -3
- package/src/web/styles.css +72 -10
package/docs/getting-started.md
CHANGED
|
@@ -57,11 +57,15 @@ See `docs/cli.md` for framework CLI helpers and generic API command support.
|
|
|
57
57
|
Frontend entrypoints should use `renderWebApp` so Bun/browser hot reload reuses the existing React root instead of calling `createRoot()` twice. Import the framework CSS explicitly so Bun hot reload observes style changes:
|
|
58
58
|
|
|
59
59
|
```tsx
|
|
60
|
-
import { WebAppRoot, renderWebApp } from "@pablozaiden/webapp/web";
|
|
60
|
+
import { Page, Panel, WebAppRoot, renderWebApp } from "@pablozaiden/webapp/web";
|
|
61
61
|
import "@pablozaiden/webapp/web/styles.css";
|
|
62
62
|
|
|
63
63
|
function Home() {
|
|
64
|
-
return
|
|
64
|
+
return (
|
|
65
|
+
<Page>
|
|
66
|
+
<Panel>Hello</Panel>
|
|
67
|
+
</Page>
|
|
68
|
+
);
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
renderWebApp(
|
|
@@ -75,6 +79,7 @@ renderWebApp(
|
|
|
75
79
|
```
|
|
76
80
|
|
|
77
81
|
`renderWebApp` renders into `#root` by default and reuses the existing React root across hot reloads. Pass a custom element id or `Element` only when the app uses a different mount point.
|
|
82
|
+
`WebAppRoot` owns the shell and `.wapp-main-content`; each route component should return a `Page` wrapper so standard content margins, mobile padding and scroll behavior stay consistent.
|
|
78
83
|
|
|
79
84
|
Recommended dev script:
|
|
80
85
|
|
package/docs/ui-guidelines.md
CHANGED
|
@@ -9,6 +9,7 @@ The framework intentionally provides a consistent base UI:
|
|
|
9
9
|
- Settings and collapse controls are framework-owned.
|
|
10
10
|
- Version is always visible at the bottom of the sidebar.
|
|
11
11
|
- Main content should prefer panels, toolbars, badges and simple forms over custom one-off layouts.
|
|
12
|
+
- `WebAppRoot` owns the fixed main title bar and `.wapp-main-content`; app routes should not render or style those shell elements directly.
|
|
12
13
|
|
|
13
14
|
## Main content primitives
|
|
14
15
|
|
|
@@ -16,6 +17,7 @@ Use these first:
|
|
|
16
17
|
|
|
17
18
|
| Component | Use |
|
|
18
19
|
| --- | --- |
|
|
20
|
+
| `Page` | Required top-level wrapper for route content rendered by `WebAppRoot.routes`; provides standard margins/padding and mobile spacing |
|
|
19
21
|
| `Toolbar` | Page title/actions inside main content |
|
|
20
22
|
| `Panel` | Cards/sections; use `actions` for a top-right menu/action area |
|
|
21
23
|
| `ActionMenu` | Three-line action menu for secondary surfaces; entity-level shell menus should usually come from `SidebarNode.actions` so the framework renders them in the sidebar context menu and fixed title bar |
|
|
@@ -34,6 +36,8 @@ Use these first:
|
|
|
34
36
|
|
|
35
37
|
For entity actions, prefer the framework title bar: define one `ActionMenuItem[]` builder and attach it to `SidebarNode.actions` for the route-backed node. The framework reuses those actions for both sidebar right-click and the active route title-bar menu. Use `WebAppRoot.header.getActions` only for additional actions not owned by a sidebar node.
|
|
36
38
|
|
|
39
|
+
Every route component rendered by `WebAppRoot.routes` should return `Page` at the top level, including loading/error/empty states. Do not render a `Panel`, `DataList`, `EmptyState` or custom div directly into `WebAppRoot`; that skips the standard content margins and recreates the visual bug where cards touch the main content edge. Use `EntityHeader` only when the page needs a content-specific heading distinct from the fixed framework title bar; do not duplicate the active route title immediately below the header.
|
|
40
|
+
|
|
37
41
|
## Visual validation captures
|
|
38
42
|
|
|
39
43
|
Generated screenshots live in `artifacts/screenshots`:
|
package/package.json
CHANGED
package/src/web/WebAppRoot.tsx
CHANGED
|
@@ -102,6 +102,87 @@ function useRoute(defaultRoute: WebAppRoute) {
|
|
|
102
102
|
return { route, navigate };
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
function useMobileViewportHeight() {
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (typeof window === "undefined") {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const root = document.documentElement;
|
|
112
|
+
const mobileQuery = window.matchMedia("(max-width: 900px)");
|
|
113
|
+
const viewport = window.visualViewport;
|
|
114
|
+
const timers = new Set<number>();
|
|
115
|
+
let frame = 0;
|
|
116
|
+
|
|
117
|
+
const clearViewportHeight = () => {
|
|
118
|
+
root.style.removeProperty("--wapp-viewport-height");
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const sync = () => {
|
|
122
|
+
frame = 0;
|
|
123
|
+
if (!mobileQuery.matches) {
|
|
124
|
+
clearViewportHeight();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const height = Math.round(viewport?.height ?? window.innerHeight);
|
|
129
|
+
if (height > 0) {
|
|
130
|
+
root.style.setProperty("--wapp-viewport-height", `${height}px`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const scrollingElement = document.scrollingElement;
|
|
134
|
+
if (scrollingElement && scrollingElement.scrollTop !== 0) {
|
|
135
|
+
scrollingElement.scrollTop = 0;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const scheduleSync = () => {
|
|
140
|
+
if (frame) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
frame = requestAnimationFrame(sync);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const scheduleDelayedSync = (delay: number) => {
|
|
147
|
+
const timer = window.setTimeout(() => {
|
|
148
|
+
timers.delete(timer);
|
|
149
|
+
scheduleSync();
|
|
150
|
+
}, delay);
|
|
151
|
+
timers.add(timer);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const handleKeyboardBoundary = () => {
|
|
155
|
+
scheduleSync();
|
|
156
|
+
scheduleDelayedSync(120);
|
|
157
|
+
scheduleDelayedSync(320);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
scheduleSync();
|
|
161
|
+
viewport?.addEventListener("resize", scheduleSync);
|
|
162
|
+
viewport?.addEventListener("scroll", scheduleSync);
|
|
163
|
+
window.addEventListener("resize", scheduleSync);
|
|
164
|
+
mobileQuery.addEventListener("change", scheduleSync);
|
|
165
|
+
document.addEventListener("focusin", handleKeyboardBoundary);
|
|
166
|
+
document.addEventListener("focusout", handleKeyboardBoundary);
|
|
167
|
+
|
|
168
|
+
return () => {
|
|
169
|
+
if (frame) {
|
|
170
|
+
cancelAnimationFrame(frame);
|
|
171
|
+
}
|
|
172
|
+
for (const timer of timers) {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
}
|
|
175
|
+
viewport?.removeEventListener("resize", scheduleSync);
|
|
176
|
+
viewport?.removeEventListener("scroll", scheduleSync);
|
|
177
|
+
window.removeEventListener("resize", scheduleSync);
|
|
178
|
+
mobileQuery.removeEventListener("change", scheduleSync);
|
|
179
|
+
document.removeEventListener("focusin", handleKeyboardBoundary);
|
|
180
|
+
document.removeEventListener("focusout", handleKeyboardBoundary);
|
|
181
|
+
clearViewportHeight();
|
|
182
|
+
};
|
|
183
|
+
}, []);
|
|
184
|
+
}
|
|
185
|
+
|
|
105
186
|
async function json<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
106
187
|
const response = await fetch(path, {
|
|
107
188
|
...init,
|
|
@@ -892,6 +973,7 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
|
|
|
892
973
|
}
|
|
893
974
|
|
|
894
975
|
export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRouteChange, settings, version }: WebAppRootProps) {
|
|
976
|
+
useMobileViewportHeight();
|
|
895
977
|
const { config, error, refresh } = useConfig();
|
|
896
978
|
const { route, navigate } = useRoute(homeRoute);
|
|
897
979
|
const { theme, setTheme } = useTheme();
|
|
@@ -1002,16 +1084,28 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
|
|
|
1002
1084
|
const headerTitle = header?.renderTitle?.(headerContext) ?? defaultTitle;
|
|
1003
1085
|
const primaryHeaderActions = header?.renderActions?.(headerContext);
|
|
1004
1086
|
const headerActionLabel = typeof headerTitle === "string" ? headerTitle : defaultTitle;
|
|
1087
|
+
const navigateFromSidebarHeader = (nextRoute: WebAppRoute) => {
|
|
1088
|
+
navigate(nextRoute);
|
|
1089
|
+
setSidebarOpen(false);
|
|
1090
|
+
};
|
|
1091
|
+
const runSidebarHeaderAction = (action: SidebarAction) => {
|
|
1092
|
+
if (action.onAction) {
|
|
1093
|
+
action.onAction();
|
|
1094
|
+
} else if (action.route) {
|
|
1095
|
+
navigate(action.route);
|
|
1096
|
+
}
|
|
1097
|
+
setSidebarOpen(false);
|
|
1098
|
+
};
|
|
1005
1099
|
|
|
1006
1100
|
return (
|
|
1007
1101
|
<main className={`wapp-shell ${sidebarCollapsed ? "sidebar-collapsed" : ""} ${sidebarOpen ? "sidebar-open" : ""}`}>
|
|
1008
1102
|
<div className="wapp-mobile-backdrop" onClick={() => setSidebarOpen(false)} />
|
|
1009
1103
|
<aside className="wapp-sidebar">
|
|
1010
1104
|
<div className="wapp-sidebar-header">
|
|
1011
|
-
<button type="button" className="wapp-brand" onClick={() =>
|
|
1105
|
+
<button type="button" className="wapp-brand" onClick={() => navigateFromSidebarHeader(homeRoute)}>{appName}</button>
|
|
1012
1106
|
<div className="wapp-sidebar-actions">
|
|
1013
|
-
{topActions.map((action) => <IconButton key={action.id} className="wapp-sidebar-top-button" title={action.title} aria-label={action.title} onClick={
|
|
1014
|
-
<IconButton className="wapp-sidebar-top-button" title="Settings" aria-label="Open settings" active={route.view === "settings"} onClick={() =>
|
|
1107
|
+
{topActions.map((action) => <IconButton key={action.id} className="wapp-sidebar-top-button" title={action.title} aria-label={action.title} onClick={() => runSidebarHeaderAction(action)}><ActionIcon icon={action.icon} /></IconButton>)}
|
|
1108
|
+
<IconButton className="wapp-sidebar-top-button" title="Settings" aria-label="Open settings" active={route.view === "settings"} onClick={() => navigateFromSidebarHeader({ view: "settings" })}><Icon name="settings" /></IconButton>
|
|
1015
1109
|
<IconButton className="wapp-sidebar-top-button" title="Collapse sidebar" aria-label="Collapse sidebar" onClick={() => { setSidebarCollapsed(true); setSidebarOpen(false); }}><Icon name="sidebar" /></IconButton>
|
|
1016
1110
|
</div>
|
|
1017
1111
|
</div>
|
package/src/web/styles.css
CHANGED
|
@@ -13,9 +13,27 @@
|
|
|
13
13
|
--wapp-danger-bg: #991b1b;
|
|
14
14
|
--wapp-primary: #111827;
|
|
15
15
|
--wapp-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
16
|
+
--wapp-safe-area-top: env(safe-area-inset-top, 0px);
|
|
17
|
+
--wapp-safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
|
18
|
+
--wapp-safe-area-left: env(safe-area-inset-left, 0px);
|
|
19
|
+
--wapp-safe-area-right: env(safe-area-inset-right, 0px);
|
|
20
|
+
--wapp-mobile-bottom-clearance: max(var(--wapp-safe-area-bottom), 0.75rem);
|
|
21
|
+
--wapp-viewport-height: 100vh;
|
|
16
22
|
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
|
17
23
|
}
|
|
18
24
|
|
|
25
|
+
@supports (height: 100svh) {
|
|
26
|
+
:root {
|
|
27
|
+
--wapp-viewport-height: 100svh;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@supports (height: 100dvh) {
|
|
32
|
+
:root {
|
|
33
|
+
--wapp-viewport-height: 100dvh;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
:root.dark {
|
|
20
38
|
color-scheme: dark;
|
|
21
39
|
--wapp-bg: var(--wapp-shell-bg);
|
|
@@ -41,6 +59,7 @@ html,
|
|
|
41
59
|
body,
|
|
42
60
|
#root {
|
|
43
61
|
height: 100%;
|
|
62
|
+
min-height: 100%;
|
|
44
63
|
}
|
|
45
64
|
|
|
46
65
|
body {
|
|
@@ -84,8 +103,8 @@ textarea::placeholder {
|
|
|
84
103
|
|
|
85
104
|
.wapp-shell {
|
|
86
105
|
display: flex;
|
|
87
|
-
height:
|
|
88
|
-
min-height:
|
|
106
|
+
height: var(--wapp-viewport-height);
|
|
107
|
+
min-height: var(--wapp-viewport-height);
|
|
89
108
|
overflow: hidden;
|
|
90
109
|
background: var(--wapp-bg);
|
|
91
110
|
}
|
|
@@ -94,7 +113,7 @@ textarea::placeholder {
|
|
|
94
113
|
width: 20rem;
|
|
95
114
|
min-width: 20rem;
|
|
96
115
|
min-height: 0;
|
|
97
|
-
height:
|
|
116
|
+
height: var(--wapp-viewport-height);
|
|
98
117
|
display: flex;
|
|
99
118
|
flex-direction: column;
|
|
100
119
|
overflow: hidden;
|
|
@@ -258,6 +277,7 @@ textarea::placeholder {
|
|
|
258
277
|
overflow-y: auto;
|
|
259
278
|
overflow-x: hidden;
|
|
260
279
|
padding: 1rem 0.75rem;
|
|
280
|
+
scroll-padding-bottom: var(--wapp-mobile-bottom-clearance);
|
|
261
281
|
}
|
|
262
282
|
|
|
263
283
|
.wapp-search input {
|
|
@@ -501,7 +521,7 @@ textarea::placeholder {
|
|
|
501
521
|
align-items: center;
|
|
502
522
|
justify-content: space-between;
|
|
503
523
|
min-height: 2rem;
|
|
504
|
-
padding: 0.25rem 0.25rem
|
|
524
|
+
padding: 0.25rem 0.25rem var(--wapp-safe-area-bottom);
|
|
505
525
|
color: var(--wapp-muted-2);
|
|
506
526
|
font-size: 0.75rem;
|
|
507
527
|
}
|
|
@@ -527,7 +547,7 @@ textarea::placeholder {
|
|
|
527
547
|
flex: 1;
|
|
528
548
|
min-width: 0;
|
|
529
549
|
min-height: 0;
|
|
530
|
-
height:
|
|
550
|
+
height: var(--wapp-viewport-height);
|
|
531
551
|
display: flex;
|
|
532
552
|
flex-direction: column;
|
|
533
553
|
}
|
|
@@ -551,12 +571,53 @@ textarea::placeholder {
|
|
|
551
571
|
overflow: auto;
|
|
552
572
|
overflow-x: hidden;
|
|
553
573
|
padding: 0;
|
|
574
|
+
scroll-padding-bottom: var(--wapp-mobile-bottom-clearance);
|
|
554
575
|
}
|
|
555
576
|
|
|
556
577
|
.wapp-page {
|
|
557
578
|
width: 100%;
|
|
558
579
|
min-width: 0;
|
|
559
|
-
|
|
580
|
+
max-width: 100%;
|
|
581
|
+
padding: 1.5rem 2rem max(2rem, var(--wapp-mobile-bottom-clearance));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
.wapp-page,
|
|
585
|
+
.wapp-panel,
|
|
586
|
+
.wapp-modal-body {
|
|
587
|
+
overflow-wrap: anywhere;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
.wapp-page :where(*):not(table, thead, tbody, tfoot, tr, th, td),
|
|
591
|
+
.wapp-panel :where(*):not(table, thead, tbody, tfoot, tr, th, td),
|
|
592
|
+
.wapp-modal-body :where(*):not(table, thead, tbody, tfoot, tr, th, td) {
|
|
593
|
+
min-width: 0;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
.wapp-page :where(img, video, canvas, iframe, svg),
|
|
597
|
+
.wapp-panel :where(img, video, canvas, iframe, svg),
|
|
598
|
+
.wapp-modal-body :where(img, video, canvas, iframe, svg) {
|
|
599
|
+
max-width: 100%;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
.wapp-page :where(pre),
|
|
603
|
+
.wapp-panel :where(pre),
|
|
604
|
+
.wapp-modal-body :where(pre) {
|
|
605
|
+
max-width: 100%;
|
|
606
|
+
overflow-x: auto;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
.wapp-page :where(table),
|
|
610
|
+
.wapp-panel :where(table),
|
|
611
|
+
.wapp-modal-body :where(table) {
|
|
612
|
+
display: block;
|
|
613
|
+
max-width: 100%;
|
|
614
|
+
overflow-x: auto;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
.wapp-page :where(code, kbd, samp),
|
|
618
|
+
.wapp-panel :where(code, kbd, samp),
|
|
619
|
+
.wapp-modal-body :where(code, kbd, samp) {
|
|
620
|
+
overflow-wrap: anywhere;
|
|
560
621
|
}
|
|
561
622
|
|
|
562
623
|
.wapp-panel {
|
|
@@ -1599,7 +1660,7 @@ textarea::placeholder {
|
|
|
1599
1660
|
flex-direction: column;
|
|
1600
1661
|
width: 100%;
|
|
1601
1662
|
min-width: 0;
|
|
1602
|
-
max-height: calc(
|
|
1663
|
+
max-height: calc(var(--wapp-viewport-height) - 1.5rem - var(--wapp-safe-area-bottom));
|
|
1603
1664
|
overflow: hidden;
|
|
1604
1665
|
border: 1px solid var(--wapp-border);
|
|
1605
1666
|
border-radius: 0.5rem;
|
|
@@ -1682,6 +1743,7 @@ textarea::placeholder {
|
|
|
1682
1743
|
overflow-x: hidden;
|
|
1683
1744
|
overflow-y: auto;
|
|
1684
1745
|
padding: 0.75rem 1rem;
|
|
1746
|
+
scroll-padding-bottom: var(--wapp-mobile-bottom-clearance);
|
|
1685
1747
|
}
|
|
1686
1748
|
|
|
1687
1749
|
.wapp-modal-footer {
|
|
@@ -1692,7 +1754,7 @@ textarea::placeholder {
|
|
|
1692
1754
|
justify-content: flex-end;
|
|
1693
1755
|
gap: 0.5rem;
|
|
1694
1756
|
border-top: 1px solid var(--wapp-border);
|
|
1695
|
-
padding: 0.75rem 1rem;
|
|
1757
|
+
padding: 0.75rem 1rem max(0.75rem, var(--wapp-mobile-bottom-clearance));
|
|
1696
1758
|
}
|
|
1697
1759
|
|
|
1698
1760
|
@media (min-width: 640px) {
|
|
@@ -1701,7 +1763,7 @@ textarea::placeholder {
|
|
|
1701
1763
|
}
|
|
1702
1764
|
|
|
1703
1765
|
.wapp-modal {
|
|
1704
|
-
max-height: calc(
|
|
1766
|
+
max-height: calc(var(--wapp-viewport-height) - 2.5rem - var(--wapp-safe-area-bottom));
|
|
1705
1767
|
}
|
|
1706
1768
|
|
|
1707
1769
|
.wapp-modal-header {
|
|
@@ -1776,7 +1838,7 @@ textarea::placeholder {
|
|
|
1776
1838
|
}
|
|
1777
1839
|
|
|
1778
1840
|
.wapp-page {
|
|
1779
|
-
padding: 1rem;
|
|
1841
|
+
padding: 1rem 1rem max(1rem, var(--wapp-mobile-bottom-clearance));
|
|
1780
1842
|
}
|
|
1781
1843
|
|
|
1782
1844
|
.wapp-panel-header,
|