@marianmeres/stuic 3.154.0 → 3.156.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/API.md +3 -1
- package/dist/actions/onboarding/onboarding.container.fixture.svelte +76 -0
- package/dist/actions/onboarding/onboarding.container.fixture.svelte.d.ts +8 -0
- package/dist/actions/onboarding/onboarding.svelte.js +46 -0
- package/dist/components/Input/README.md +21 -0
- package/dist/components/Input/index.css +78 -0
- package/package.json +1 -1
package/API.md
CHANGED
|
@@ -1629,10 +1629,12 @@ Multi-step onboarding tour built on the spotlight primitive. Define steps centra
|
|
|
1629
1629
|
| `onSkip` | `() => void` | — | Called when tour is skipped |
|
|
1630
1630
|
| `onStepChange` | `(step, index) => void` | — | Called on every step change |
|
|
1631
1631
|
|
|
1632
|
-
**Returns:** `{ start(),
|
|
1632
|
+
**Returns:** `{ start(), next(), prev(), skip(), reset(), reposition(), active, currentStep, currentIndex, seen }`
|
|
1633
1633
|
|
|
1634
1634
|
`reposition()` forces the active step's spotlight to re-measure its target and re-apply the cutout/anchor. Useful after a layout shift the spotlight's auto-tracking can't observe (or when a step opted out of it).
|
|
1635
1635
|
|
|
1636
|
+
`reset()` clears the `storageKey` result so `start()` will run the tour again; `seen` is that flag read back. Every `start()` re-resolves `selector` steps against the current DOM, so a tour whose targets remount between runs (a lazy tab, a route, a keyed block) points at the nodes that are on screen now. Targets registered through `use:tourStep` are left to the action, which already tracks their mount and unmount.
|
|
1637
|
+
|
|
1636
1638
|
**`TourStepDef`:**
|
|
1637
1639
|
|
|
1638
1640
|
| Field | Type | Description |
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from "svelte";
|
|
3
|
+
import { createTour, tourStep } from "./onboarding.svelte.js";
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
/** Register the target through `use:tourStep` instead of a `selector`. */
|
|
7
|
+
useAction = false,
|
|
8
|
+
/** Re-keys the target, i.e. destroys the node and mounts a fresh one at
|
|
9
|
+
* different coordinates — what a lazy tab or a keyed block does between
|
|
10
|
+
* two runs of the same tour. */
|
|
11
|
+
swapped = false,
|
|
12
|
+
storageKey = undefined,
|
|
13
|
+
}: {
|
|
14
|
+
useAction?: boolean;
|
|
15
|
+
swapped?: boolean;
|
|
16
|
+
storageKey?: string;
|
|
17
|
+
} = $props();
|
|
18
|
+
|
|
19
|
+
const tour = createTour({
|
|
20
|
+
steps: [
|
|
21
|
+
{
|
|
22
|
+
id: "one",
|
|
23
|
+
title: "Step one",
|
|
24
|
+
content: "the only step",
|
|
25
|
+
position: "bottom",
|
|
26
|
+
padding: 0,
|
|
27
|
+
borderRadius: 0,
|
|
28
|
+
// The whole point of the two modes: an action-registered step has
|
|
29
|
+
// no selector to be re-resolved from. untracked for the same reason
|
|
30
|
+
// as `storageKey` below.
|
|
31
|
+
selector: untrack(() => (useAction ? undefined : "[data-testid='target']")),
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
// Short: the action test asserts a step is NOT skipped, and a skip costs
|
|
35
|
+
// this whole wait before it can be observed.
|
|
36
|
+
waitForElement: 300,
|
|
37
|
+
// untrack: read once at init, which is when `createTour` needs it — a
|
|
38
|
+
// tracked read here would be a reactivity warning for a prop that never
|
|
39
|
+
// changes after mount.
|
|
40
|
+
storageKey: untrack(() => storageKey),
|
|
41
|
+
storage: "session",
|
|
42
|
+
showSteps: false,
|
|
43
|
+
});
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<!-- Above the spotlight backdrop (z-index 50), which would otherwise swallow
|
|
47
|
+
every click the test makes while a tour is running. -->
|
|
48
|
+
<div style="position: relative; z-index: 100;">
|
|
49
|
+
<button data-testid="start" onclick={() => tour.start()}>start</button>
|
|
50
|
+
<button data-testid="reset" onclick={() => tour.reset()}>reset</button>
|
|
51
|
+
<button data-testid="skip" onclick={() => void tour.skip()}>skip</button>
|
|
52
|
+
<div data-testid="active">{tour.active ? "yes" : "no"}</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
{#key swapped}
|
|
56
|
+
{#if useAction}
|
|
57
|
+
<div
|
|
58
|
+
data-testid="target"
|
|
59
|
+
use:tourStep={[tour, "one"]}
|
|
60
|
+
style="position: absolute; left: {swapped ? 160 : 40}px; top: {swapped
|
|
61
|
+
? 130
|
|
62
|
+
: 30}px; width: 60px; height: 20px;"
|
|
63
|
+
>
|
|
64
|
+
t
|
|
65
|
+
</div>
|
|
66
|
+
{:else}
|
|
67
|
+
<div
|
|
68
|
+
data-testid="target"
|
|
69
|
+
style="position: absolute; left: {swapped ? 160 : 40}px; top: {swapped
|
|
70
|
+
? 130
|
|
71
|
+
: 30}px; width: 60px; height: 20px;"
|
|
72
|
+
>
|
|
73
|
+
t
|
|
74
|
+
</div>
|
|
75
|
+
{/if}
|
|
76
|
+
{/key}
|
|
@@ -225,12 +225,51 @@ export function createTour(options) {
|
|
|
225
225
|
store?.set(options.storageKey, "completed");
|
|
226
226
|
options.onEnd?.();
|
|
227
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* Drop every SELECTOR-resolved target, so the next run resolves them against
|
|
230
|
+
* the DOM as it is now.
|
|
231
|
+
*
|
|
232
|
+
* The registry is a cache and `advanceTo` only queries the DOM for a step it
|
|
233
|
+
* does not already hold, so without this a second run re-uses the first
|
|
234
|
+
* run's nodes. For a tour whose targets all sit in one stable subtree that
|
|
235
|
+
* is harmless. For one that crosses a lazy tab, a route or a keyed block it
|
|
236
|
+
* is fatal, and silently: a detached node's `getBoundingClientRect()` is all
|
|
237
|
+
* zeroes, so the cutout collapses to 0x0 in the top-left corner and the
|
|
238
|
+
* annotation follows it there. Nothing throws and nothing warns.
|
|
239
|
+
*
|
|
240
|
+
* Steps registered through `use:tourStep` are deliberately SPARED. The
|
|
241
|
+
* action owns their lifetime — it registers on mount and unregisters on
|
|
242
|
+
* destroy — so their entries are never stale, and such a step has no
|
|
243
|
+
* `selector` to be re-resolved from: dropping one whose element is still on
|
|
244
|
+
* screen would leave `advanceTo` nothing to find, and it would skip the step
|
|
245
|
+
* after waiting `waitForElement` ms for a registration that already
|
|
246
|
+
* happened. `actionRegistered` is exactly the set to spare.
|
|
247
|
+
*/
|
|
248
|
+
function clearResolvedTargets() {
|
|
249
|
+
for (const id of registry.keys()) {
|
|
250
|
+
if (!actionRegistered.has(id))
|
|
251
|
+
registry.delete(id);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
228
254
|
// -- Public API ---------------------------------------------------------------------
|
|
255
|
+
/**
|
|
256
|
+
* Begin the tour at its first available step.
|
|
257
|
+
*
|
|
258
|
+
* No-op while a tour is already running, and — if `storageKey` is set — for
|
|
259
|
+
* anyone who has already completed or skipped it. Use {@link reset} to
|
|
260
|
+
* clear that.
|
|
261
|
+
*
|
|
262
|
+
* Resolved targets are cleared HERE rather than in `reset()`, because the
|
|
263
|
+
* staleness is per-RUN and not per-persisted-flag: a tour with no
|
|
264
|
+
* `storageKey` is re-startable without ever calling `reset()`, and would
|
|
265
|
+
* otherwise walk the previous run's nodes. See {@link clearResolvedTargets}.
|
|
266
|
+
*/
|
|
229
267
|
function start() {
|
|
230
268
|
if (active)
|
|
231
269
|
return;
|
|
232
270
|
if (store && store.has(options.storageKey))
|
|
233
271
|
return;
|
|
272
|
+
clearResolvedTargets();
|
|
234
273
|
options.onStart?.();
|
|
235
274
|
advanceTo(0);
|
|
236
275
|
}
|
|
@@ -248,6 +287,13 @@ export function createTour(options) {
|
|
|
248
287
|
store?.set(options.storageKey, "skipped");
|
|
249
288
|
options.onSkip?.();
|
|
250
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Forget that this tour was completed or skipped, so {@link start} runs it
|
|
292
|
+
* again. A no-op without `storageKey` — there is nothing persisted to clear.
|
|
293
|
+
*
|
|
294
|
+
* It does NOT touch the resolved-target cache; `start()` does, on every run.
|
|
295
|
+
* See {@link clearResolvedTargets} for why that is the right boundary.
|
|
296
|
+
*/
|
|
251
297
|
function reset() {
|
|
252
298
|
store?.remove(options.storageKey);
|
|
253
299
|
}
|
|
@@ -291,6 +291,27 @@ Each size (sm, md, lg) has corresponding tokens:
|
|
|
291
291
|
| `--stuic-input-font-size-{size}` | `--text-sm` | `--text-base` | `--text-lg` |
|
|
292
292
|
| `--stuic-input-min-height-{size}` | `2.5rem` | `2.75rem` | `3rem` |
|
|
293
293
|
|
|
294
|
+
#### iOS zoom guard
|
|
295
|
+
|
|
296
|
+
iOS/iPadOS Safari zooms the whole page when a text control with a computed
|
|
297
|
+
font-size below 16px receives focus — which the `sm` size (14px) triggers. On
|
|
298
|
+
touch-capable Apple devices only, inputs, textareas and selects are therefore
|
|
299
|
+
raised to at least:
|
|
300
|
+
|
|
301
|
+
| Variable | Default | Description |
|
|
302
|
+
| ----------------------------------- | ------- | -------------------------------------------------- |
|
|
303
|
+
| `--stuic-input-font-size-touch-min` | `16px` | Minimum font-size on touch WebKit (iOS zoom guard) |
|
|
304
|
+
|
|
305
|
+
Desktop (including macOS Safari) and Android keep the original, smaller size —
|
|
306
|
+
neither zooms on focus. The guard never shrinks a control, so `md`/`lg` are
|
|
307
|
+
unaffected at their default sizes. Opt out with:
|
|
308
|
+
|
|
309
|
+
```css
|
|
310
|
+
:root {
|
|
311
|
+
--stuic-input-font-size-touch-min: 0px;
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
294
315
|
### Checkbox/Radio Tokens
|
|
295
316
|
|
|
296
317
|
| Variable | Default | Description |
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
--stuic-input-font-size-lg: var(--text-lg);
|
|
43
43
|
--stuic-input-min-height-lg: 3rem;
|
|
44
44
|
|
|
45
|
+
/* Smallest font-size a text control may compute to on touch WebKit.
|
|
46
|
+
See the "IOS ZOOM GUARD" section at the bottom of this file. */
|
|
47
|
+
--stuic-input-font-size-touch-min: 16px;
|
|
48
|
+
|
|
45
49
|
/* Range input */
|
|
46
50
|
--stuic-input-range-thumb-size: 18px;
|
|
47
51
|
--stuic-input-range-track-height: 4px;
|
|
@@ -674,3 +678,77 @@
|
|
|
674
678
|
padding-bottom: 0;
|
|
675
679
|
}
|
|
676
680
|
}
|
|
681
|
+
|
|
682
|
+
/* ============================================================================
|
|
683
|
+
IOS ZOOM GUARD
|
|
684
|
+
|
|
685
|
+
Mobile WebKit (iOS/iPadOS) auto-zooms the viewport when a text control whose
|
|
686
|
+
computed font-size is below 16px receives focus. The `sm` size (14px) trips
|
|
687
|
+
it, so tapping into e.g. a FieldsBuilder input jumps the whole page.
|
|
688
|
+
|
|
689
|
+
Raise the font-size to at least `--stuic-input-font-size-touch-min`, but only
|
|
690
|
+
where the zoom actually happens. Two conditions, both required:
|
|
691
|
+
- `@supports (font: -apple-system-body) or (-webkit-touch-callout: none)`
|
|
692
|
+
→ an Apple/WebKit engine (verified: true in WebKit, false in Blink and
|
|
693
|
+
Gecko). Two signals OR-ed because `-webkit-touch-callout` is iOS-only
|
|
694
|
+
while `-apple-system-body` covers Apple platforms generally.
|
|
695
|
+
- `@media (any-pointer: coarse)` → ...on a touch-capable device, which on
|
|
696
|
+
Apple means iOS/iPadOS. This is what excludes desktop Safari.
|
|
697
|
+
|
|
698
|
+
Desktop (incl. Safari) and Android/Blink — neither of which zooms on focus —
|
|
699
|
+
keep the original, smaller size.
|
|
700
|
+
|
|
701
|
+
`max()` respects consumer overrides of the size tokens and never shrinks a
|
|
702
|
+
control. Opt out with `--stuic-input-font-size-touch-min: 0px`.
|
|
703
|
+
|
|
704
|
+
Deliberately NOT in `@layer components`: unlayered rules beat layered ones,
|
|
705
|
+
so this also wins over the Tailwind `text-sm` utilities some components put
|
|
706
|
+
on their own raw inputs (FieldsBuilder's mono key/value inputs, ...), which
|
|
707
|
+
would otherwise stay at 14px regardless of the size tokens.
|
|
708
|
+
============================================================================ */
|
|
709
|
+
|
|
710
|
+
@supports (font: -apple-system-body) or (-webkit-touch-callout: none) {
|
|
711
|
+
@media (any-pointer: coarse) {
|
|
712
|
+
.stuic-input[data-size="sm"]
|
|
713
|
+
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not(
|
|
714
|
+
[type="file"]
|
|
715
|
+
),
|
|
716
|
+
.stuic-input[data-size="sm"] textarea,
|
|
717
|
+
.stuic-input[data-size="sm"] select {
|
|
718
|
+
font-size: max(
|
|
719
|
+
var(--stuic-input-font-size-touch-min),
|
|
720
|
+
var(--stuic-input-font-size-sm)
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.stuic-input[data-size="md"]
|
|
725
|
+
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not(
|
|
726
|
+
[type="file"]
|
|
727
|
+
),
|
|
728
|
+
.stuic-input[data-size="md"] textarea,
|
|
729
|
+
.stuic-input[data-size="md"] select,
|
|
730
|
+
.stuic-input:not([data-size])
|
|
731
|
+
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not(
|
|
732
|
+
[type="file"]
|
|
733
|
+
),
|
|
734
|
+
.stuic-input:not([data-size]) textarea,
|
|
735
|
+
.stuic-input:not([data-size]) select {
|
|
736
|
+
font-size: max(
|
|
737
|
+
var(--stuic-input-font-size-touch-min),
|
|
738
|
+
var(--stuic-input-font-size-md)
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
.stuic-input[data-size="lg"]
|
|
743
|
+
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not(
|
|
744
|
+
[type="file"]
|
|
745
|
+
),
|
|
746
|
+
.stuic-input[data-size="lg"] textarea,
|
|
747
|
+
.stuic-input[data-size="lg"] select {
|
|
748
|
+
font-size: max(
|
|
749
|
+
var(--stuic-input-font-size-touch-min),
|
|
750
|
+
var(--stuic-input-font-size-lg)
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|