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