@marianmeres/stuic 3.103.0 → 3.105.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.
@@ -418,11 +418,17 @@
418
418
  let swipeStartTime = 0;
419
419
  let isSwiping = false;
420
420
 
421
- // ---- Drag detection (for page click vs drag/swipe discrimination) ----
422
-
423
- let _wasDragged = false;
424
- let _dragStartClientX = 0;
425
- let _dragStartClientY = 0;
421
+ // ---- Tap vs drag discrimination for page/area activation ----
422
+ // Activation uses pointer events (pointerdown/pointerup) rather than a synthesized
423
+ // mouse `click`. Pointer events unify mouse + touch and fire reliably on a clean tap,
424
+ // even on the `touch-action: none` gesture surface where tap→click synthesis is
425
+ // unreliable (notably iOS Safari, worst of all on inner SVG shapes). A tap is a
426
+ // pointerup landing within TAP_SLOP_PX of its pointerdown; movement beyond that is a
427
+ // swipe/drag and does not activate.
428
+
429
+ const TAP_SLOP_PX = 10;
430
+ let _tapDownX = 0;
431
+ let _tapDownY = 0;
426
432
 
427
433
  // ---- Zoom helpers ----
428
434
 
@@ -572,10 +578,6 @@
572
578
  const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
573
579
  const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
574
580
 
575
- _wasDragged = false;
576
- _dragStartClientX = clientX;
577
- _dragStartClientY = clientY;
578
-
579
581
  if (zoomLevel > 1) {
580
582
  // Pan mode
581
583
  e.preventDefault();
@@ -603,18 +605,6 @@
603
605
  return;
604
606
  }
605
607
 
606
- // Track drag for page click detection
607
- if (!_wasDragged) {
608
- const cx = "touches" in e ? (e.touches[0]?.clientX ?? 0) : e.clientX;
609
- const cy = "touches" in e ? (e.touches[0]?.clientY ?? 0) : e.clientY;
610
- if (
611
- Math.abs(cx - _dragStartClientX) > 5 ||
612
- Math.abs(cy - _dragStartClientY) > 5
613
- ) {
614
- _wasDragged = true;
615
- }
616
- }
617
-
618
608
  // Pan
619
609
  if (isPanning) {
620
610
  e.preventDefault();
@@ -693,10 +683,21 @@
693
683
  };
694
684
  }
695
685
 
686
+ // ---- Tap (pointer) helpers for page/area activation ----
687
+
688
+ function handleTapDown(e: PointerEvent) {
689
+ _tapDownX = e.clientX;
690
+ _tapDownY = e.clientY;
691
+ }
692
+
693
+ function isTap(e: PointerEvent): boolean {
694
+ return Math.hypot(e.clientX - _tapDownX, e.clientY - _tapDownY) <= TAP_SLOP_PX;
695
+ }
696
+
696
697
  // ---- Page click ----
697
698
 
698
- function handlePageClick(e: MouseEvent, page: BookPage | undefined) {
699
- if (!onPageClick || !page || _wasDragged) return;
699
+ function handlePageClick(e: PointerEvent, page: BookPage | undefined) {
700
+ if (!onPageClick || !page || !isTap(e)) return;
700
701
  const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
701
702
  const x = (e.clientX - rect.left) / rect.width;
702
703
  const y = (e.clientY - rect.top) / rect.height;
@@ -769,7 +770,8 @@
769
770
  <div
770
771
  class={twMerge(!unstyled && "stuic-book-sheet-front", classPage)}
771
772
  data-placeholder={!sheet.frontPage && sheet.backPage ? "" : undefined}
772
- onclick={(e) => handlePageClick(e, sheet.frontPage)}
773
+ onpointerdown={handleTapDown}
774
+ onpointerup={(e) => handlePageClick(e, sheet.frontPage)}
773
775
  >
774
776
  {#if isNearby && sheet.frontPage}
775
777
  {#if renderPage}
@@ -840,8 +842,9 @@
840
842
  width={area.w}
841
843
  height={area.h}
842
844
  class={!unstyled ? "stuic-book-area" : undefined}
843
- onclick={(e: MouseEvent) => {
844
- if (_wasDragged) return;
845
+ onpointerdown={handleTapDown}
846
+ onpointerup={(e: PointerEvent) => {
847
+ if (!isTap(e)) return;
845
848
  e.stopPropagation();
846
849
  onAreaClick({ area, page: sheet.frontPage! });
847
850
  }}
@@ -858,7 +861,8 @@
858
861
  <div
859
862
  class={twMerge(!unstyled && "stuic-book-sheet-back", classPage)}
860
863
  data-placeholder={!sheet.backPage && sheet.frontPage ? "" : undefined}
861
- onclick={(e) => handlePageClick(e, sheet.backPage)}
864
+ onpointerdown={handleTapDown}
865
+ onpointerup={(e) => handlePageClick(e, sheet.backPage)}
862
866
  >
863
867
  {#if isNearby && sheet.backPage}
864
868
  {#if renderPage}
@@ -925,8 +929,9 @@
925
929
  width={area.w}
926
930
  height={area.h}
927
931
  class={!unstyled ? "stuic-book-area" : undefined}
928
- onclick={(e: MouseEvent) => {
929
- if (_wasDragged) return;
932
+ onpointerdown={handleTapDown}
933
+ onpointerup={(e: PointerEvent) => {
934
+ if (!isTap(e)) return;
930
935
  e.stopPropagation();
931
936
  onAreaClick({ area, page: sheet.backPage! });
932
937
  }}
@@ -32,6 +32,14 @@
32
32
  unstyled?: boolean;
33
33
  /** Render as rounded-full */
34
34
  roundedFull?: boolean;
35
+ /**
36
+ * Trim the icon-side horizontal padding down to the vertical padding, so a
37
+ * leading/trailing icon sits the same distance from the edge as a rounded icon
38
+ * (aspect1) button. Pair with `roundedFull` for the "rounded icon button with
39
+ * label" pill look (e.g. a "Back" button: prev arrow + label). Size-aware and
40
+ * RTL-aware (`leading` = start side, `trailing` = end side).
41
+ */
42
+ iconEdge?: "leading" | "trailing";
35
43
  /** Render as aspect ratio 1 */
36
44
  aspect1?: boolean;
37
45
  /** Icon-only button (implies aspect1, adds data-icon-button for global CSS targeting) */
@@ -104,6 +112,7 @@
104
112
  raised = false,
105
113
  unstyled = false,
106
114
  roundedFull = false,
115
+ iconEdge,
107
116
  aspect1 = false,
108
117
  iconButton = false,
109
118
  tooltip: _tooltip,
@@ -168,6 +177,7 @@
168
177
  data-raised={!unstyled && raised ? "true" : undefined}
169
178
  data-checked={roleSwitch && checked ? "true" : undefined}
170
179
  data-rounded-full={!unstyled && roundedFull ? "true" : undefined}
180
+ data-icon-edge={!unstyled && iconEdge ? iconEdge : undefined}
171
181
  data-aspect1={!unstyled && _isAspect1 ? "true" : undefined}
172
182
  data-icon-button={!unstyled && _isIconButton ? "true" : undefined}
173
183
  data-x={!unstyled && !!_xProps ? "true" : undefined}
@@ -213,6 +223,7 @@
213
223
  data-raised={!unstyled && raised ? "true" : undefined}
214
224
  data-checked={roleSwitch && checked ? "true" : undefined}
215
225
  data-rounded-full={!unstyled && roundedFull ? "true" : undefined}
226
+ data-icon-edge={!unstyled && iconEdge ? iconEdge : undefined}
216
227
  data-aspect1={!unstyled && _isAspect1 ? "true" : undefined}
217
228
  data-icon-button={!unstyled && _isIconButton ? "true" : undefined}
218
229
  data-x={!unstyled && !!_xProps ? "true" : undefined}
@@ -27,6 +27,14 @@ export interface Props extends Omit<HTMLButtonAttributes, "children"> {
27
27
  unstyled?: boolean;
28
28
  /** Render as rounded-full */
29
29
  roundedFull?: boolean;
30
+ /**
31
+ * Trim the icon-side horizontal padding down to the vertical padding, so a
32
+ * leading/trailing icon sits the same distance from the edge as a rounded icon
33
+ * (aspect1) button. Pair with `roundedFull` for the "rounded icon button with
34
+ * label" pill look (e.g. a "Back" button: prev arrow + label). Size-aware and
35
+ * RTL-aware (`leading` = start side, `trailing` = end side).
36
+ */
37
+ iconEdge?: "leading" | "trailing";
30
38
  /** Render as aspect ratio 1 */
31
39
  aspect1?: boolean;
32
40
  /** Icon-only button (implies aspect1, adds data-icon-button for global CSS targeting) */
@@ -20,6 +20,7 @@ A flexible button component with semantic intents, visual variants, sizes, and o
20
20
  | `iconSwap` | `[string \| Snippet, string \| Snippet]` | - | Two icon states with swap animation (implies iconButton) |
21
21
  | `x` | `boolean \| XProps` | - | Normalized "X" icon button shortcut (close/dismiss) |
22
22
  | `nav` | `"prev" \| "next" \| ButtonNavProps` | - | Normalized prev/next icon button shortcut (arrow by default; `x` wins on conflict) |
23
+ | `iconEdge` | `"leading" \| "trailing"` | - | Trim icon-side padding to the y-padding (pill + edge-flush icon; pair with `roundedFull`) |
23
24
  | `class` | `string` | - | Additional CSS classes |
24
25
 
25
26
  ## Snippet Props
@@ -134,6 +135,26 @@ Global CSS targeting for all icon buttons:
134
135
  }
135
136
  ```
136
137
 
138
+ ### Pill with edge-flush icon (`iconEdge`)
139
+
140
+ The "rounded icon button with label" look: a pill-shaped button whose leading (or
141
+ trailing) icon sits the same distance from the edge as a rounded icon (nav) button.
142
+ `iconEdge` trims the icon-side horizontal padding down to the vertical padding — it's
143
+ size-aware and uses logical properties, so `leading`/`trailing` follow text direction
144
+ (RTL-safe). It does NOT round the button on its own; pair it with `roundedFull`.
145
+
146
+ ```svelte
147
+ <Button roundedFull iconEdge="leading">{@html iconArrowLeft({ size: 24 })} Back</Button>
148
+ <Button roundedFull iconEdge="trailing">Next {@html iconArrowRight({ size: 24 })}</Button>
149
+ ```
150
+
151
+ Notes:
152
+
153
+ - `iconEdge` is composable — it only trims padding, so it also works on a
154
+ default-radius (non-pill) button if you want the icon flush without the pill shape.
155
+ - You compose the icon + label yourself in `children` (no default icon, unlike `nav`),
156
+ so it works with any icon. Use `size: 24` to match the nav button's arrow.
157
+
137
158
  ### Custom Styling
138
159
 
139
160
  ```svelte
@@ -199,6 +220,7 @@ The component uses data attributes for styling:
199
220
  - `data-raised` - Present when raised
200
221
  - `data-checked` - Present when roleSwitch is enabled and checked
201
222
  - `data-rounded-full` - Present when roundedFull
223
+ - `data-icon-edge` - Set to `"leading"` or `"trailing"` when iconEdge is set
202
224
  - `data-aspect1` - Present when aspect1 (or iconButton, or x, or nav)
203
225
  - `data-icon-button` - Present when iconButton (or x, or nav)
204
226
  - `data-x` - Present when x is set
@@ -422,6 +422,38 @@
422
422
  padding: var(--stuic-button-padding-y-xl);
423
423
  }
424
424
 
425
+ /* ============================================================================
426
+ ICON EDGE
427
+ Trim the icon-side horizontal padding down to the vertical padding, so a
428
+ leading/trailing icon sits the same distance from the edge as a rounded icon
429
+ (aspect1) button. Pair with [data-rounded-full] for the "rounded icon button
430
+ with label" pill look. Logical props => leading/trailing follow text direction.
431
+ ============================================================================ */
432
+ .stuic-button[data-icon-edge="leading"][data-size="sm"] {
433
+ padding-inline-start: var(--stuic-button-padding-y-sm);
434
+ }
435
+ .stuic-button[data-icon-edge="leading"][data-size="md"] {
436
+ padding-inline-start: var(--stuic-button-padding-y-md);
437
+ }
438
+ .stuic-button[data-icon-edge="leading"][data-size="lg"] {
439
+ padding-inline-start: var(--stuic-button-padding-y-lg);
440
+ }
441
+ .stuic-button[data-icon-edge="leading"][data-size="xl"] {
442
+ padding-inline-start: var(--stuic-button-padding-y-xl);
443
+ }
444
+ .stuic-button[data-icon-edge="trailing"][data-size="sm"] {
445
+ padding-inline-end: var(--stuic-button-padding-y-sm);
446
+ }
447
+ .stuic-button[data-icon-edge="trailing"][data-size="md"] {
448
+ padding-inline-end: var(--stuic-button-padding-y-md);
449
+ }
450
+ .stuic-button[data-icon-edge="trailing"][data-size="lg"] {
451
+ padding-inline-end: var(--stuic-button-padding-y-lg);
452
+ }
453
+ .stuic-button[data-icon-edge="trailing"][data-size="xl"] {
454
+ padding-inline-end: var(--stuic-button-padding-y-xl);
455
+ }
456
+
425
457
  /* ============================================================================
426
458
  ICON BUTTON
427
459
  Semantic marker for icon-only buttons. Layout is handled by [data-aspect1].
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.103.0",
3
+ "version": "3.105.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && pnpm run prepack",
@@ -69,7 +69,7 @@
69
69
  "prettier": "^3.8.3",
70
70
  "prettier-plugin-svelte": "^3.5.2",
71
71
  "publint": "^0.3.21",
72
- "svelte": "^5.55.10",
72
+ "svelte": "^5.56.0",
73
73
  "svelte-check": "^4.4.8",
74
74
  "tailwindcss": "^4.3.0",
75
75
  "tsx": "^4.22.3",
@@ -81,14 +81,14 @@
81
81
  "dependencies": {
82
82
  "@marianmeres/clog": "^3.21.0",
83
83
  "@marianmeres/cron": "^2.0.1",
84
- "@marianmeres/design-tokens": "^1.7.0",
84
+ "@marianmeres/design-tokens": "^1.8.0",
85
85
  "@marianmeres/icons-fns": "^5.0.0",
86
86
  "@marianmeres/item-collection": "^1.4.2",
87
87
  "@marianmeres/paging-store": "^2.1.1",
88
88
  "@marianmeres/parse-boolean": "^2.1.0",
89
89
  "@marianmeres/ticker": "^1.17.1",
90
90
  "@marianmeres/tree": "^2.3.0",
91
- "libphonenumber-js": "^1.13.3",
91
+ "libphonenumber-js": "^1.13.4",
92
92
  "runed": "^0.23.4",
93
93
  "tailwind-merge": "^3.6.0"
94
94
  }