@pablozaiden/webapp 1.1.1 → 1.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.
@@ -38,6 +38,10 @@
38
38
  --wapp-safe-area-right: env(safe-area-inset-right, 0px);
39
39
  --wapp-mobile-bottom-clearance: max(var(--wapp-safe-area-bottom), 0.75rem);
40
40
  --wapp-viewport-height: 100vh;
41
+ --wapp-motion-fast: 350ms;
42
+ --wapp-motion-normal: 350ms;
43
+ --wapp-motion-ease: cubic-bezier(0.22, 1, 0.36, 1);
44
+ --wapp-motion-distance: 0.25rem;
41
45
  font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
42
46
  }
43
47
 
@@ -638,6 +642,7 @@ textarea::placeholder {
638
642
  }
639
643
 
640
644
  .wapp-main-content {
645
+ view-transition-name: wapp-main-content;
641
646
  display: flex;
642
647
  flex-direction: column;
643
648
  flex: 1;
@@ -648,6 +653,132 @@ textarea::placeholder {
648
653
  scroll-padding-bottom: var(--wapp-mobile-bottom-clearance);
649
654
  }
650
655
 
656
+ .wapp-route-view,
657
+ .wapp-route-fade {
658
+ min-width: 0;
659
+ }
660
+
661
+ .wapp-route-view {
662
+ display: flex;
663
+ flex: 1;
664
+ min-height: 0;
665
+ flex-direction: column;
666
+ }
667
+
668
+ .wapp-route-view,
669
+ .wapp-route-fade {
670
+ animation: wapp-fade-in var(--wapp-motion-normal) var(--wapp-motion-ease) both;
671
+ }
672
+
673
+ .wapp-native-route-transitions .wapp-route-view {
674
+ animation: none;
675
+ }
676
+
677
+ ::view-transition-old(wapp-main-content) {
678
+ animation: wapp-view-transition-old var(--wapp-motion-normal) var(--wapp-motion-ease) both;
679
+ }
680
+
681
+ ::view-transition-new(wapp-main-content) {
682
+ animation: wapp-view-transition-new var(--wapp-motion-normal) var(--wapp-motion-ease) both;
683
+ }
684
+
685
+ @keyframes wapp-view-transition-old {
686
+ to {
687
+ opacity: 0;
688
+ transform: translateY(calc(var(--wapp-motion-distance) * -1));
689
+ }
690
+ }
691
+
692
+ @keyframes wapp-view-transition-new {
693
+ from {
694
+ opacity: 0;
695
+ transform: translateY(var(--wapp-motion-distance));
696
+ }
697
+ }
698
+
699
+ .wapp-motion-enter {
700
+ animation: wapp-fade-in var(--wapp-motion-fast) var(--wapp-motion-ease) both;
701
+ }
702
+
703
+ .wapp-motion-exit {
704
+ animation: wapp-fade-out var(--wapp-motion-fast) var(--wapp-motion-ease) both;
705
+ pointer-events: none;
706
+ }
707
+
708
+ @keyframes wapp-fade-in {
709
+ from {
710
+ opacity: 0;
711
+ transform: translateY(var(--wapp-motion-distance));
712
+ }
713
+
714
+ to {
715
+ opacity: 1;
716
+ transform: translateY(0);
717
+ }
718
+ }
719
+
720
+ @keyframes wapp-fade-out {
721
+ from {
722
+ opacity: 1;
723
+ transform: translateY(0);
724
+ }
725
+
726
+ to {
727
+ opacity: 0;
728
+ transform: translateY(calc(var(--wapp-motion-distance) * -1));
729
+ }
730
+ }
731
+
732
+ .wapp-streaming-text-chunk {
733
+ animation: wapp-streaming-text-enter var(--wapp-motion-fast) var(--wapp-motion-ease) both;
734
+ }
735
+
736
+ @keyframes wapp-streaming-text-enter {
737
+ from {
738
+ opacity: 0;
739
+ }
740
+
741
+ to {
742
+ opacity: 1;
743
+ }
744
+ }
745
+
746
+ .wapp-collapsible {
747
+ display: grid;
748
+ grid-template-rows: 1fr;
749
+ transition: grid-template-rows var(--wapp-motion-normal) var(--wapp-motion-ease), opacity var(--wapp-motion-normal) var(--wapp-motion-ease);
750
+ }
751
+
752
+ .wapp-collapsible-inner {
753
+ min-height: 0;
754
+ overflow: hidden;
755
+ }
756
+
757
+ .wapp-collapsible-enter {
758
+ grid-template-rows: 0fr;
759
+ opacity: 0;
760
+ }
761
+
762
+ .wapp-collapsible-idle {
763
+ grid-template-rows: 1fr;
764
+ opacity: 1;
765
+ }
766
+
767
+ .wapp-collapsible-exit {
768
+ grid-template-rows: 0fr;
769
+ opacity: 0;
770
+ pointer-events: none;
771
+ }
772
+
773
+ .wapp-async-state {
774
+ animation: wapp-fade-in var(--wapp-motion-fast) var(--wapp-motion-ease) both;
775
+ }
776
+
777
+ .wapp-async-state-refreshing {
778
+ color: var(--wapp-muted);
779
+ font-size: 0.75rem;
780
+ }
781
+
651
782
  .wapp-page {
652
783
  width: 100%;
653
784
  min-width: 0;
@@ -1208,6 +1339,77 @@ textarea::placeholder {
1208
1339
  box-shadow: 0 16px 36px rgb(15 23 42 / 0.18);
1209
1340
  }
1210
1341
 
1342
+ .wapp-floating-panel {
1343
+ z-index: 1000;
1344
+ transform-origin: top left;
1345
+ }
1346
+
1347
+ .wapp-floating-panel.wapp-motion-enter {
1348
+ animation-name: wapp-floating-panel-enter;
1349
+ }
1350
+
1351
+ .wapp-floating-panel.wapp-motion-exit {
1352
+ animation-name: wapp-floating-panel-exit;
1353
+ }
1354
+
1355
+ @keyframes wapp-floating-panel-enter {
1356
+ from {
1357
+ opacity: 0;
1358
+ transform: translateY(0.25rem) scale(0.98);
1359
+ }
1360
+
1361
+ to {
1362
+ opacity: 1;
1363
+ transform: translateY(0) scale(1);
1364
+ }
1365
+ }
1366
+
1367
+ @keyframes wapp-floating-panel-exit {
1368
+ from {
1369
+ opacity: 1;
1370
+ transform: translateY(0) scale(1);
1371
+ }
1372
+
1373
+ to {
1374
+ opacity: 0;
1375
+ transform: translateY(0.25rem) scale(0.98);
1376
+ }
1377
+ }
1378
+
1379
+ .wapp-action-menu.wapp-motion-enter {
1380
+ transform-origin: top right;
1381
+ animation-name: wapp-menu-enter;
1382
+ }
1383
+
1384
+ .wapp-action-menu.wapp-motion-exit {
1385
+ transform-origin: top right;
1386
+ animation-name: wapp-menu-exit;
1387
+ }
1388
+
1389
+ @keyframes wapp-menu-enter {
1390
+ from {
1391
+ opacity: 0;
1392
+ transform: translateY(-0.25rem) scale(0.98);
1393
+ }
1394
+
1395
+ to {
1396
+ opacity: 1;
1397
+ transform: translateY(0) scale(1);
1398
+ }
1399
+ }
1400
+
1401
+ @keyframes wapp-menu-exit {
1402
+ from {
1403
+ opacity: 1;
1404
+ transform: translateY(0) scale(1);
1405
+ }
1406
+
1407
+ to {
1408
+ opacity: 0;
1409
+ transform: translateY(-0.25rem) scale(0.98);
1410
+ }
1411
+ }
1412
+
1211
1413
  :root.dark .wapp-action-menu {
1212
1414
  border-color: #334155;
1213
1415
  background: #262626;
@@ -1684,7 +1886,7 @@ textarea::placeholder {
1684
1886
  padding: 0.75rem;
1685
1887
  box-shadow: 0 8px 24px rgb(0 0 0 / 0.14);
1686
1888
  pointer-events: auto;
1687
- animation: wapp-toast-enter 160ms ease-out;
1889
+ animation: wapp-toast-enter var(--wapp-motion-fast) var(--wapp-motion-ease) both;
1688
1890
  }
1689
1891
 
1690
1892
  .wapp-toast-success {
@@ -1754,9 +1956,30 @@ textarea::placeholder {
1754
1956
  }
1755
1957
  }
1756
1958
 
1959
+ .wapp-toast.wapp-motion-enter {
1960
+ animation-name: wapp-toast-enter;
1961
+ }
1962
+
1963
+ .wapp-toast.wapp-motion-exit {
1964
+ animation-name: wapp-toast-exit;
1965
+ pointer-events: none;
1966
+ }
1967
+
1968
+ @keyframes wapp-toast-exit {
1969
+ to {
1970
+ opacity: 0;
1971
+ transform: translateY(0.25rem);
1972
+ }
1973
+ }
1974
+
1757
1975
  @media (prefers-reduced-motion: reduce) {
1758
- .wapp-toast {
1759
- animation: none;
1976
+ *,
1977
+ *::before,
1978
+ *::after {
1979
+ animation-duration: 0.01ms !important;
1980
+ animation-iteration-count: 1 !important;
1981
+ scroll-behavior: auto !important;
1982
+ transition-duration: 0.01ms !important;
1760
1983
  }
1761
1984
  }
1762
1985
 
@@ -1841,6 +2064,76 @@ textarea::placeholder {
1841
2064
  box-shadow: none;
1842
2065
  }
1843
2066
 
2067
+ .wapp-tabs {
2068
+ display: flex;
2069
+ align-items: flex-end;
2070
+ gap: 0.25rem;
2071
+ border-bottom: 1px solid var(--wapp-border-soft);
2072
+ }
2073
+
2074
+ .wapp-tab {
2075
+ position: relative;
2076
+ border: 0;
2077
+ background: transparent;
2078
+ color: var(--wapp-muted);
2079
+ cursor: pointer;
2080
+ padding: 0.625rem 0.75rem;
2081
+ transition: color 120ms ease;
2082
+ }
2083
+
2084
+ .wapp-tab::after {
2085
+ position: absolute;
2086
+ right: 0.75rem;
2087
+ bottom: -1px;
2088
+ left: 0.75rem;
2089
+ height: 2px;
2090
+ background: var(--wapp-primary);
2091
+ content: "";
2092
+ opacity: 0;
2093
+ transform: scaleX(0.35);
2094
+ transition: opacity 120ms ease, transform 160ms var(--wapp-motion-ease);
2095
+ }
2096
+
2097
+ .wapp-tab:hover,
2098
+ .wapp-tab.active {
2099
+ color: var(--wapp-text);
2100
+ }
2101
+
2102
+ .wapp-tab.active::after {
2103
+ opacity: 1;
2104
+ transform: scaleX(1);
2105
+ }
2106
+
2107
+ .wapp-tab:disabled {
2108
+ cursor: not-allowed;
2109
+ opacity: 0.5;
2110
+ }
2111
+
2112
+ .wapp-tab:focus-visible {
2113
+ outline: 2px solid var(--wapp-primary);
2114
+ outline-offset: -2px;
2115
+ }
2116
+
2117
+ .wapp-tab-panels {
2118
+ position: relative;
2119
+ min-width: 0;
2120
+ }
2121
+
2122
+ .wapp-tab-panel {
2123
+ min-width: 0;
2124
+ }
2125
+
2126
+ .wapp-tab-panel.wapp-motion-exit {
2127
+ position: absolute;
2128
+ inset: 0;
2129
+ overflow: hidden;
2130
+ width: 100%;
2131
+ }
2132
+
2133
+ .wapp-animated-list-contents {
2134
+ display: contents;
2135
+ }
2136
+
1844
2137
  .wapp-list {
1845
2138
  margin-top: 0.75rem;
1846
2139
  border: 1px solid var(--wapp-border-soft);
@@ -1932,6 +2225,12 @@ textarea::placeholder {
1932
2225
  background: var(--wapp-overlay-bg);
1933
2226
  backdrop-filter: var(--wapp-overlay-blur);
1934
2227
  z-index: 50;
2228
+ animation: wapp-fade-in var(--wapp-motion-fast) var(--wapp-motion-ease) both;
2229
+ }
2230
+
2231
+ .wapp-mobile-backdrop.wapp-motion-exit {
2232
+ animation-name: wapp-fade-out;
2233
+ pointer-events: none;
1935
2234
  }
1936
2235
 
1937
2236
  .wapp-dialog-backdrop {
@@ -1940,6 +2239,7 @@ textarea::placeholder {
1940
2239
  z-index: 80;
1941
2240
  background: rgb(0 0 0 / 0.58);
1942
2241
  backdrop-filter: blur(10px);
2242
+ animation: wapp-fade-in var(--wapp-motion-fast) var(--wapp-motion-ease) both;
1943
2243
  }
1944
2244
 
1945
2245
  .wapp-dialog {
@@ -1955,6 +2255,36 @@ textarea::placeholder {
1955
2255
  background: var(--wapp-surface);
1956
2256
  padding: 0;
1957
2257
  box-shadow: 0 24px 55px rgb(0 0 0 / 0.38);
2258
+ animation: wapp-dialog-enter var(--wapp-motion-fast) var(--wapp-motion-ease) both;
2259
+ }
2260
+
2261
+ .wapp-dialog.wapp-motion-exit,
2262
+ .wapp-modal.wapp-motion-exit {
2263
+ animation-name: wapp-dialog-exit;
2264
+ }
2265
+
2266
+ @keyframes wapp-dialog-enter {
2267
+ from {
2268
+ opacity: 0;
2269
+ transform: translate(-50%, calc(-50% + 0.5rem)) scale(0.98);
2270
+ }
2271
+
2272
+ to {
2273
+ opacity: 1;
2274
+ transform: translate(-50%, -50%) scale(1);
2275
+ }
2276
+ }
2277
+
2278
+ @keyframes wapp-dialog-exit {
2279
+ from {
2280
+ opacity: 1;
2281
+ transform: translate(-50%, -50%) scale(1);
2282
+ }
2283
+
2284
+ to {
2285
+ opacity: 0;
2286
+ transform: translate(-50%, calc(-50% + 0.5rem)) scale(0.98);
2287
+ }
1958
2288
  }
1959
2289
 
1960
2290
  .wapp-dialog-title {
@@ -2026,6 +2356,13 @@ textarea::placeholder {
2026
2356
  overflow-x: hidden;
2027
2357
  overflow-y: auto;
2028
2358
  padding: 0.5rem;
2359
+ animation: wapp-fade-in var(--wapp-motion-fast) var(--wapp-motion-ease) both;
2360
+ }
2361
+
2362
+ .wapp-modal-layer.wapp-motion-exit,
2363
+ .wapp-dialog-backdrop.wapp-motion-exit {
2364
+ animation: wapp-fade-out var(--wapp-motion-fast) var(--wapp-motion-ease) both;
2365
+ pointer-events: none;
2029
2366
  }
2030
2367
 
2031
2368
  .wapp-modal-overlay {
@@ -2048,6 +2385,7 @@ textarea::placeholder {
2048
2385
  background: var(--wapp-surface);
2049
2386
  box-shadow: 0 24px 55px rgb(0 0 0 / 0.38);
2050
2387
  outline: none;
2388
+ animation: wapp-dialog-enter var(--wapp-motion-fast) var(--wapp-motion-ease) both;
2051
2389
  }
2052
2390
 
2053
2391
  .wapp-modal-sm {
@@ -2205,10 +2543,18 @@ textarea::placeholder {
2205
2543
  transform: translateX(0);
2206
2544
  }
2207
2545
 
2546
+ :root[data-wapp-mobile] .wapp-shell:not(.sidebar-open) .wapp-sidebar {
2547
+ pointer-events: none;
2548
+ }
2549
+
2208
2550
  :root[data-wapp-mobile] .wapp-shell.sidebar-open .wapp-mobile-backdrop {
2209
2551
  display: block;
2210
2552
  }
2211
2553
 
2554
+ :root[data-wapp-mobile] .wapp-mobile-backdrop.wapp-motion-exit {
2555
+ display: block;
2556
+ }
2557
+
2212
2558
  :root[data-wapp-mobile] .wapp-main-header {
2213
2559
  padding: 0.875rem 1rem;
2214
2560
  }
package/src/web/toast.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
2
  import { createPortal } from "react-dom";
3
+ import { MOTION_FAST_MS, usePresence } from "./motion";
3
4
 
4
5
  export type ToastVariant = "success" | "error" | "warning" | "info";
5
6
  export type ToastId = string;
@@ -67,31 +68,77 @@ function normalizeDuration(duration: number | undefined): number {
67
68
  return duration;
68
69
  }
69
70
 
71
+ function ToastItem({
72
+ toast,
73
+ present,
74
+ onDismiss,
75
+ onExitComplete,
76
+ }: {
77
+ toast: Toast;
78
+ present: boolean;
79
+ onDismiss: (id: ToastId) => void;
80
+ onExitComplete: (id: ToastId) => void;
81
+ }) {
82
+ const presence = usePresence(present, { duration: MOTION_FAST_MS });
83
+
84
+ useEffect(() => {
85
+ if (!present && !presence.mounted) {
86
+ onExitComplete(toast.id);
87
+ }
88
+ }, [onExitComplete, presence.mounted, present, toast.id]);
89
+
90
+ if (!presence.mounted) {
91
+ return null;
92
+ }
93
+
94
+ const isError = toast.variant === "error";
95
+ return (
96
+ <div
97
+ className={`wapp-toast wapp-toast-${toast.variant} wapp-motion-${presence.state}`}
98
+ data-toast-variant={toast.variant}
99
+ role={isError ? "alert" : "status"}
100
+ aria-live={isError ? "assertive" : "polite"}
101
+ aria-atomic="true"
102
+ aria-hidden={present ? undefined : true}
103
+ >
104
+ <span className="wapp-toast-message">{toast.message}</span>
105
+ <button type="button" className="wapp-toast-dismiss" aria-label="Dismiss notification" onClick={() => onDismiss(toast.id)}>
106
+ ×
107
+ </button>
108
+ </div>
109
+ );
110
+ }
111
+
70
112
  function ToastViewport({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: ToastId) => void }) {
71
- if (toasts.length === 0) {
113
+ const [renderedToasts, setRenderedToasts] = useState<Toast[]>(toasts);
114
+ const activeIds = useMemo(() => new Set(toasts.map((toast) => toast.id)), [toasts]);
115
+
116
+ useEffect(() => {
117
+ setRenderedToasts((current) => {
118
+ const exiting = current.filter((toast) => !activeIds.has(toast.id));
119
+ return [...toasts, ...exiting];
120
+ });
121
+ }, [activeIds, toasts]);
122
+
123
+ const removeExited = useCallback((id: ToastId) => {
124
+ setRenderedToasts((current) => current.filter((toast) => toast.id !== id));
125
+ }, []);
126
+
127
+ if (renderedToasts.length === 0) {
72
128
  return null;
73
129
  }
74
130
 
75
131
  return createPortal(
76
- <div className="wapp-toast-viewport" role="region" aria-label="Notifications">
77
- {toasts.map((toast) => {
78
- const isError = toast.variant === "error";
79
- return (
80
- <div
81
- key={toast.id}
82
- className={`wapp-toast wapp-toast-${toast.variant}`}
83
- data-toast-variant={toast.variant}
84
- role={isError ? "alert" : "status"}
85
- aria-live={isError ? "assertive" : "polite"}
86
- aria-atomic="true"
87
- >
88
- <span className="wapp-toast-message">{toast.message}</span>
89
- <button type="button" className="wapp-toast-dismiss" aria-label="Dismiss notification" onClick={() => onDismiss(toast.id)}>
90
- ×
91
- </button>
92
- </div>
93
- );
94
- })}
132
+ <div className="wapp-toast-viewport" role="region" aria-label="Notifications" aria-hidden={toasts.length === 0 ? true : undefined}>
133
+ {renderedToasts.map((toast) => (
134
+ <ToastItem
135
+ key={toast.id}
136
+ toast={toast}
137
+ present={activeIds.has(toast.id)}
138
+ onDismiss={onDismiss}
139
+ onExitComplete={removeExited}
140
+ />
141
+ ))}
95
142
  </div>,
96
143
  document.body,
97
144
  );
@@ -1,5 +1,5 @@
1
1
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
- import type { WebAppConfigResponse } from "../contracts";
2
+ import { VALID_LOG_LEVELS, type WebAppConfigResponse } from "../contracts";
3
3
  import { appJson } from "./api-client";
4
4
 
5
5
  export interface WebAppConfigState {
@@ -16,7 +16,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
16
16
  }
17
17
 
18
18
  function isLogLevelName(value: unknown): boolean {
19
- return value === "trace" || value === "debug" || value === "info" || value === "warn" || value === "error";
19
+ return typeof value === "string" && VALID_LOG_LEVELS.includes(value as WebAppConfigResponse["logLevel"]["level"]);
20
20
  }
21
21
 
22
22
  function isCurrentUser(value: unknown): boolean {