openguessr-ui 0.3.2 → 0.3.4

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,155 @@
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 dragStartX;
68
+ let dragStartVerticalOffset;
69
+ let isDragging = $state(false);
70
+ let drawerVerticalOffset = $state(0);
71
+ let velocitySamples = $state([]); // Y positions with timestamps
72
+ let openAnimationFrame; // Latest open animation frame for cancelling it
73
+
74
+ const closedOffset = $derived(uiWindowHeight + DRAWER_HIDDEN_OVERSHOOT);
75
+ const overlayProgress = $derived(Math.min(1, 1 - drawerVerticalOffset / closedOffset));
76
+
77
+ // Animate drawer to fully open
78
+ const animateDrawerOpen = () => {
79
+ if (drawerVerticalOffset == 0) return; // Skip when the drawer is already fully open
80
+ const start = drawerVerticalOffset;
81
+ const startTime = performance.now();
82
+ const step = (now) => {
83
+ const progress = Math.min(1, (now - startTime) / DRAWER_TIMING.duration); // Progress (0-1)
84
+ drawerVerticalOffset = start * (1 - DRAWER_TIMING.easing(progress));
85
+ if (progress < 1) openAnimationFrame = requestAnimationFrame(step);
86
+ };
87
+ openAnimationFrame = requestAnimationFrame(step);
88
+ };
89
+
90
+ const dragStart = (event) => {
91
+ // 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
92
+ if (!popupToDrawer.current || isScrolledDown(event.target, contentEl)) return;
93
+
94
+ // Catch the drawer where it is (could still be animating open)
95
+ cancelAnimationFrame(openAnimationFrame);
96
+ dragStartVerticalOffset = drawerVerticalOffset;
97
+ dragStartY = event.touches[0].clientY;
98
+ dragStartX = event.touches[0].clientX;
99
+ velocitySamples = [{ y: dragStartY, time: performance.now() }];
100
+ };
101
+
102
+ const drag = (event) => {
103
+ if (dragStartY == null) return;
104
+
105
+ const eventY = event.touches[0].clientY;
106
+
107
+ // On the first move, decide whether the gesture drags the drawer or scrolls the content
108
+ if (!isDragging) {
109
+ // Horizontal movements belong to the content, e.g. horizontally scrolling elements
110
+ const isScrollingHorizontally = Math.abs(event.touches[0].clientX - dragStartX) > Math.abs(eventY - dragStartY); // Check if more horizontal movement than vertical
111
+
112
+ // Dragging finger upwards while the touched element (or an ancestor up to the content) can still scroll down
113
+ // Mainly relevant for the slow stretch animation when the drwaer is dragged upwards (which shouldn't show when content is scrolled down)
114
+ const isScrollingDown = eventY <= dragStartY && canScrollDown(event.target, contentEl);
115
+
116
+ if (isScrollingHorizontally || isScrollingDown) {
117
+ dragStartY = null;
118
+ return animateDrawerOpen(); // Touch could've stopped the drawer during open, since it's a content scroll not a drag, resume
119
+ }
120
+ }
121
+
122
+ event.preventDefault(); // Prevent page scroll
123
+ isDragging = true;
124
+
125
+ // Collect samples for velocity calc
126
+ const now = performance.now();
127
+ velocitySamples.push({ y: eventY, time: now });
128
+ while (velocitySamples.length > 2 && now - velocitySamples[0].time > 80) velocitySamples.shift(); // Keep the last 80ms
129
+
130
+ // Apply offset
131
+ const delta = eventY - dragStartY + dragStartVerticalOffset;
132
+ 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)
133
+ };
134
+
135
+ const dragEnd = () => {
136
+ if (dragStartY == null) return;
137
+ dragStartY = null;
138
+
139
+ if (!isDragging) return animateDrawerOpen(); // Single tap without drag, resume opening if it was caught mid-animation
140
+ isDragging = false;
141
+
142
+ // Calculate velocity
143
+ const first = velocitySamples[0];
144
+ const last = velocitySamples.at(-1);
145
+ const velocity = last.time > first.time ? (last.y - first.y) / (last.time - first.time) : 0; // Pixels per ms (time is ms)
146
+
147
+ // If swiped with enough velocity or past 35%, animate it shut, otherwise open fully again
148
+ if (velocity > 0.4 || drawerVerticalOffset > uiWindowHeight * 0.35) {
149
+ const duration = clamp((closedOffset - drawerVerticalOffset) / Math.max(velocity, 1.5), 180, 420); // Faster drag velocity means lower duration
150
+ closeTiming = { duration, easing: cubicBezier(0.45, 0.55, 0.4, 0.92) };
151
+ close();
152
+ } else animateDrawerOpen(); // Too little drag, snap back to fully open state
153
+ };
154
+
155
+ // Automatic z-indexing ---------------------------------------------------------------------
156
+
15
157
  // Helper to remove the current popup's z-index
16
158
  const removeZIndex = () => {
17
159
  const index = openZIndexes.indexOf(zIndex);
@@ -33,6 +175,7 @@
33
175
  }
34
176
  });
35
177
 
178
+ // Close function ----------------------------------------------------------------------------
36
179
  function close() {
37
180
  onuserclose?.();
38
181
  open = false;
@@ -53,24 +196,62 @@
53
196
  <!-- svelte-ignore a11y_click_events_have_key_events -->
54
197
  <div
55
198
  class="popup-holder"
199
+ class:drawer-holder={popupToDrawer.current}
56
200
  style:z-index={zIndex}
57
- transition:fade={{ duration: 100 }}
201
+ in:popupHolderTransition
202
+ out:popupHolderTransition
58
203
  onclick={(e) => {
59
204
  if (e.target == e.currentTarget) close();
60
205
  }}
61
206
  role="dialog"
62
207
  tabindex={0}
63
208
  >
209
+ <!-- Separate from the holder, a backdrop filter on an ancestor wouldn't work -->
210
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
211
+ <div
212
+ class="drawer-overlay"
213
+ out:drawerOverlayOutTransition
214
+ onclick={close}
215
+ style:background-color="color-mix(in srgb, var(--overlay-color) {overlayProgress * 100}%, transparent)"
216
+ style:backdrop-filter="blur(calc(var(--weak-blur) * {overlayProgress}))"
217
+ ></div>
64
218
  <div
65
219
  class="ui-window"
66
- transition:scale={{ easing: backOut, duration: 250, start: 0.9 }}
220
+ role="menu"
221
+ tabindex="0"
222
+ class:drawer={popupToDrawer.current}
223
+ in:windowTransition
224
+ out:windowTransition
67
225
  class:frame-less={frameless}
68
- style:max-width={ultraSlim ? "450px" : verySlim ? "600px" : slim ? "750px" : "850px"}
226
+ style:max-width={popupToDrawer.current
227
+ ? "none"
228
+ : ultraSlim
229
+ ? "450px"
230
+ : verySlim
231
+ ? "600px"
232
+ : slim
233
+ ? "750px"
234
+ : "850px"}
235
+ style:transform={popupToDrawer.current && drawerVerticalOffset
236
+ ? `translate3d(0, ${drawerVerticalOffset}px, 0)`
237
+ : ""}
238
+ bind:offsetHeight={uiWindowHeight}
239
+ {@attach (node) => {
240
+ // Needed since regular Svelte's touchmove is passive
241
+ const cleanup = on(node, "touchmove", drag, { passive: false });
242
+ return cleanup;
243
+ }}
244
+ ontouchstart={dragStart}
245
+ ontouchend={dragEnd}
246
+ ontouchcancel={dragEnd}
69
247
  >
248
+ {#if popupToDrawer.current && !frameless}
249
+ <div class="drawer-handle"></div>
250
+ {/if}
70
251
  <button class="close-popup-button standard-button" aria-label="close" onclick={close}
71
252
  ><X strokeWidth={2.25} /></button
72
253
  >
73
- <div class="content of-top of-bottom" class:content-frameless={frameless}>
254
+ <div class="content of-top of-bottom" class:content-frameless={frameless} bind:this={contentEl}>
74
255
  {@render children?.()}
75
256
  </div>
76
257
  </div>
@@ -90,6 +271,7 @@
90
271
  background-color: var(--overlay-color);
91
272
  pointer-events: auto;
92
273
  backdrop-filter: blur(var(--weak-blur));
274
+ --window-padding: calc(var(--layout-margin) + var(--content-margin));
93
275
  }
94
276
 
95
277
  .close-popup-button {
@@ -102,10 +284,6 @@
102
284
  z-index: 10;
103
285
  }
104
286
 
105
- .frame-less {
106
- padding: var(--content-margin) !important;
107
- }
108
-
109
287
  .content {
110
288
  position: relative;
111
289
  overflow-y: auto;
@@ -119,18 +297,18 @@
119
297
  );
120
298
  width: 100%;
121
299
  scrollbar-width: none;
300
+ overscroll-behavior: contain;
122
301
  border-radius: var(--box-margin);
123
302
  }
124
303
 
125
304
  .content-frameless {
126
305
  max-height: calc(100dvh - var(--layout-spacer) - env(safe-area-inset-top) - env(safe-area-inset-bottom));
127
- margin-bottom: -4px;
128
306
  border-radius: 0;
129
307
  }
130
308
 
131
309
  .ui-window {
132
310
  width: calc(100% - var(--layout-margin) * 2);
133
- padding: calc(var(--layout-margin) + var(--content-margin));
311
+ padding: var(--window-padding);
134
312
  border-radius: calc(var(--layout-margin) + var(--box-margin));
135
313
  text-align: left;
136
314
  position: relative;
@@ -139,11 +317,64 @@
139
317
  box-shadow:
140
318
  0 2px 100px rgba(5, 6, 10, 0.5),
141
319
  inset 0 0.05rem 1px hsla(0, 0%, 100%, 0.2),
142
- inset 0 -0.2rem 1px rgba(0, 0, 2, 0.2) !important;
320
+ inset 0 -0.2rem 1px rgba(0, 0, 2, 0.2);
143
321
  color: white;
144
322
  pointer-events: auto;
145
- transition:
146
- opacity 250ms ease,
147
- transform 250ms ease;
323
+ }
324
+
325
+ .frame-less {
326
+ padding: var(--content-margin);
327
+ }
328
+
329
+ .drawer-overlay {
330
+ display: none;
331
+ }
332
+
333
+ .drawer-holder {
334
+ align-items: flex-end;
335
+ background-color: transparent;
336
+ backdrop-filter: none;
337
+ }
338
+
339
+ .drawer-holder .drawer-overlay {
340
+ display: block;
341
+ position: absolute;
342
+ inset: 0;
343
+ background-color: var(--overlay-color);
344
+ backdrop-filter: blur(var(--weak-blur));
345
+ }
346
+
347
+ .drawer {
348
+ display: flex;
349
+ flex-direction: column;
350
+ width: 100%;
351
+ /* Max height is also shorter by the negative --layout-spacer margin bottom */
352
+ max-height: calc(100dvh - env(safe-area-inset-top));
353
+ padding-bottom: calc(max(var(--window-padding), env(safe-area-inset-bottom)) + var(--layout-spacer));
354
+ margin-bottom: calc(-1 * var(--layout-spacer));
355
+ border-bottom-left-radius: 0;
356
+ border-bottom-right-radius: 0;
357
+ }
358
+
359
+ .drawer.frame-less {
360
+ /* Add --layout-spacer since we have the negative --layout-spacer margin bottom */
361
+ padding-bottom: calc(max(var(--content-margin), env(safe-area-inset-bottom)) + var(--layout-spacer));
362
+ }
363
+
364
+ .drawer .content {
365
+ flex: 1;
366
+ min-height: 0;
367
+ max-height: none;
368
+ }
369
+
370
+ .drawer-handle {
371
+ position: absolute;
372
+ top: var(--box-margin);
373
+ border-radius: 4px;
374
+ left: 50%;
375
+ translate: -50% 0;
376
+ width: 40px;
377
+ height: 4px;
378
+ background-color: var(--line-color);
148
379
  }
149
380
  </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.4",
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