@pablozaiden/webapp 0.5.7 → 0.5.8

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/sidebar.md CHANGED
@@ -38,6 +38,8 @@ Sidebar nodes support:
38
38
 
39
39
  Search is intentionally app-defined: `getNodes({ search })` receives raw search text and returns the tree that should be rendered. Set `sidebar.search: false` when an app has a small fixed navigation tree and should not show the sidebar search box.
40
40
 
41
+ On mobile widths, the drawer can be opened with a horizontal swipe starting within the first 24px of the viewport, in addition to the header button. The gesture must move at least 64px to the right, stay within 48px of vertical displacement, and remain more horizontal than vertical.
42
+
41
43
  Use `actions` when an entity needs commands in the sidebar. `WebAppRoot` finds the active route-backed sidebar node and automatically renders its `ActionMenuItem[]` in the title-bar three-line menu, so the sidebar right-click menu and header menu stay consistent from one source of truth. Use `header.getActions` only for extra route-level actions that are not represented by the active sidebar node.
42
44
 
43
45
  ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "0.5.7",
3
+ "version": "0.5.8",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -190,7 +190,7 @@ function useMobileViewportHeight() {
190
190
  timers.add(timer);
191
191
  };
192
192
 
193
- const handleKeyboardBoundary = () => {
193
+ const handleViewportTransition = () => {
194
194
  scheduleSync();
195
195
  scheduleDelayedSync(120);
196
196
  scheduleDelayedSync(320);
@@ -200,9 +200,10 @@ function useMobileViewportHeight() {
200
200
  viewport?.addEventListener("resize", scheduleSync);
201
201
  viewport?.addEventListener("scroll", scheduleSync);
202
202
  window.addEventListener("resize", scheduleSync);
203
+ window.addEventListener("orientationchange", handleViewportTransition);
203
204
  mobileQuery.addEventListener("change", scheduleSync);
204
- document.addEventListener("focusin", handleKeyboardBoundary);
205
- document.addEventListener("focusout", handleKeyboardBoundary);
205
+ document.addEventListener("focusin", handleViewportTransition);
206
+ document.addEventListener("focusout", handleViewportTransition);
206
207
 
207
208
  return () => {
208
209
  if (frame) {
@@ -214,14 +215,94 @@ function useMobileViewportHeight() {
214
215
  viewport?.removeEventListener("resize", scheduleSync);
215
216
  viewport?.removeEventListener("scroll", scheduleSync);
216
217
  window.removeEventListener("resize", scheduleSync);
218
+ window.removeEventListener("orientationchange", handleViewportTransition);
217
219
  mobileQuery.removeEventListener("change", scheduleSync);
218
- document.removeEventListener("focusin", handleKeyboardBoundary);
219
- document.removeEventListener("focusout", handleKeyboardBoundary);
220
+ document.removeEventListener("focusin", handleViewportTransition);
221
+ document.removeEventListener("focusout", handleViewportTransition);
220
222
  clearViewportHeight();
221
223
  };
222
224
  }, []);
223
225
  }
224
226
 
227
+ const MOBILE_SIDEBAR_BREAKPOINT = 900;
228
+ const SIDEBAR_SWIPE_EDGE_WIDTH = 24;
229
+ const SIDEBAR_SWIPE_DISTANCE = 64;
230
+ const SIDEBAR_SWIPE_VERTICAL_TOLERANCE = 48;
231
+
232
+ function useMobileSidebarSwipe(sidebarOpen: boolean, setSidebarOpen: (open: boolean) => void) {
233
+ useEffect(() => {
234
+ if (typeof window === "undefined" || typeof document === "undefined") {
235
+ return;
236
+ }
237
+
238
+ let tracking = false;
239
+ let startX = 0;
240
+ let startY = 0;
241
+
242
+ const reset = () => {
243
+ tracking = false;
244
+ };
245
+
246
+ const handleTouchStart = (event: TouchEvent) => {
247
+ if (sidebarOpen || window.innerWidth > MOBILE_SIDEBAR_BREAKPOINT || event.touches.length !== 1) {
248
+ reset();
249
+ return;
250
+ }
251
+
252
+ const touch = event.touches[0];
253
+ if (!touch || touch.clientX > SIDEBAR_SWIPE_EDGE_WIDTH) {
254
+ reset();
255
+ return;
256
+ }
257
+
258
+ tracking = true;
259
+ startX = touch.clientX;
260
+ startY = touch.clientY;
261
+ };
262
+
263
+ const handleTouchMove = (event: TouchEvent) => {
264
+ if (!tracking) {
265
+ return;
266
+ }
267
+ if (event.touches.length !== 1) {
268
+ reset();
269
+ return;
270
+ }
271
+
272
+ const touch = event.touches[0];
273
+ if (!touch) {
274
+ reset();
275
+ return;
276
+ }
277
+
278
+ const deltaX = touch.clientX - startX;
279
+ const deltaY = Math.abs(touch.clientY - startY);
280
+ if (deltaX <= 0 || deltaY > SIDEBAR_SWIPE_VERTICAL_TOLERANCE || deltaY > deltaX) {
281
+ reset();
282
+ return;
283
+ }
284
+ if (deltaX < SIDEBAR_SWIPE_DISTANCE) {
285
+ return;
286
+ }
287
+
288
+ event.preventDefault();
289
+ setSidebarOpen(true);
290
+ reset();
291
+ };
292
+
293
+ document.addEventListener("touchstart", handleTouchStart, { passive: true });
294
+ document.addEventListener("touchmove", handleTouchMove, { passive: false });
295
+ document.addEventListener("touchend", reset);
296
+ document.addEventListener("touchcancel", reset);
297
+ return () => {
298
+ document.removeEventListener("touchstart", handleTouchStart);
299
+ document.removeEventListener("touchmove", handleTouchMove);
300
+ document.removeEventListener("touchend", reset);
301
+ document.removeEventListener("touchcancel", reset);
302
+ };
303
+ }, [setSidebarOpen, sidebarOpen]);
304
+ }
305
+
225
306
  async function json<T>(path: string, init: RequestInit = {}): Promise<T> {
226
307
  const response = await fetch(path, {
227
308
  ...init,
@@ -1077,6 +1158,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1077
1158
  const [sidebarOpen, setSidebarOpen] = useState(false);
1078
1159
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
1079
1160
  const sidebarTreeState = useSidebarCollapsedState(appName);
1161
+ useMobileSidebarSwipe(sidebarOpen, setSidebarOpen);
1080
1162
  const toggleSidebarCollapsed = useCallback(() => {
1081
1163
  setSidebarCollapsed((current) => {
1082
1164
  const nextCollapsed = !current;
@@ -108,6 +108,7 @@ textarea::placeholder {
108
108
  height: var(--wapp-viewport-height);
109
109
  min-height: var(--wapp-viewport-height);
110
110
  overflow: hidden;
111
+ overscroll-behavior-x: none;
111
112
  background: var(--wapp-bg);
112
113
  }
113
114
 
@@ -1896,6 +1897,10 @@ textarea::placeholder {
1896
1897
  padding: 0.875rem 1rem;
1897
1898
  }
1898
1899
 
1900
+ .wapp-main {
1901
+ touch-action: pan-y;
1902
+ }
1903
+
1899
1904
  .wapp-main-content {
1900
1905
  overflow: auto;
1901
1906
  overflow-x: hidden;