@pablozaiden/webapp 0.2.3 → 0.3.1
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 +3 -6
- package/docs/ui-guidelines.md +8 -4
- package/package.json +1 -1
- package/src/server/auth/sqlite-store.ts +0 -1
- package/src/server/create-web-app-server.ts +17 -3
- package/src/server/route-catalog.ts +1 -1
- package/src/server/routes.ts +8 -1
- package/src/web/WebAppRoot.tsx +47 -20
- package/src/web/components/index.tsx +441 -36
- package/src/web/sidebar/types.ts +17 -1
- package/src/web/styles.css +442 -29
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type CSSProperties, type InputHTMLAttributes, type MouseEvent as ReactMouseEvent, type ReactNode, type SelectHTMLAttributes, type TextareaHTMLAttributes } from "react";
|
|
1
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type CSSProperties, type HTMLAttributes, type InputHTMLAttributes, type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, type SelectHTMLAttributes, type TextareaHTMLAttributes } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import type { ActionMenuItem, BadgeVariant } from "../sidebar/types";
|
|
4
4
|
|
|
5
5
|
export function Button({
|
|
6
6
|
variant = "default",
|
|
7
|
+
loading = false,
|
|
7
8
|
className = "",
|
|
9
|
+
disabled,
|
|
10
|
+
children,
|
|
8
11
|
...props
|
|
9
|
-
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "default" | "primary" | "danger" | "ghost" }) {
|
|
10
|
-
return
|
|
12
|
+
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "default" | "primary" | "danger" | "ghost"; loading?: boolean }) {
|
|
13
|
+
return (
|
|
14
|
+
<button {...props} disabled={disabled || loading} className={`wapp-button wapp-button-${variant} ${className}`}>
|
|
15
|
+
{loading ? <span className="wapp-button-spinner" aria-hidden="true" /> : null}
|
|
16
|
+
{children}
|
|
17
|
+
</button>
|
|
18
|
+
);
|
|
11
19
|
}
|
|
12
20
|
|
|
13
21
|
export function IconButton({
|
|
@@ -18,8 +26,12 @@ export function IconButton({
|
|
|
18
26
|
return <button {...props} className={`wapp-icon-button ${active ? "active" : ""} ${className}`} />;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
|
-
export function Badge({ variant = "default", children }: { variant?: BadgeVariant; children: ReactNode }) {
|
|
22
|
-
return <span className={`wapp-badge wapp-badge-${variant}`}>{children}</span>;
|
|
29
|
+
export function Badge({ variant = "default", className = "", children, ...props }: HTMLAttributes<HTMLSpanElement> & { variant?: BadgeVariant; children: ReactNode }) {
|
|
30
|
+
return <span {...props} className={`wapp-badge wapp-badge-${variant} ${className}`}>{children}</span>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Page({ className = "", children, ...props }: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
|
|
34
|
+
return <div {...props} className={`wapp-page ${className}`}>{children}</div>;
|
|
23
35
|
}
|
|
24
36
|
|
|
25
37
|
export function Panel({ title, description, actions, children, className = "" }: { title?: string; description?: string; actions?: ReactNode; children?: ReactNode; className?: string }) {
|
|
@@ -233,6 +245,323 @@ export function CodeValue({ value, label, copyLabel = "Copy" }: { value: string;
|
|
|
233
245
|
);
|
|
234
246
|
}
|
|
235
247
|
|
|
248
|
+
function isTopmostDialog(dialog: HTMLElement): boolean {
|
|
249
|
+
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>("[role='dialog'][aria-modal='true']"));
|
|
250
|
+
return openDialogs[openDialogs.length - 1] === dialog;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function isNativeEnterTarget(target: EventTarget | null): boolean {
|
|
254
|
+
if (!(target instanceof HTMLElement)) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
if (target.isContentEditable) {
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
if (target instanceof HTMLTextAreaElement) {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
if (target instanceof HTMLButtonElement || target instanceof HTMLAnchorElement || target instanceof HTMLSelectElement) {
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
if (target instanceof HTMLInputElement) {
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function findDefaultDialogAction(dialog: HTMLElement): HTMLElement | null {
|
|
273
|
+
const explicit = dialog.querySelector<HTMLElement>("[data-dialog-default-action]:not(:disabled)");
|
|
274
|
+
if (explicit) {
|
|
275
|
+
return explicit;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const submit = dialog.querySelector<HTMLElement>("button[type='submit']:not(:disabled), input[type='submit']:not(:disabled)");
|
|
279
|
+
if (submit) {
|
|
280
|
+
return submit;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const actionContainers = Array.from(dialog.querySelectorAll<HTMLElement>("[data-dialog-actions], .wapp-dialog-actions"));
|
|
284
|
+
const actionContainer = actionContainers[actionContainers.length - 1];
|
|
285
|
+
if (!actionContainer) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const buttons = Array.from(actionContainer.querySelectorAll<HTMLElement>("button:not(:disabled), [role='button']:not([aria-disabled='true'])"));
|
|
290
|
+
return buttons[buttons.length - 1] ?? null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function useDialogKeyboardShortcuts({
|
|
294
|
+
dialogRef,
|
|
295
|
+
enabled = true,
|
|
296
|
+
onCancel,
|
|
297
|
+
onAccept,
|
|
298
|
+
acceptDisabled = false,
|
|
299
|
+
}: {
|
|
300
|
+
dialogRef: RefObject<HTMLElement | null>;
|
|
301
|
+
enabled?: boolean;
|
|
302
|
+
onCancel?: () => void;
|
|
303
|
+
onAccept?: () => void;
|
|
304
|
+
acceptDisabled?: boolean;
|
|
305
|
+
}) {
|
|
306
|
+
const onCancelRef = useRef(onCancel);
|
|
307
|
+
const onAcceptRef = useRef(onAccept);
|
|
308
|
+
onCancelRef.current = onCancel;
|
|
309
|
+
onAcceptRef.current = onAccept;
|
|
310
|
+
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
if (!enabled) return;
|
|
313
|
+
|
|
314
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
315
|
+
const dialog = dialogRef.current;
|
|
316
|
+
if (!dialog || !isTopmostDialog(dialog)) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (event.key === "Escape") {
|
|
321
|
+
if (onCancelRef.current) {
|
|
322
|
+
event.preventDefault();
|
|
323
|
+
event.stopPropagation();
|
|
324
|
+
onCancelRef.current();
|
|
325
|
+
}
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (event.key !== "Enter" || event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || event.isComposing) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (isNativeEnterTarget(event.target)) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
event.preventDefault();
|
|
338
|
+
event.stopPropagation();
|
|
339
|
+
if (onAcceptRef.current) {
|
|
340
|
+
if (!acceptDisabled) {
|
|
341
|
+
onAcceptRef.current();
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!acceptDisabled) {
|
|
347
|
+
findDefaultDialogAction(dialog)?.click();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
352
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
353
|
+
}, [acceptDisabled, dialogRef, enabled]);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
357
|
+
const MODAL_SCROLL_LOCK_COUNT_KEY = "__webappModalScrollLockCount";
|
|
358
|
+
const MODAL_SCROLL_LOCK_OVERFLOW_KEY = "__webappModalScrollLockOverflow";
|
|
359
|
+
|
|
360
|
+
type ScrollLockedBody = HTMLElement & {
|
|
361
|
+
[MODAL_SCROLL_LOCK_COUNT_KEY]?: number;
|
|
362
|
+
[MODAL_SCROLL_LOCK_OVERFLOW_KEY]?: string;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
function lockBodyScroll(): () => void {
|
|
366
|
+
const body = document.body as ScrollLockedBody;
|
|
367
|
+
const lockCount = body[MODAL_SCROLL_LOCK_COUNT_KEY] ?? 0;
|
|
368
|
+
if (lockCount === 0) {
|
|
369
|
+
body[MODAL_SCROLL_LOCK_OVERFLOW_KEY] = body.style.overflow;
|
|
370
|
+
body.style.overflow = "hidden";
|
|
371
|
+
}
|
|
372
|
+
body[MODAL_SCROLL_LOCK_COUNT_KEY] = lockCount + 1;
|
|
373
|
+
|
|
374
|
+
return () => {
|
|
375
|
+
const nextLockCount = Math.max((body[MODAL_SCROLL_LOCK_COUNT_KEY] ?? 1) - 1, 0);
|
|
376
|
+
if (nextLockCount > 0) {
|
|
377
|
+
body[MODAL_SCROLL_LOCK_COUNT_KEY] = nextLockCount;
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
body.style.overflow = body[MODAL_SCROLL_LOCK_OVERFLOW_KEY] ?? "";
|
|
382
|
+
delete body[MODAL_SCROLL_LOCK_COUNT_KEY];
|
|
383
|
+
delete body[MODAL_SCROLL_LOCK_OVERFLOW_KEY];
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface ModalProps {
|
|
388
|
+
isOpen: boolean;
|
|
389
|
+
onClose: () => void;
|
|
390
|
+
title: string;
|
|
391
|
+
description?: ReactNode;
|
|
392
|
+
children: ReactNode;
|
|
393
|
+
footer?: ReactNode;
|
|
394
|
+
size?: "sm" | "md" | "lg" | "xl";
|
|
395
|
+
showCloseButton?: boolean;
|
|
396
|
+
closeOnOverlayClick?: boolean;
|
|
397
|
+
className?: string;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function Modal({
|
|
401
|
+
isOpen,
|
|
402
|
+
onClose,
|
|
403
|
+
title,
|
|
404
|
+
description,
|
|
405
|
+
children,
|
|
406
|
+
footer,
|
|
407
|
+
size = "md",
|
|
408
|
+
showCloseButton = true,
|
|
409
|
+
closeOnOverlayClick = true,
|
|
410
|
+
className = "",
|
|
411
|
+
}: ModalProps) {
|
|
412
|
+
const modalRef = useRef<HTMLDivElement>(null);
|
|
413
|
+
const previousFocusRef = useRef<Element | null>(null);
|
|
414
|
+
const titleId = useId();
|
|
415
|
+
const onCloseRef = useRef(onClose);
|
|
416
|
+
onCloseRef.current = onClose;
|
|
417
|
+
|
|
418
|
+
useDialogKeyboardShortcuts({
|
|
419
|
+
dialogRef: modalRef,
|
|
420
|
+
enabled: isOpen,
|
|
421
|
+
onCancel: () => onCloseRef.current(),
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const handleFocusTrap = useCallback((event: KeyboardEvent) => {
|
|
425
|
+
if (event.key !== "Tab") {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const currentModal = modalRef.current;
|
|
430
|
+
const openModals = Array.from(document.querySelectorAll<HTMLElement>("[role='dialog'][aria-modal='true']"));
|
|
431
|
+
const topmostModal = openModals[openModals.length - 1];
|
|
432
|
+
if (!currentModal || currentModal !== topmostModal) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const focusable = currentModal.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
|
437
|
+
if (focusable.length === 0) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const first = focusable[0]!;
|
|
442
|
+
const last = focusable[focusable.length - 1]!;
|
|
443
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
444
|
+
event.preventDefault();
|
|
445
|
+
last.focus();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (!event.shiftKey && document.activeElement === last) {
|
|
449
|
+
event.preventDefault();
|
|
450
|
+
first.focus();
|
|
451
|
+
}
|
|
452
|
+
}, []);
|
|
453
|
+
|
|
454
|
+
useEffect(() => {
|
|
455
|
+
if (!isOpen) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
previousFocusRef.current = document.activeElement;
|
|
460
|
+
document.addEventListener("keydown", handleFocusTrap);
|
|
461
|
+
const unlockBodyScroll = lockBodyScroll();
|
|
462
|
+
modalRef.current?.focus();
|
|
463
|
+
|
|
464
|
+
return () => {
|
|
465
|
+
document.removeEventListener("keydown", handleFocusTrap);
|
|
466
|
+
unlockBodyScroll();
|
|
467
|
+
if (previousFocusRef.current instanceof HTMLElement) {
|
|
468
|
+
previousFocusRef.current.focus();
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
}, [handleFocusTrap, isOpen]);
|
|
472
|
+
|
|
473
|
+
if (!isOpen) {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return createPortal(
|
|
478
|
+
<div className="wapp-modal-layer">
|
|
479
|
+
<div
|
|
480
|
+
className="wapp-modal-overlay"
|
|
481
|
+
onClick={closeOnOverlayClick ? onClose : undefined}
|
|
482
|
+
aria-hidden="true"
|
|
483
|
+
/>
|
|
484
|
+
<div
|
|
485
|
+
ref={modalRef}
|
|
486
|
+
tabIndex={-1}
|
|
487
|
+
className={`wapp-modal wapp-modal-${size} ${className}`}
|
|
488
|
+
role="dialog"
|
|
489
|
+
aria-modal="true"
|
|
490
|
+
aria-labelledby={titleId}
|
|
491
|
+
>
|
|
492
|
+
<div className="wapp-modal-header">
|
|
493
|
+
<div className="wapp-modal-title-block">
|
|
494
|
+
<h2 id={titleId}>{title}</h2>
|
|
495
|
+
{description ? <p>{description}</p> : null}
|
|
496
|
+
</div>
|
|
497
|
+
{showCloseButton ? (
|
|
498
|
+
<button type="button" className="wapp-modal-close" aria-label="Close" onClick={onClose}>
|
|
499
|
+
×
|
|
500
|
+
</button>
|
|
501
|
+
) : null}
|
|
502
|
+
</div>
|
|
503
|
+
<div className="wapp-modal-body">
|
|
504
|
+
{children}
|
|
505
|
+
</div>
|
|
506
|
+
{footer ? (
|
|
507
|
+
<div className="wapp-modal-footer" data-dialog-actions>
|
|
508
|
+
{footer}
|
|
509
|
+
</div>
|
|
510
|
+
) : null}
|
|
511
|
+
</div>
|
|
512
|
+
</div>,
|
|
513
|
+
document.body,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export interface ConfirmModalProps {
|
|
518
|
+
isOpen: boolean;
|
|
519
|
+
onClose: () => void;
|
|
520
|
+
onConfirm: () => void;
|
|
521
|
+
title: string;
|
|
522
|
+
message: string;
|
|
523
|
+
children?: ReactNode;
|
|
524
|
+
confirmLabel?: string;
|
|
525
|
+
cancelLabel?: string;
|
|
526
|
+
loading?: boolean;
|
|
527
|
+
variant?: "danger" | "primary";
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function ConfirmModal({
|
|
531
|
+
isOpen,
|
|
532
|
+
onClose,
|
|
533
|
+
onConfirm,
|
|
534
|
+
title,
|
|
535
|
+
message,
|
|
536
|
+
children,
|
|
537
|
+
confirmLabel = "Confirm",
|
|
538
|
+
cancelLabel = "Cancel",
|
|
539
|
+
loading = false,
|
|
540
|
+
variant = "danger",
|
|
541
|
+
}: ConfirmModalProps) {
|
|
542
|
+
return (
|
|
543
|
+
<Modal
|
|
544
|
+
isOpen={isOpen}
|
|
545
|
+
onClose={onClose}
|
|
546
|
+
title={title}
|
|
547
|
+
size="sm"
|
|
548
|
+
footer={(
|
|
549
|
+
<>
|
|
550
|
+
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
|
|
551
|
+
{cancelLabel}
|
|
552
|
+
</Button>
|
|
553
|
+
<Button type="button" variant={variant} onClick={onConfirm} loading={loading}>
|
|
554
|
+
{confirmLabel}
|
|
555
|
+
</Button>
|
|
556
|
+
</>
|
|
557
|
+
)}
|
|
558
|
+
>
|
|
559
|
+
<p>{message}</p>
|
|
560
|
+
{children}
|
|
561
|
+
</Modal>
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
236
565
|
export function Dialog({
|
|
237
566
|
title,
|
|
238
567
|
description,
|
|
@@ -248,8 +577,11 @@ export function Dialog({
|
|
|
248
577
|
onClose?: () => void;
|
|
249
578
|
className?: string;
|
|
250
579
|
}) {
|
|
580
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
581
|
+
useDialogKeyboardShortcuts({ dialogRef, onCancel: onClose });
|
|
582
|
+
|
|
251
583
|
return (
|
|
252
|
-
<div className={`wapp-dialog ${className}`} role="dialog" aria-modal="true" aria-label={title}>
|
|
584
|
+
<div ref={dialogRef} className={`wapp-dialog ${className}`} role="dialog" aria-modal="true" aria-label={title}>
|
|
253
585
|
<div className="wapp-dialog-title">
|
|
254
586
|
<div>
|
|
255
587
|
<h2>{title}</h2>
|
|
@@ -260,7 +592,7 @@ export function Dialog({
|
|
|
260
592
|
<div className="wapp-dialog-body">
|
|
261
593
|
{children}
|
|
262
594
|
</div>
|
|
263
|
-
<div className="wapp-dialog-actions">
|
|
595
|
+
<div className="wapp-dialog-actions" data-dialog-actions>
|
|
264
596
|
{actions}
|
|
265
597
|
</div>
|
|
266
598
|
</div>
|
|
@@ -284,17 +616,6 @@ export function ConfirmDialog({
|
|
|
284
616
|
onCancel: () => void;
|
|
285
617
|
onConfirm: () => void;
|
|
286
618
|
}) {
|
|
287
|
-
useEffect(() => {
|
|
288
|
-
if (!open) return;
|
|
289
|
-
function handleKeyDown(event: KeyboardEvent) {
|
|
290
|
-
if (event.key === "Escape") {
|
|
291
|
-
onCancel();
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
document.addEventListener("keydown", handleKeyDown);
|
|
295
|
-
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
296
|
-
}, [onCancel, open]);
|
|
297
|
-
|
|
298
619
|
if (!open) return null;
|
|
299
620
|
return createPortal(
|
|
300
621
|
<div className="wapp-dialog-backdrop" role="presentation">
|
|
@@ -324,6 +645,31 @@ export interface ContextMenuPosition {
|
|
|
324
645
|
y: number;
|
|
325
646
|
}
|
|
326
647
|
|
|
648
|
+
interface ViewportBounds {
|
|
649
|
+
left: number;
|
|
650
|
+
top: number;
|
|
651
|
+
right: number;
|
|
652
|
+
bottom: number;
|
|
653
|
+
width: number;
|
|
654
|
+
height: number;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function getViewportBounds(): ViewportBounds {
|
|
658
|
+
const viewport = window.visualViewport;
|
|
659
|
+
const left = viewport?.offsetLeft ?? 0;
|
|
660
|
+
const top = viewport?.offsetTop ?? 0;
|
|
661
|
+
const width = viewport?.width ?? window.innerWidth;
|
|
662
|
+
const height = viewport?.height ?? window.innerHeight;
|
|
663
|
+
return {
|
|
664
|
+
left,
|
|
665
|
+
top,
|
|
666
|
+
width,
|
|
667
|
+
height,
|
|
668
|
+
right: left + width,
|
|
669
|
+
bottom: top + height,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
327
673
|
function MenuIcon() {
|
|
328
674
|
return (
|
|
329
675
|
<svg aria-hidden="true" viewBox="0 0 24 24" className="wapp-svg">
|
|
@@ -332,26 +678,52 @@ function MenuIcon() {
|
|
|
332
678
|
);
|
|
333
679
|
}
|
|
334
680
|
|
|
335
|
-
function
|
|
336
|
-
if (!menu) return position;
|
|
681
|
+
function boundedMenuStyle(menu: HTMLDivElement | null, position: ContextMenuPosition): CSSProperties {
|
|
337
682
|
const margin = 8;
|
|
338
|
-
const
|
|
683
|
+
const viewport = getViewportBounds();
|
|
684
|
+
const rect = menu?.getBoundingClientRect();
|
|
685
|
+
const width = rect?.width ?? 180;
|
|
686
|
+
const height = rect?.height ?? 0;
|
|
687
|
+
const maxHeight = Math.max(80, viewport.height - margin * 2);
|
|
688
|
+
const boundedHeight = Math.min(height || maxHeight, maxHeight);
|
|
689
|
+
const left = Math.max(viewport.left + margin, Math.min(position.x, viewport.right - width - margin));
|
|
690
|
+
const top = Math.max(viewport.top + margin, Math.min(position.y, viewport.bottom - boundedHeight - margin));
|
|
339
691
|
return {
|
|
340
|
-
|
|
341
|
-
|
|
692
|
+
position: "fixed",
|
|
693
|
+
left,
|
|
694
|
+
top,
|
|
695
|
+
maxHeight,
|
|
696
|
+
overflowY: "auto",
|
|
342
697
|
};
|
|
343
698
|
}
|
|
344
699
|
|
|
700
|
+
function hiddenMenuStyle(position: ContextMenuPosition): CSSProperties {
|
|
701
|
+
return {
|
|
702
|
+
position: "fixed",
|
|
703
|
+
left: position.x,
|
|
704
|
+
top: position.y,
|
|
705
|
+
visibility: "hidden",
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function isDestructiveActionMenuItem(item: ActionMenuItem): boolean {
|
|
710
|
+
return item.destructive === true || item.id?.toLowerCase().includes("delete") === true || item.label.toLowerCase().includes("delete");
|
|
711
|
+
}
|
|
712
|
+
|
|
345
713
|
function ActionMenuItems({ items, onItemClick }: { items: ActionMenuItem[]; onItemClick: (item: ActionMenuItem) => void }) {
|
|
714
|
+
const orderedItems = [
|
|
715
|
+
...items.filter((item) => !isDestructiveActionMenuItem(item)),
|
|
716
|
+
...items.filter(isDestructiveActionMenuItem),
|
|
717
|
+
];
|
|
346
718
|
return (
|
|
347
719
|
<div className="wapp-action-menu-items">
|
|
348
|
-
{
|
|
720
|
+
{orderedItems.map((item, index) => (
|
|
349
721
|
<button
|
|
350
722
|
type="button"
|
|
351
723
|
role="menuitem"
|
|
352
724
|
key={item.id ?? `${item.label}:${index}`}
|
|
353
725
|
disabled={item.disabled}
|
|
354
|
-
className={`wapp-action-menu-item ${item
|
|
726
|
+
className={`wapp-action-menu-item ${isDestructiveActionMenuItem(item) ? "danger" : ""}`}
|
|
355
727
|
onClick={() => onItemClick(item)}
|
|
356
728
|
>
|
|
357
729
|
{item.label}
|
|
@@ -366,11 +738,15 @@ export function ActionMenu({
|
|
|
366
738
|
ariaLabel = "Actions",
|
|
367
739
|
disabled = false,
|
|
368
740
|
trigger,
|
|
741
|
+
triggerVariant = "default",
|
|
742
|
+
triggerSize = "default",
|
|
369
743
|
}: {
|
|
370
744
|
items: ActionMenuItem[];
|
|
371
745
|
ariaLabel?: string;
|
|
372
746
|
disabled?: boolean;
|
|
373
747
|
trigger?: ReactNode;
|
|
748
|
+
triggerVariant?: "default" | "ghost";
|
|
749
|
+
triggerSize?: "default" | "compact";
|
|
374
750
|
}) {
|
|
375
751
|
const [open, setOpen] = useState(false);
|
|
376
752
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
@@ -399,12 +775,11 @@ export function ActionMenu({
|
|
|
399
775
|
useLayoutEffect(() => {
|
|
400
776
|
if (!open || !triggerRef.current || !menuRef.current) return;
|
|
401
777
|
const triggerRect = triggerRef.current.getBoundingClientRect();
|
|
402
|
-
const menuRect = menuRef.current.getBoundingClientRect();
|
|
403
|
-
const margin = 8;
|
|
404
778
|
setStyle({
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
779
|
+
...boundedMenuStyle(menuRef.current, {
|
|
780
|
+
x: triggerRect.right - menuRef.current.getBoundingClientRect().width,
|
|
781
|
+
y: triggerRect.bottom + 4,
|
|
782
|
+
}),
|
|
408
783
|
});
|
|
409
784
|
}, [open]);
|
|
410
785
|
|
|
@@ -419,7 +794,11 @@ export function ActionMenu({
|
|
|
419
794
|
<button
|
|
420
795
|
type="button"
|
|
421
796
|
ref={triggerRef}
|
|
422
|
-
className=
|
|
797
|
+
className={[
|
|
798
|
+
"wapp-action-menu-trigger",
|
|
799
|
+
triggerVariant === "ghost" ? "wapp-action-menu-trigger-ghost" : "",
|
|
800
|
+
triggerSize === "compact" ? "wapp-action-menu-trigger-compact" : "",
|
|
801
|
+
].filter(Boolean).join(" ")}
|
|
423
802
|
aria-label={ariaLabel}
|
|
424
803
|
aria-haspopup="menu"
|
|
425
804
|
aria-expanded={open}
|
|
@@ -450,14 +829,33 @@ export function ContextMenu({
|
|
|
450
829
|
ariaLabel?: string;
|
|
451
830
|
}) {
|
|
452
831
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
453
|
-
const [
|
|
832
|
+
const [menuStyle, setMenuStyle] = useState<CSSProperties | null>(position ? hiddenMenuStyle(position) : null);
|
|
454
833
|
|
|
455
834
|
useLayoutEffect(() => {
|
|
456
|
-
|
|
457
|
-
|
|
835
|
+
if (!position) {
|
|
836
|
+
setMenuStyle(null);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
let frameId: number | null = null;
|
|
841
|
+
const updatePosition = () => {
|
|
842
|
+
setMenuStyle(boundedMenuStyle(menuRef.current, position));
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
setMenuStyle(hiddenMenuStyle(position));
|
|
846
|
+
updatePosition();
|
|
847
|
+
frameId = window.requestAnimationFrame(updatePosition);
|
|
848
|
+
|
|
849
|
+
return () => {
|
|
850
|
+
if (frameId !== null) {
|
|
851
|
+
window.cancelAnimationFrame(frameId);
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
}, [items, position]);
|
|
458
855
|
|
|
459
856
|
useEffect(() => {
|
|
460
857
|
if (!position) return;
|
|
858
|
+
const currentPosition = position;
|
|
461
859
|
function handleKeyDown(event: KeyboardEvent) {
|
|
462
860
|
if (event.key === "Escape") onClose();
|
|
463
861
|
}
|
|
@@ -467,17 +865,24 @@ export function ContextMenu({
|
|
|
467
865
|
function handleScroll() {
|
|
468
866
|
onClose();
|
|
469
867
|
}
|
|
868
|
+
function handleResize() {
|
|
869
|
+
setMenuStyle(boundedMenuStyle(menuRef.current, currentPosition));
|
|
870
|
+
}
|
|
470
871
|
document.addEventListener("keydown", handleKeyDown);
|
|
471
872
|
document.addEventListener("mousedown", handleMouseDown);
|
|
472
873
|
window.addEventListener("scroll", handleScroll, true);
|
|
874
|
+
window.addEventListener("resize", handleResize);
|
|
875
|
+
window.visualViewport?.addEventListener("resize", handleResize);
|
|
473
876
|
return () => {
|
|
474
877
|
document.removeEventListener("keydown", handleKeyDown);
|
|
475
878
|
document.removeEventListener("mousedown", handleMouseDown);
|
|
476
879
|
window.removeEventListener("scroll", handleScroll, true);
|
|
880
|
+
window.removeEventListener("resize", handleResize);
|
|
881
|
+
window.visualViewport?.removeEventListener("resize", handleResize);
|
|
477
882
|
};
|
|
478
883
|
}, [onClose, position]);
|
|
479
884
|
|
|
480
|
-
if (!position || !
|
|
885
|
+
if (!position || !menuStyle) return null;
|
|
481
886
|
|
|
482
887
|
function handleItemClick(item: ActionMenuItem) {
|
|
483
888
|
if (item.disabled) return;
|
|
@@ -491,7 +896,7 @@ export function ContextMenu({
|
|
|
491
896
|
className="wapp-action-menu"
|
|
492
897
|
role="menu"
|
|
493
898
|
aria-label={ariaLabel}
|
|
494
|
-
style={
|
|
899
|
+
style={menuStyle}
|
|
495
900
|
onContextMenu={(event: ReactMouseEvent<HTMLDivElement>) => {
|
|
496
901
|
event.preventDefault();
|
|
497
902
|
event.stopPropagation();
|
package/src/web/sidebar/types.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
|
-
export type BadgeVariant =
|
|
3
|
+
export type BadgeVariant =
|
|
4
|
+
| "default"
|
|
5
|
+
| "success"
|
|
6
|
+
| "warning"
|
|
7
|
+
| "error"
|
|
8
|
+
| "info"
|
|
9
|
+
| "disabled"
|
|
10
|
+
| "idle"
|
|
11
|
+
| "planning"
|
|
12
|
+
| "running"
|
|
13
|
+
| "completed"
|
|
14
|
+
| "stopped"
|
|
15
|
+
| "failed"
|
|
16
|
+
| "merged"
|
|
17
|
+
| "pushed"
|
|
18
|
+
| "deleted"
|
|
19
|
+
| "plan_ready";
|
|
4
20
|
|
|
5
21
|
export interface WebAppRoute {
|
|
6
22
|
view: string;
|