openguessr-ui 0.3.2 → 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/README.md CHANGED
@@ -249,6 +249,8 @@ The popup component is used for dialogs. It takes the following props:
249
249
  - `frameless`: Reduces the padding, e.g. for iframes
250
250
  - `onuserclose`: Called when the user closes the popup
251
251
 
252
+ On mobile devices (portrait orientation, and narrow in terms of width), the popup transforms into a drawer.
253
+
252
254
  A common design pattern is to include a heading at the top of a popup. For this, the built-in `popup-title` class can be used:
253
255
 
254
256
  ```html
package/base.css CHANGED
@@ -14,7 +14,8 @@
14
14
  html {
15
15
  /* This UI library is for games, not sites, double-tap to zoom or drag down to refresh are not wanted */
16
16
  overscroll-behavior: none;
17
- touch-action: none;
17
+ /* Block double-tap or pinch to zoom */
18
+ touch-action: pan-x pan-y;
18
19
  }
19
20
 
20
21
  html * {
@@ -23,8 +24,8 @@ html * {
23
24
 
24
25
  body {
25
26
  font-family: Barlow, sans-serif;
26
- /* Disable touch action again since body could be a scroll container */
27
- touch-action: none;
27
+ /* Set touch action again since body could be a scroll container */
28
+ touch-action: pan-x pan-y;
28
29
  background-color: var(--background-color);
29
30
  }
30
31
 
@@ -1,6 +1,6 @@
1
1
  <script>
2
2
  import Popup from "./Popup.svelte";
3
- import { confirmPopup } from "../confirmPopup.svelte.js";
3
+ import { confirmPopup } from "../utils/confirm-popup.svelte.js";
4
4
 
5
5
  let { onconfirm } = $props();
6
6
 
@@ -1,5 +1,5 @@
1
1
  <script>
2
- import { errorPopupState } from "../errorPopup.svelte.js";
2
+ import { errorPopupState } from "../utils/error-popup.svelte.js";
3
3
  import Popup from "./Popup.svelte";
4
4
  </script>
5
5
 
@@ -1,5 +1,5 @@
1
1
  <script>
2
- import { showConfirmPopup } from "../confirmPopup.svelte.js";
2
+ import { showConfirmPopup } from "../utils/confirm-popup.svelte.js";
3
3
  import experimentStars from "../images/experiment_stars.png";
4
4
 
5
5
  let { class: classes } = $props();
@@ -1,5 +1,5 @@
1
1
  <script>
2
- import { notification, hideNotification } from "../notifications.svelte.js";
2
+ import { notification, hideNotification } from "../utils/notifications.svelte.js";
3
3
  import { Check, SquareArrowOutUpRight, X } from "@lucide/svelte";
4
4
  import { fly } from "svelte/transition";
5
5
 
@@ -5,13 +5,145 @@
5
5
  <script>
6
6
  import { X } from "@lucide/svelte";
7
7
  import { untrack } from "svelte";
8
+ import { on } from "svelte/events";
9
+ import { cubicBezier } from "../utils/cubic-bezier.js";
10
+ import { clamp } from "../utils/clamp.js";
11
+ import { isScrolledDown, canScrollDown } from "../utils/scroll-check.js";
12
+ import { scale, fade } from "svelte/transition";
8
13
  import { backOut } from "svelte/easing";
9
- import { fade, scale } from "svelte/transition";
10
-
14
+ import { MediaQuery } from "svelte/reactivity";
11
15
  let { children, frameless, slim, verySlim, ultraSlim, open = $bindable(false), onuserclose } = $props();
12
16
 
13
17
  let zIndex = $state(0);
14
18
 
19
+ // Transitions ------------------------------------------------------------------------------
20
+ const DRAWER_TIMING = { duration: 500, easing: cubicBezier(0.32, 0.72, 0, 1) };
21
+ const DRAWER_HIDDEN_OVERSHOOT = 50; // Added negative closed y-pos offset to move the panel shadow off-screen (100px blur - 50px --layout-spacer)
22
+ const popupToDrawer = new MediaQuery("(orientation: portrait) and (max-width: 500px)");
23
+
24
+ const popupHolderTransition = (node) => (popupToDrawer.current ? { duration: 0 } : fade(node, { duration: 100 }));
25
+
26
+ // Replaced when dragged closed, so the close continues the swipe's speed
27
+ let closeTiming = DRAWER_TIMING;
28
+
29
+ function windowTransition(node, _params, { direction }) {
30
+ if (!popupToDrawer.current) return scale(node, { easing: backOut, duration: 250, start: 0.9 });
31
+
32
+ if (direction == "in") {
33
+ closeTiming = DRAWER_TIMING;
34
+ drawerVerticalOffset = node.offsetHeight + DRAWER_HIDDEN_OVERSHOOT;
35
+ animateDrawerOpen(); // Opening is animated by this function, so a drag can take over at any point
36
+ return { duration: 0 };
37
+ } else {
38
+ // Close from wherever the drawer currently is
39
+ cancelAnimationFrame(openAnimationFrame);
40
+ const start = drawerVerticalOffset;
41
+ return {
42
+ ...closeTiming,
43
+ css: (t) => `transform: translate3d(0, ${closedOffset + (start - closedOffset) * t}px, 0)`,
44
+ };
45
+ }
46
+ }
47
+
48
+ function drawerOverlayOutTransition() {
49
+ if (!popupToDrawer.current) return { duration: 0 };
50
+ const start = overlayProgress; // Current strength
51
+ return {
52
+ ...closeTiming,
53
+ // Fade color opacity and blur strength relative to time factor t (0-1)
54
+ css: (t) =>
55
+ `background-color: color-mix(in srgb, var(--overlay-color) ${start * t * 100}%, transparent); backdrop-filter: blur(calc(var(--weak-blur) * ${start * t}));`,
56
+ };
57
+ }
58
+
59
+ // Drawer logic ------------------------------------------------------------------------------------
60
+
61
+ // Bindings
62
+ let contentEl = $state();
63
+ let uiWindowHeight = $state();
64
+
65
+ // Drawer state
66
+ let dragStartY = $state();
67
+ let dragStartVerticalOffset;
68
+ let isDragging = $state(false);
69
+ let drawerVerticalOffset = $state(0);
70
+ let velocitySamples = $state([]); // Y positions with timestamps
71
+ let openAnimationFrame; // Latest open animation frame for cancelling it
72
+
73
+ const closedOffset = $derived(uiWindowHeight + DRAWER_HIDDEN_OVERSHOOT);
74
+ const overlayProgress = $derived(Math.min(1, 1 - drawerVerticalOffset / closedOffset));
75
+
76
+ // Animate drawer to fully open
77
+ const animateDrawerOpen = () => {
78
+ if (drawerVerticalOffset == 0) return; // Skip when the drawer is already fully open
79
+ const start = drawerVerticalOffset;
80
+ const startTime = performance.now();
81
+ const step = (now) => {
82
+ const progress = Math.min(1, (now - startTime) / DRAWER_TIMING.duration); // Progress (0-1)
83
+ drawerVerticalOffset = start * (1 - DRAWER_TIMING.easing(progress));
84
+ if (progress < 1) openAnimationFrame = requestAnimationFrame(step);
85
+ };
86
+ openAnimationFrame = requestAnimationFrame(step);
87
+ };
88
+
89
+ const dragStart = (event) => {
90
+ // Abort if not a drawer, or if content should be scrolled by the "finger downwards" movement because it isn't scrolled all the way up
91
+ if (!popupToDrawer.current || isScrolledDown(event.target, contentEl)) return;
92
+
93
+ // Catch the drawer where it is (could still be animating open)
94
+ cancelAnimationFrame(openAnimationFrame);
95
+ dragStartVerticalOffset = drawerVerticalOffset;
96
+ dragStartY = event.touches[0].clientY;
97
+ velocitySamples = [{ y: dragStartY, time: performance.now() }];
98
+ };
99
+
100
+ const drag = (event) => {
101
+ if (dragStartY == null) return;
102
+
103
+ const eventY = event.touches[0].clientY;
104
+
105
+ // Cancel if dragging finger upwards while the touched element (or an ancestor up to the content) can still scroll down
106
+ // Mainly relevant for the slow stretch animation when the drwaer is dragged upwards (which shouldn't show when content is scrolled down)
107
+ if (!isDragging && eventY <= dragStartY && canScrollDown(event.target, contentEl)) {
108
+ dragStartY = null;
109
+ return animateDrawerOpen(); // Touch could've stopped the drawer during open, since it's a content scroll not a drag, resume
110
+ }
111
+
112
+ event.preventDefault(); // Prevent page scroll
113
+ isDragging = true;
114
+
115
+ // Collect samples for velocity calc
116
+ const now = performance.now();
117
+ velocitySamples.push({ y: eventY, time: now });
118
+ while (velocitySamples.length > 2 && now - velocitySamples[0].time > 80) velocitySamples.shift(); // Keep the last 80ms
119
+
120
+ // Apply offset
121
+ const delta = eventY - dragStartY + dragStartVerticalOffset;
122
+ drawerVerticalOffset = delta > 0 ? delta : delta * 0.05; // Allow dragging the drawer upwards, but make it less effective (that's what the x0.05 is for)
123
+ };
124
+
125
+ const dragEnd = () => {
126
+ if (dragStartY == null) return;
127
+ dragStartY = null;
128
+
129
+ if (!isDragging) return animateDrawerOpen(); // Single tap without drag, resume opening if it was caught mid-animation
130
+ isDragging = false;
131
+
132
+ // Calculate velocity
133
+ const first = velocitySamples[0];
134
+ const last = velocitySamples.at(-1);
135
+ const velocity = last.time > first.time ? (last.y - first.y) / (last.time - first.time) : 0; // Pixels per ms (time is ms)
136
+
137
+ // If swiped with enough velocity or past 35%, animate it shut, otherwise open fully again
138
+ if (velocity > 0.4 || drawerVerticalOffset > uiWindowHeight * 0.35) {
139
+ const duration = clamp((closedOffset - drawerVerticalOffset) / Math.max(velocity, 1.5), 180, 420); // Faster drag velocity means lower duration
140
+ closeTiming = { duration, easing: cubicBezier(0.45, 0.55, 0.4, 0.92) };
141
+ close();
142
+ } else animateDrawerOpen(); // Too little drag, snap back to fully open state
143
+ };
144
+
145
+ // Automatic z-indexing ---------------------------------------------------------------------
146
+
15
147
  // Helper to remove the current popup's z-index
16
148
  const removeZIndex = () => {
17
149
  const index = openZIndexes.indexOf(zIndex);
@@ -33,6 +165,7 @@
33
165
  }
34
166
  });
35
167
 
168
+ // Close function ----------------------------------------------------------------------------
36
169
  function close() {
37
170
  onuserclose?.();
38
171
  open = false;
@@ -53,24 +186,62 @@
53
186
  <!-- svelte-ignore a11y_click_events_have_key_events -->
54
187
  <div
55
188
  class="popup-holder"
189
+ class:drawer-holder={popupToDrawer.current}
56
190
  style:z-index={zIndex}
57
- transition:fade={{ duration: 100 }}
191
+ in:popupHolderTransition
192
+ out:popupHolderTransition
58
193
  onclick={(e) => {
59
194
  if (e.target == e.currentTarget) close();
60
195
  }}
61
196
  role="dialog"
62
197
  tabindex={0}
63
198
  >
199
+ <!-- Separate from the holder, a backdrop filter on an ancestor wouldn't work -->
200
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
201
+ <div
202
+ class="drawer-overlay"
203
+ out:drawerOverlayOutTransition
204
+ onclick={close}
205
+ style:background-color="color-mix(in srgb, var(--overlay-color) {overlayProgress * 100}%, transparent)"
206
+ style:backdrop-filter="blur(calc(var(--weak-blur) * {overlayProgress}))"
207
+ ></div>
64
208
  <div
65
209
  class="ui-window"
66
- transition:scale={{ easing: backOut, duration: 250, start: 0.9 }}
210
+ role="menu"
211
+ tabindex="0"
212
+ class:drawer={popupToDrawer.current}
213
+ in:windowTransition
214
+ out:windowTransition
67
215
  class:frame-less={frameless}
68
- style:max-width={ultraSlim ? "450px" : verySlim ? "600px" : slim ? "750px" : "850px"}
216
+ style:max-width={popupToDrawer.current
217
+ ? "none"
218
+ : ultraSlim
219
+ ? "450px"
220
+ : verySlim
221
+ ? "600px"
222
+ : slim
223
+ ? "750px"
224
+ : "850px"}
225
+ style:transform={popupToDrawer.current && drawerVerticalOffset
226
+ ? `translate3d(0, ${drawerVerticalOffset}px, 0)`
227
+ : ""}
228
+ bind:offsetHeight={uiWindowHeight}
229
+ {@attach (node) => {
230
+ // Needed since regular Svelte's touchmove is passive
231
+ const cleanup = on(node, "touchmove", drag, { passive: false });
232
+ return cleanup;
233
+ }}
234
+ ontouchstart={dragStart}
235
+ ontouchend={dragEnd}
236
+ ontouchcancel={dragEnd}
69
237
  >
238
+ {#if popupToDrawer.current && !frameless}
239
+ <div class="drawer-handle"></div>
240
+ {/if}
70
241
  <button class="close-popup-button standard-button" aria-label="close" onclick={close}
71
242
  ><X strokeWidth={2.25} /></button
72
243
  >
73
- <div class="content of-top of-bottom" class:content-frameless={frameless}>
244
+ <div class="content of-top of-bottom" class:content-frameless={frameless} bind:this={contentEl}>
74
245
  {@render children?.()}
75
246
  </div>
76
247
  </div>
@@ -90,6 +261,7 @@
90
261
  background-color: var(--overlay-color);
91
262
  pointer-events: auto;
92
263
  backdrop-filter: blur(var(--weak-blur));
264
+ --window-padding: calc(var(--layout-margin) + var(--content-margin));
93
265
  }
94
266
 
95
267
  .close-popup-button {
@@ -102,10 +274,6 @@
102
274
  z-index: 10;
103
275
  }
104
276
 
105
- .frame-less {
106
- padding: var(--content-margin) !important;
107
- }
108
-
109
277
  .content {
110
278
  position: relative;
111
279
  overflow-y: auto;
@@ -119,18 +287,18 @@
119
287
  );
120
288
  width: 100%;
121
289
  scrollbar-width: none;
290
+ overscroll-behavior: contain;
122
291
  border-radius: var(--box-margin);
123
292
  }
124
293
 
125
294
  .content-frameless {
126
295
  max-height: calc(100dvh - var(--layout-spacer) - env(safe-area-inset-top) - env(safe-area-inset-bottom));
127
- margin-bottom: -4px;
128
296
  border-radius: 0;
129
297
  }
130
298
 
131
299
  .ui-window {
132
300
  width: calc(100% - var(--layout-margin) * 2);
133
- padding: calc(var(--layout-margin) + var(--content-margin));
301
+ padding: var(--window-padding);
134
302
  border-radius: calc(var(--layout-margin) + var(--box-margin));
135
303
  text-align: left;
136
304
  position: relative;
@@ -139,11 +307,64 @@
139
307
  box-shadow:
140
308
  0 2px 100px rgba(5, 6, 10, 0.5),
141
309
  inset 0 0.05rem 1px hsla(0, 0%, 100%, 0.2),
142
- inset 0 -0.2rem 1px rgba(0, 0, 2, 0.2) !important;
310
+ inset 0 -0.2rem 1px rgba(0, 0, 2, 0.2);
143
311
  color: white;
144
312
  pointer-events: auto;
145
- transition:
146
- opacity 250ms ease,
147
- transform 250ms ease;
313
+ }
314
+
315
+ .frame-less {
316
+ padding: var(--content-margin);
317
+ }
318
+
319
+ .drawer-overlay {
320
+ display: none;
321
+ }
322
+
323
+ .drawer-holder {
324
+ align-items: flex-end;
325
+ background-color: transparent;
326
+ backdrop-filter: none;
327
+ }
328
+
329
+ .drawer-holder .drawer-overlay {
330
+ display: block;
331
+ position: absolute;
332
+ inset: 0;
333
+ background-color: var(--overlay-color);
334
+ backdrop-filter: blur(var(--weak-blur));
335
+ }
336
+
337
+ .drawer {
338
+ display: flex;
339
+ flex-direction: column;
340
+ width: 100%;
341
+ /* Max height is also shorter by the negative --layout-spacer margin bottom */
342
+ max-height: calc(100dvh - env(safe-area-inset-top));
343
+ padding-bottom: calc(var(--window-padding) + env(safe-area-inset-bottom) + var(--layout-spacer));
344
+ margin-bottom: calc(-1 * var(--layout-spacer));
345
+ border-bottom-left-radius: 0;
346
+ border-bottom-right-radius: 0;
347
+ }
348
+
349
+ .drawer.frame-less {
350
+ /* Add --layout-spacer since we have the negative --layout-spacer margin bottom */
351
+ padding-bottom: calc(max(var(--content-margin), env(safe-area-inset-bottom)) + var(--layout-spacer));
352
+ }
353
+
354
+ .drawer .content {
355
+ flex: 1;
356
+ min-height: 0;
357
+ max-height: none;
358
+ }
359
+
360
+ .drawer-handle {
361
+ position: absolute;
362
+ top: var(--box-margin);
363
+ border-radius: 4px;
364
+ left: 50%;
365
+ translate: -50% 0;
366
+ width: 40px;
367
+ height: 4px;
368
+ background-color: var(--line-color);
148
369
  }
149
370
  </style>
@@ -1,6 +1,6 @@
1
1
  <script>
2
2
  import { ArrowRight } from "@lucide/svelte";
3
- import { tooltip } from "../tooltip.svelte.js";
3
+ import { tooltip } from "../utils/tooltip.svelte.js";
4
4
 
5
5
  let { segments = $bindable(["", "", "", "", "", ""]), inPage = true, onsubmit } = $props(); // 6 segments for XXX-XXX
6
6
 
package/index.js CHANGED
@@ -11,10 +11,10 @@ export { default as Tabs } from "./components/Tabs.svelte";
11
11
  export { default as Ticker } from "./components/Ticker.svelte";
12
12
  export { default as TitleSeparator } from "./components/TitleSeparator.svelte";
13
13
  export { default as ExperimentSign } from "./components/ExperimentSign.svelte";
14
- export { tooltip } from "./tooltip.svelte.js";
15
- export { showNotification } from "./notifications.svelte.js";
16
- export { showConfirmPopup } from "./confirmPopup.svelte.js";
17
- export { showErrorPopup } from "./errorPopup.svelte.js";
14
+ export { tooltip } from "./utils/tooltip.svelte.js";
15
+ export { showNotification } from "./utils/notifications.svelte.js";
16
+ export { showConfirmPopup } from "./utils/confirm-popup.svelte.js";
17
+ export { showErrorPopup } from "./utils/error-popup.svelte.js";
18
18
 
19
19
  // Sounds
20
20
  import basic_button from "./sound-effects/basic_button.ogg";
package/package.json CHANGED
@@ -1,16 +1,13 @@
1
1
  {
2
2
  "name": "openguessr-ui",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite example"
7
7
  },
8
8
  "files": [
9
9
  "index.js",
10
- "tooltip.svelte.js",
11
- "notifications.svelte.js",
12
- "confirmPopup.svelte.js",
13
- "errorPopup.svelte.js",
10
+ "utils",
14
11
  "components",
15
12
  "fonts",
16
13
  "vector-graphics",
@@ -34,7 +31,7 @@
34
31
  "peerDependencies": {
35
32
  "@lucide/svelte": "^1.0.0",
36
33
  "overfade": "^0.0.11",
37
- "svelte": "^5.0.0"
34
+ "svelte": "^5.7.0"
38
35
  },
39
36
  "dependencies": {
40
37
  "@floating-ui/dom": "^1.8.0"
package/utils/clamp.js ADDED
@@ -0,0 +1 @@
1
+ export const clamp = (val, min, max) => Math.min(Math.max(val, min), max);
@@ -0,0 +1,12 @@
1
+ export function cubicBezier(x1, y1, x2, y2) {
2
+ const curve = (s, c1, c2) => (1 - 3 * c2 + 3 * c1) * s ** 3 + (3 * c2 - 6 * c1) * s ** 2 + 3 * c1 * s;
3
+ return (t) => {
4
+ let s = t;
5
+ for (let i = 0; i < 5; i++) {
6
+ const slope = 3 * (1 - 3 * x2 + 3 * x1) * s ** 2 + 2 * (3 * x2 - 6 * x1) * s + 3 * x1;
7
+ if (Math.abs(slope) < 1e-6) break;
8
+ s -= (curve(s, x1, x2) - t) / slope;
9
+ }
10
+ return curve(s, y1, y2);
11
+ };
12
+ }
@@ -0,0 +1,20 @@
1
+ // Whether the element or any ancestor up to root is scrolled down
2
+ export function isScrolledDown(el, root) {
3
+ while (el) {
4
+ if (el.scrollTop > 0) return true; // Return true when a scrollable element was found
5
+ if (el === root) return false;
6
+ el = el.parentElement;
7
+ }
8
+ return false;
9
+ }
10
+
11
+ // Whether the element or any ancestor up to root can still scroll further down
12
+ export function canScrollDown(el, root) {
13
+ while (el) {
14
+ const scrollRemaining = el.scrollHeight - el.scrollTop - el.clientHeight;
15
+ if (scrollRemaining > 1) return true; // scrollTop can be fractional while scrollHeight and clientHeight are rounded
16
+ if (el === root) return false;
17
+ el = el.parentElement;
18
+ }
19
+ return false;
20
+ }
File without changes