openguessr-ui 0.3.6 → 0.5.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/README.md CHANGED
@@ -343,7 +343,8 @@ The loading spinner component can be displayed while large content loads, such a
343
343
 
344
344
  The notification component is used for short messages at the top of the screen, optionally with an action. It needs to be mounted. It takes the following props:
345
345
 
346
- - `top`: Distance from the top of the viewport, defaults to `--layout-margin`
346
+ - `top`: Distance from the top of the viewport, defaults to `--box-margin`
347
+ - `offsetSafeArea`: Whether the top position should be offset by the safe area inset, defaults to true
347
348
  - `onaccept`: Called when the accept button is used
348
349
  - `ondismiss`: Called when the dismiss button is used
349
350
 
@@ -440,6 +441,8 @@ Options:
440
441
  - `zIndex`: Tooltip z-index
441
442
  - `mobile`: Set to false to hide the tooltip on mobile
442
443
 
444
+ Tooltips with automatic state handling are shown on hover (and on long-press with touch input).
445
+
443
446
  ## Sound effects
444
447
 
445
448
  UI sound effects are available as `.ogg` files and can be played with any audio library. The `sounds` export maps each name to its file URL:
@@ -3,7 +3,7 @@
3
3
  import { Check, SquareArrowOutUpRight, X } from "@lucide/svelte";
4
4
  import { fly } from "svelte/transition";
5
5
 
6
- let { top = "var(--box-margin)", onaccept, ondismiss } = $props();
6
+ let { top = "var(--box-margin)", offsetSafeArea = true, onaccept, ondismiss } = $props();
7
7
 
8
8
  function accept() {
9
9
  if (notification.acceptFunction) {
@@ -36,7 +36,7 @@
36
36
  );
37
37
  </script>
38
38
 
39
- <div class="notification-holder" style:top>
39
+ <div class="notification-holder" style:top style:margin-top={offsetSafeArea ? "env(safe-area-inset-top)" : ""}>
40
40
  {#if notification.visible}
41
41
  <div id="notification-bar" transition:fly={{ y: -20, duration: 250 }}>
42
42
  <p id="notification-text">
@@ -103,7 +103,6 @@
103
103
  position: fixed;
104
104
  left: var(--box-margin);
105
105
  right: var(--box-margin);
106
- margin-top: env(safe-area-inset-top);
107
106
  display: flex;
108
107
  justify-content: center;
109
108
  pointer-events: none;
@@ -59,7 +59,6 @@
59
59
  // Drawer logic ------------------------------------------------------------------------------------
60
60
 
61
61
  // Bindings
62
- let contentEl = $state();
63
62
  let uiWindowHeight = $state();
64
63
 
65
64
  // Drawer state
@@ -93,7 +92,7 @@
93
92
 
94
93
  const dragStart = (event) => {
95
94
  // Abort if not a drawer, already tracking a finger, or if content should be scrolled by the "finger downwards" movement because it isn't scrolled all the way up
96
- if (!popupToDrawer.current || dragStartY != null || isScrolledDown(event.target, contentEl)) return;
95
+ if (!popupToDrawer.current || dragStartY != null || isScrolledDown(event.target, event.currentTarget)) return; // Current target is ui-window
97
96
 
98
97
  // Catch the drawer where it is (could still be animating open)
99
98
  cancelAnimationFrame(openAnimationFrame);
@@ -118,11 +117,11 @@
118
117
 
119
118
  // Dragging finger upwards while the touched element (or an ancestor up to the content) can still scroll down
120
119
  // Mainly relevant for the slow stretch animation when the drwaer is dragged upwards (which shouldn't show when content is scrolled down)
121
- const isScrollingDown = eventY <= dragStartY && canScrollDown(event.target, contentEl);
120
+ const isScrollingDown = eventY <= dragStartY && canScrollDown(event.target, event.currentTarget); // Current target is ui-window
122
121
 
123
122
  if (isScrollingHorizontally || isScrollingDown) {
124
123
  dragStartY = null;
125
- return animateDrawerOpen(); // Touch could've stopped the drawer during open, since it's a content scroll not a drag, resume
124
+ return animateDrawerOpen(); // Touch could've stopped the drawer during open, since it's a content scroll not a drag, resume
126
125
  }
127
126
  }
128
127
 
@@ -140,7 +139,7 @@
140
139
  };
141
140
 
142
141
  const dragEnd = (event) => {
143
- if (dragStartY == null || !findDragTouch(event.changedTouches)) return; // Only the dragging finger ends the drag
142
+ if (dragStartY == null || !findDragTouch(event.changedTouches)) return; // Only the dragging finger can end the drag
144
143
  dragStartY = null;
145
144
 
146
145
  if (!isDragging) return animateDrawerOpen(); // Single tap without drag, resume opening if it was caught mid-animation
@@ -149,14 +148,14 @@
149
148
  // Calculate velocity
150
149
  const first = velocitySamples[0];
151
150
  const last = velocitySamples.at(-1);
152
- const velocity = last.time > first.time ? (last.y - first.y) / (last.time - first.time) : 0; // Pixels per ms (time is ms)
151
+ const velocity = last.time > first.time ? (last.y - first.y) / (last.time - first.time) : 0; // Pixels per ms
153
152
 
154
153
  // If swiped with enough velocity or past 35%, animate it shut, otherwise open fully again
155
154
  if (velocity > 0.4 || drawerVerticalOffset > uiWindowHeight * 0.35) {
156
155
  const duration = clamp((closedOffset - drawerVerticalOffset) / Math.max(velocity, 1.5), 180, 420); // Faster drag velocity means lower duration
157
156
  closeTiming = { duration, easing: cubicBezier(0.45, 0.55, 0.4, 0.92) };
158
157
  close();
159
- } else animateDrawerOpen(); // Too little drag, snap back to fully open state
158
+ } else animateDrawerOpen();
160
159
  };
161
160
 
162
161
  // Automatic z-indexing ---------------------------------------------------------------------
@@ -258,7 +257,7 @@
258
257
  <button class="close-popup-button standard-button" aria-label="close" onclick={close}
259
258
  ><X strokeWidth={2.25} /></button
260
259
  >
261
- <div class="content of-top of-bottom" class:content-frameless={frameless} bind:this={contentEl}>
260
+ <div class="content of-top of-bottom" class:content-frameless={frameless}>
262
261
  {@render children?.()}
263
262
  </div>
264
263
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openguessr-ui",
3
- "version": "0.3.6",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite example"
@@ -6,6 +6,8 @@ export function tooltip({ text, imageSrc, imageAspectRatio = "1 / 1", state, onc
6
6
  let visible = false;
7
7
  let cleanupAutoUpdate;
8
8
  let showTimeout = null;
9
+ let longPressTimeout = null;
10
+ let suppressClick = false;
9
11
 
10
12
  const persistent = Boolean(state);
11
13
  const closedScale = imageSrc ? 0.85 : 0;
@@ -157,6 +159,43 @@ export function tooltip({ text, imageSrc, imageAspectRatio = "1 / 1", state, onc
157
159
  hide();
158
160
  }
159
161
 
162
+ // On touch devices, a long press shows the tooltip
163
+ function onPointerDown(e) {
164
+ suppressClick = false;
165
+ if (e.pointerType != "touch" || !e.isPrimary) return;
166
+
167
+ // Close the tooltip on any click or other pointer down (scroll, swipe...)
168
+ const closeLongPressTooltip = () => {
169
+ suppressClick = false;
170
+ hide();
171
+ }
172
+
173
+ // After the 500ms timeout, the tooltip is shown
174
+ clearTimeout(longPressTimeout);
175
+ longPressTimeout = setTimeout(() => {
176
+ if (!visible) show();
177
+ if (visible) {
178
+ suppressClick = true;
179
+ document.addEventListener('pointerdown', closeLongPressTooltip, { capture: true, once: true });
180
+ }
181
+ }, 500);
182
+ }
183
+
184
+ // On pointerup, and pointercancel since cancel can be e.g. a scroll starting
185
+ function cancelLongPress() {
186
+ clearTimeout(longPressTimeout);
187
+ longPressTimeout = null;
188
+ }
189
+
190
+ // Don't trigger the element's click action after a long press
191
+ function onClick(e) {
192
+ cancelLongPress(); // Pointerup doesn't fire reliably with clicks (especialy on WK)
193
+ if (!suppressClick) return hide(); // The click may change what's under the cursor (e.g. a new page) without a pointerleave until the mouse moves
194
+ suppressClick = false;
195
+ e.preventDefault();
196
+ e.stopImmediatePropagation();
197
+ }
198
+
160
199
  if (persistent) {
161
200
  $effect(() => {
162
201
  if (state.visible) scheduleShow();
@@ -165,13 +204,22 @@ export function tooltip({ text, imageSrc, imageAspectRatio = "1 / 1", state, onc
165
204
  } else {
166
205
  node.addEventListener('pointerenter', onPointerEnter);
167
206
  node.addEventListener('pointerleave', onPointerLeave);
207
+ node.addEventListener('pointerdown', onPointerDown);
208
+ node.addEventListener('pointerup', cancelLongPress);
209
+ node.addEventListener('pointercancel', cancelLongPress);
210
+ node.addEventListener('click', onClick, true);
168
211
  }
169
212
 
170
213
  return () => {
171
214
  if (!persistent) {
172
215
  node.removeEventListener('pointerenter', onPointerEnter);
173
216
  node.removeEventListener('pointerleave', onPointerLeave);
217
+ node.removeEventListener('pointerdown', onPointerDown);
218
+ node.removeEventListener('pointerup', cancelLongPress);
219
+ node.removeEventListener('pointercancel', cancelLongPress);
220
+ node.removeEventListener('click', onClick, true);
174
221
  }
222
+ if (longPressTimeout) clearTimeout(longPressTimeout);
175
223
  if (showTimeout) clearTimeout(showTimeout);
176
224
  cleanupAutoUpdate?.();
177
225
  tooltipElement?.remove();