@stacksjs/defaults 0.75.97 → 0.75.99

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.
@@ -18,7 +18,7 @@ const { title = '', back = '', backLabel = 'Back', large = true } = defineProps<
18
18
  </script>
19
19
 
20
20
  <script client>
21
- import { backLabelFor, canGoBack, enterNavTrail, goBack, haptics, navTrail, observePageScroll } from '@stacksjs/mobile'
21
+ import { backLabelFor, canGoBack, enterNavTrail, goBack, haptics, navTrail, observeLargeTitle, previousScreenPath, trailEntryFor } from '@stacksjs/mobile'
22
22
 
23
23
  const isLarge = useReactiveProp<boolean>('large', true)
24
24
  const onScroll = useReactiveProp<boolean>('titleOnScroll', false)
@@ -40,19 +40,36 @@ effect(() => {
40
40
  if (here) here.title = name
41
41
  })
42
42
 
43
+ // The bar follows the scroll a frame at a time, as UINavigationBar does:
44
+ // --native-nav-progress runs from 0 (large title in full view) to 1 (scrolled
45
+ // under the bar) and the stylesheet fades the material in and moves the small
46
+ // title into the bar off it; --native-nav-stretch grows the large title a
47
+ // little while the page is pulled past its top. Past half way, the title is
48
+ // the bar's (it takes its room, and VoiceOver reads it there).
49
+ const navRoot = useRef('nativeNav')
50
+ const largeTitle = useRef('nativeNavLargeTitle')
43
51
  onMount(() => {
44
- // The large title is about 44px tall; past it, its name belongs in the bar.
45
- stopScroll = observePageScroll(offset => scrolled.set(offset > 40))
52
+ const root = navRoot.current as HTMLElement | null
53
+ if (root) {
54
+ stopScroll = observeLargeTitle(root, {
55
+ title: largeTitle.current as HTMLElement | null,
56
+ onChange: metrics => scrolled.set(metrics.progress >= 0.5),
57
+ })
58
+ }
46
59
  const { current, previous } = enterNavTrail(navTrail(), window.location.pathname + window.location.search)
47
60
  here = current
48
61
  current.title = navTitle()
49
- if (previous && canGoBack()) previousTitle.set(previous.title)
62
+ // With a stack per tab the router knows where back goes; the trail only
63
+ // knows what was entered last, which may be another tab's screen.
64
+ const backPath = previousScreenPath()
65
+ const returnsTo = backPath === undefined ? previous : backPath ? trailEntryFor(navTrail(), backPath) : null
66
+ if (returnsTo && canGoBack()) previousTitle.set(returnsTo.title)
50
67
  })
51
68
  onDestroy(() => stopScroll?.())
52
69
 
53
70
  const barClass = derived(() => {
54
71
  const collapsed = (!isLarge() && !onScroll()) || scrolled()
55
- return `native-nav-bar${collapsed ? ' is-collapsed' : ''}${scrolled() ? ' is-scrolled' : ''}`
72
+ return `native-nav-bar${isLarge() ? ' is-large' : ''}${collapsed ? ' is-collapsed' : ''}${scrolled() ? ' is-scrolled' : ''}`
56
73
  })
57
74
 
58
75
  function tapBack(event: MouseEvent): void {
@@ -63,12 +80,12 @@ function tapBack(event: MouseEvent): void {
63
80
 
64
81
  <!-- `display: contents` on this box and on stx's own wrapper: a sticky element
65
82
  sticks within its parent, and either box would give the bar no room. -->
66
- <div class="native-nav" data-native-nav-bar>
83
+ <div ref="nativeNav" class="native-nav" data-native-nav-bar>
67
84
  <header :class="barClass()">
68
85
  <div class="native-nav-side">
69
86
  @if(back)
70
87
  <a href="{{ back }}" class="native-nav-back" :aria-label="backText()" draggable="false" @click="tapBack">
71
- <i aria-hidden="true" class="i-lucide-chevron-left native-nav-back-icon"></i>
88
+ <NativeSymbol name="chevron.backward" icon="i-lucide-chevron-left" weight="semibold" class="native-nav-back-icon" />
72
89
  <span class="native-nav-back-label">{{ backText() }}</span>
73
90
  </a>
74
91
  @endif
@@ -80,7 +97,7 @@ function tapBack(event: MouseEvent): void {
80
97
  </div>
81
98
  </header>
82
99
  @if(large)
83
- <h1 class="native-nav-large-title" :class="navTitle() ? '' : 'is-empty'">{{ navTitle() }}</h1>
100
+ <h1 ref="nativeNavLargeTitle" class="native-nav-large-title" :class="navTitle() ? '' : 'is-empty'">{{ navTitle() }}</h1>
84
101
  @endif
85
102
  <slot />
86
103
  </div>
@@ -102,6 +119,9 @@ function tapBack(event: MouseEvent): void {
102
119
  align-items: center;
103
120
  min-height: 2.75rem;
104
121
  padding: 0 0.75rem;
122
+ /* Dynamic Type, capped as UIKit caps its bars: a bar's text stops
123
+ growing well before the content's does. */
124
+ font-size: min(var(--native-font-size, 17px), 23px);
105
125
  }
106
126
 
107
127
  /* The frosted material is always there and only fades. Adding or removing
@@ -125,6 +145,13 @@ function tapBack(event: MouseEvent): void {
125
145
 
126
146
  .native-nav-bar.is-scrolled::before { opacity: 1; }
127
147
 
148
+ /* Scroll-linked once the page has run: the material comes in with the
149
+ scroll itself, no transition. */
150
+ .native-nav[style*="--native-nav-progress"] .native-nav-bar::before {
151
+ opacity: max(0.001, min(1, var(--native-nav-progress) * 1.5));
152
+ transition: none;
153
+ }
154
+
128
155
  .dark .native-nav-bar::before {
129
156
  border-color: var(--native-bar-border-dark, rgb(51 65 85 / 0.6));
130
157
  background: var(--native-bar-background-dark, rgb(2 6 23 / 0.86));
@@ -139,7 +166,7 @@ function tapBack(event: MouseEvent): void {
139
166
  .native-nav-title {
140
167
  max-width: 60vw;
141
168
  overflow: hidden;
142
- font-size: 1.0625rem;
169
+ font-size: 1em;
143
170
  font-weight: 600;
144
171
  text-overflow: ellipsis;
145
172
  white-space: nowrap;
@@ -150,6 +177,14 @@ function tapBack(event: MouseEvent): void {
150
177
 
151
178
  .native-nav-bar.is-collapsed .native-nav-title { opacity: 1; transform: none; }
152
179
 
180
+ /* Under a large title the small one rises into the bar with the scroll,
181
+ appearing over the last part of it. */
182
+ .native-nav[style*="--native-nav-progress"] .native-nav-bar.is-large .native-nav-title {
183
+ opacity: clamp(0, calc((var(--native-nav-progress) - 0.6) * 2.5), 1);
184
+ transform: translateY(calc((1 - var(--native-nav-progress)) * 0.5em));
185
+ transition: none;
186
+ }
187
+
153
188
  /* A title that is not showing takes no room from the sides either. */
154
189
  .native-nav-bar:not(.is-collapsed) .native-nav-title { max-width: 0; }
155
190
 
@@ -162,14 +197,16 @@ function tapBack(event: MouseEvent): void {
162
197
  margin-left: -0.4rem;
163
198
  padding-right: 0.5rem;
164
199
  color: var(--native-accent, rgb(37 99 235));
165
- font-size: 1.0625rem;
200
+ font-size: 1em;
166
201
  text-decoration: none;
167
202
  -webkit-touch-callout: none;
168
203
  }
169
204
 
170
205
  .dark .native-nav-back { color: var(--native-accent-dark, rgb(96 165 250)); }
171
206
  .native-nav-back:active { opacity: 0.55; }
172
- .native-nav-back-icon { flex-shrink: 0; width: 1.6rem; height: 1.6rem; }
207
+ /* SF's chevron.backward at the bar's text size, drawn a little heavier and
208
+ larger than the label as UIKit draws it. */
209
+ .native-nav-back-icon { flex-shrink: 0; width: 1.35em; height: 1.35em; }
173
210
  .native-nav-back-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
174
211
  /* Where the centred title leaves too little room for a word, the chevron
175
212
  alone, as on iOS: at 4.5rem a long title still left "Wo…" for "Workout"
@@ -183,16 +220,23 @@ function tapBack(event: MouseEvent): void {
183
220
  .native-nav-large-title {
184
221
  margin: 0.125rem 0 0.5rem;
185
222
  padding: 0 1rem;
186
- font-size: 2.125rem;
223
+ /* 34pt at the default text size: twice the body, as on iOS. */
224
+ font-size: calc(var(--native-font-size, 17px) * 2);
187
225
  font-weight: 700;
188
226
  line-height: 1.15;
189
227
  letter-spacing: -0.02em;
228
+ /* Pulled past the top, it grows from its leading edge. */
229
+ transform: scale(var(--native-nav-stretch, 1));
230
+ transform-origin: 0 50%;
190
231
  }
191
232
 
192
233
  @media (prefers-reduced-motion: reduce) {
193
234
  .native-nav-bar::before, .native-nav-title { transition: none; }
235
+ .native-nav-large-title { transform: none; }
194
236
  }
195
237
 
238
+ :root[data-native-reduce-motion] .native-nav-large-title { transform: none; }
239
+
196
240
  @media (prefers-reduced-transparency: reduce) {
197
241
  .native-nav-bar::before { backdrop-filter: none; -webkit-backdrop-filter: none; background: var(--native-bar-solid, rgb(248 250 252)); }
198
242
  .dark .native-nav-bar::before { background: var(--native-bar-solid-dark, rgb(2 6 23)); }
@@ -77,7 +77,7 @@ onDestroy(() => {
77
77
  /* After the base rule and doubled up, so it wins over `display: flex`. */
78
78
  .native-network-banner.native-network-banner--quiet { display: none; }
79
79
 
80
- @media (prefers-color-scheme: dark) {
81
- .native-network-banner { color: rgb(254 243 199); background: rgb(120 53 15 / 0.96); }
82
- }
80
+ /* Dark with the app's own theme, the `.dark` class, not the system's: an
81
+ app pinned to light in a dark phone kept a dark banner on a light page. */
82
+ .dark .native-network-banner { color: rgb(254 243 199); background: rgb(120 53 15 / 0.96); }
83
83
  </style>
@@ -3,21 +3,30 @@
3
3
  * A ring that fills as something completes: sets done, a countdown running
4
4
  * out. Whatever goes inside it — a count, a time — is the slot.
5
5
  *
6
- * `value` is 0–100. Colours follow `--native-ring` and `--native-ring-track`,
7
- * its thickness `--native-ring-width`.
6
+ * `value` is 0–100. Drawn as an Activity-style ring: a stroke with rounded
7
+ * ends that sweeps from the top, animating to each new value. Colours follow
8
+ * `--native-ring` and `--native-ring-track`, its thickness
9
+ * `--native-ring-width`.
8
10
  */
9
11
  const value = useReactiveProp<number>('value', 0)
10
12
  const size = useReactiveProp<number>('size', 56)
11
13
  const label = useReactiveProp<string>('label', '')
12
14
 
15
+ const percent = derived(() => Math.max(0, Math.min(100, Number(value()) || 0)))
13
16
  const ringStyle = derived(() => {
14
- const percent = Math.max(0, Math.min(100, Number(value()) || 0))
15
17
  const px = Math.max(16, Number(size()) || 56)
16
- return `width:${px}px;height:${px}px;background:conic-gradient(var(--native-ring, rgb(16 185 129)) ${percent}%, var(--native-ring-track, rgb(148 163 184 / 0.22)) 0)`
18
+ return `width:${px}px;height:${px}px`
17
19
  })
20
+ // pathLength is 100, so the offset is what is left to fill. An empty ring
21
+ // hides its stroke: a rounded end of zero length still draws a dot.
22
+ const fillStyle = derived(() => `stroke-dashoffset:${100 - percent()};opacity:${percent() > 0 ? 1 : 0}`)
18
23
  </script>
19
24
 
20
25
  <div class="native-ring" role="img" :aria-label="label()" :style="ringStyle()" data-native-progress-ring>
26
+ <svg class="native-ring-svg" aria-hidden="true" focusable="false">
27
+ <circle class="native-ring-track" cx="50%" cy="50%" r="50%" />
28
+ <circle class="native-ring-fill" cx="50%" cy="50%" r="50%" pathLength="100" :style="fillStyle()" />
29
+ </svg>
21
30
  <div class="native-ring-center">
22
31
  <slot />
23
32
  </div>
@@ -27,8 +36,34 @@ const ringStyle = derived(() => {
27
36
  .native-ring {
28
37
  position: relative;
29
38
  flex-shrink: 0;
30
- border-radius: 9999px;
31
- transition: background 200ms linear;
39
+ }
40
+
41
+ /* No viewBox, so the SVG's units are CSS pixels and the stroke is exactly
42
+ --native-ring-width. Inset by half of it: the stroke is drawn centred on
43
+ the circle (r 50% of a square box is half its side) and overflows the
44
+ box by the other half, so the ring's outer edge is the component's. */
45
+ .native-ring-svg {
46
+ position: absolute;
47
+ inset: calc(var(--native-ring-width, 5px) / 2);
48
+ width: calc(100% - var(--native-ring-width, 5px));
49
+ height: calc(100% - var(--native-ring-width, 5px));
50
+ overflow: visible;
51
+ transform: rotate(-90deg);
52
+ }
53
+
54
+ .native-ring-track,
55
+ .native-ring-fill {
56
+ fill: none;
57
+ stroke-width: var(--native-ring-width, 5px);
58
+ }
59
+
60
+ .native-ring-track { stroke: var(--native-ring-track, rgb(148 163 184 / 0.22)); }
61
+
62
+ .native-ring-fill {
63
+ stroke: var(--native-ring, rgb(16 185 129));
64
+ stroke-linecap: round;
65
+ stroke-dasharray: 100;
66
+ transition: stroke-dashoffset 500ms cubic-bezier(0.32, 0.72, 0, 1), opacity 150ms ease;
32
67
  }
33
68
 
34
69
  .native-ring-center {
@@ -39,10 +74,11 @@ const ringStyle = derived(() => {
39
74
  align-items: center;
40
75
  justify-content: center;
41
76
  border-radius: 9999px;
42
- background: var(--native-ring-center, var(--native-safe-area-background, Canvas));
43
77
  }
44
78
 
45
79
  @media (prefers-reduced-motion: reduce) {
46
- .native-ring { transition: none; }
80
+ .native-ring-fill { transition: none; }
47
81
  }
82
+
83
+ :root[data-native-reduce-motion] .native-ring-fill { transition: none; }
48
84
  </style>
@@ -1,101 +1,194 @@
1
1
  <script client>
2
- import { observePullToRefresh } from '@stacksjs/mobile'
2
+ import { observeNativeRefresh, observePullToRefresh, refresh, resolveScrollHost, whenBridgeReady } from '@stacksjs/mobile'
3
3
 
4
4
  /**
5
5
  * Pull the page down from its top to refresh it.
6
6
  *
7
- * On iOS the pull rides the system's own rubber band, so it feels like a
8
- * native list; elsewhere the finger's travel drives it. Crossing the threshold
9
- * taps the haptic engine once. The `refresh` event carries `done()`: call it
10
- * when the new data is in, and the spinner goes away. A handler that never
11
- * calls it is let go after 15 seconds.
7
+ * In Craft's iOS shell this is the phone's own UIRefreshControl, the spinner
8
+ * and the feel of every native list. Elsewhere, and on a shell without it,
9
+ * the web gesture draws the same thing: the 12-spoke activity indicator
10
+ * revealed spoke by spoke above the content as it is pulled, a haptic tap at
11
+ * the threshold, the content held down under the spinning indicator while
12
+ * refreshing, and eased back up when done.
13
+ *
14
+ * The `refresh` event carries `done()`: call it when the new data is in. A
15
+ * handler that never calls it is let go after 15 seconds. `scrollHost` names
16
+ * (by CSS selector) a container the screen scrolls in instead of the page;
17
+ * the native control covers only the page, so a scroll host uses the gesture.
12
18
  */
13
19
  const emit = defineEmits()
14
20
  const threshold = useReactiveProp<number>('threshold', 72)
15
21
  const label = useReactiveProp<string>('label', 'Refreshing')
22
+ const scrollHost = useReactiveProp<string>('scrollHost', '')
23
+ const tint = useReactiveProp<string>('tint', '')
16
24
  const distance = state(0)
25
+ const bounce = state(0)
17
26
  const armed = state(false)
18
27
  const refreshing = state(false)
28
+ const ptr = useRef('nativePtr')
19
29
  let stop: (() => void) | null = null
30
+ let destroyed = false
20
31
 
21
- onMount(() => {
32
+ // The height the content is held down by while refreshing: the indicator's.
33
+ const HOLD = 56
34
+
35
+ function run(): Promise<void> {
36
+ return new Promise<void>((resolve) => {
37
+ refreshing.set(true)
38
+ const finish = (): void => {
39
+ clearTimeout(timer)
40
+ refreshing.set(false)
41
+ resolve()
42
+ }
43
+ const timer = setTimeout(finish, 15000)
44
+ emit('refresh', { done: finish })
45
+ })
46
+ }
47
+
48
+ function useGesture(): void {
22
49
  stop = observePullToRefresh({
23
50
  threshold: threshold(),
24
- onPull: (pulled, isArmed) => {
51
+ host: resolveScrollHost(scrollHost()),
52
+ onPull: (pulled, isArmed, pull) => {
25
53
  distance.set(pulled)
54
+ bounce.set(pull.bounce)
26
55
  armed.set(isArmed)
27
56
  },
28
- onRefresh: () => new Promise<void>((resolve) => {
29
- refreshing.set(true)
30
- const finish = (): void => {
31
- clearTimeout(timer)
32
- refreshing.set(false)
33
- resolve()
34
- }
35
- const timer = setTimeout(finish, 15000)
36
- emit('refresh', { done: finish })
37
- }),
57
+ onRefresh: run,
38
58
  })
59
+ }
60
+
61
+ onMount(() => {
62
+ const element = ptr.current as HTMLElement | null
63
+ // Decided once the bridge can answer, so the phone never runs both.
64
+ void whenBridgeReady(3000).then(() => {
65
+ if (destroyed) return
66
+ if (element && !scrollHost() && refresh.isAvailable()) {
67
+ // The native spinner draws itself; the page only refreshes.
68
+ stop = observeNativeRefresh(element, {
69
+ tintColor: tint() || undefined,
70
+ onRefresh: () => new Promise<void>((resolve) => {
71
+ const timer = setTimeout(resolve, 15000)
72
+ const finish = (): void => {
73
+ clearTimeout(timer)
74
+ resolve()
75
+ }
76
+ emit('refresh', { done: finish })
77
+ }),
78
+ })
79
+ }
80
+ else {
81
+ useGesture()
82
+ }
83
+ })
84
+ })
85
+ onDestroy(() => {
86
+ destroyed = true
87
+ stop?.()
39
88
  })
40
- onDestroy(() => stop?.())
41
89
 
42
- const indicatorClass = derived(() => {
43
- const visible = refreshing() || distance() > 8
44
- return `native-ptr-indicator${visible ? ' is-visible' : ''}${refreshing() ? ' is-refreshing' : ''}${armed() ? ' is-armed' : ''}`
90
+ const ptrClass = derived(() => {
91
+ const pulling = !refreshing() && distance() > 0
92
+ return `${pulling ? 'is-pulling' : ''}${refreshing() ? ' is-refreshing' : ''}${armed() ? ' is-armed' : ''}${refreshing() || distance() > 4 ? ' is-visible' : ''}`.trim()
45
93
  })
46
- const indicatorStyle = derived(() => {
47
- if (refreshing()) return ''
48
- const progress = Math.min(1, distance() / threshold())
49
- return `opacity:${progress};transform:translateX(-50%) rotate(${Math.round(progress * 270)}deg) scale(${0.6 + progress * 0.4})`
94
+ // iOS's rubber band already moves the page by `bounce`; the content moves by
95
+ // the rest. While refreshing it is held under the indicator.
96
+ const ptrStyle = derived(() => {
97
+ const shift = refreshing() ? HOLD : Math.max(0, distance() - bounce())
98
+ const progress = refreshing() ? 1 : Math.min(1, distance() / Math.max(1, threshold()))
99
+ return `--native-ptr-shift:${Math.round(shift)}px;--native-ptr-progress:${progress.toFixed(3)}`
50
100
  })
51
101
  </script>
52
102
 
53
- <div class="native-ptr">
54
- <div :class="indicatorClass()" :style="indicatorStyle()" role="status" :aria-label="refreshing() ? label() : ''">
55
- <i aria-hidden="true" class="i-lucide-loader-circle native-ptr-icon"></i>
103
+ <div ref="nativePtr" class="native-ptr" :class="ptrClass()" :style="ptrStyle()" data-native-ptr>
104
+ <div class="native-ptr-indicator" role="status" :aria-label="refreshing() ? label() : ''">
105
+ <span class="native-ptr-spinner" aria-hidden="true">
106
+ <span class="native-ptr-spoke" style="--i: 0"></span>
107
+ <span class="native-ptr-spoke" style="--i: 1"></span>
108
+ <span class="native-ptr-spoke" style="--i: 2"></span>
109
+ <span class="native-ptr-spoke" style="--i: 3"></span>
110
+ <span class="native-ptr-spoke" style="--i: 4"></span>
111
+ <span class="native-ptr-spoke" style="--i: 5"></span>
112
+ <span class="native-ptr-spoke" style="--i: 6"></span>
113
+ <span class="native-ptr-spoke" style="--i: 7"></span>
114
+ <span class="native-ptr-spoke" style="--i: 8"></span>
115
+ <span class="native-ptr-spoke" style="--i: 9"></span>
116
+ <span class="native-ptr-spoke" style="--i: 10"></span>
117
+ <span class="native-ptr-spoke" style="--i: 11"></span>
118
+ </span>
119
+ </div>
120
+ <div class="native-ptr-content">
121
+ <slot />
56
122
  </div>
57
- <slot />
58
123
  </div>
59
124
 
60
125
  <style>
61
126
  .native-ptr { position: relative; }
62
127
 
128
+ /* Under the navigation bar, in the gap the pulled content opens. */
63
129
  .native-ptr-indicator {
64
130
  position: fixed;
65
- top: calc(env(safe-area-inset-top, 0px) + 3.25rem);
131
+ top: calc(env(safe-area-inset-top, 0px) + var(--native-ptr-top, 3.25rem));
66
132
  left: 50%;
67
133
  z-index: 25;
68
- display: flex;
69
- align-items: center;
70
- justify-content: center;
71
- width: 2.25rem;
72
- height: 2.25rem;
73
- border-radius: 999px;
74
- color: var(--native-accent, rgb(37 99 235));
75
- background: var(--native-ptr-background, white);
76
- box-shadow: 0 4px 14px rgb(15 23 42 / 0.14);
134
+ width: 1.75rem;
135
+ height: 1.75rem;
136
+ margin-left: -0.875rem;
137
+ color: var(--native-ptr-color, rgb(142 142 147));
77
138
  opacity: 0;
78
139
  pointer-events: none;
79
- transform: translateX(-50%) scale(0.6);
80
- /* Restyled every frame of a pull: its own layer keeps that off the page's paint. */
81
- will-change: transform, opacity;
140
+ transform: scale(0.85);
141
+ transition: opacity 200ms ease, transform 200ms ease;
82
142
  }
83
143
 
84
- .dark .native-ptr-indicator { color: var(--native-accent-dark, rgb(96 165 250)); background: var(--native-ptr-background-dark, rgb(30 41 59)); }
85
- .native-ptr-indicator.is-visible { opacity: 1; }
86
- .native-ptr-icon { width: 1.25rem; height: 1.25rem; }
144
+ .native-ptr.is-visible .native-ptr-indicator { opacity: 1; transform: none; }
145
+ .native-ptr.is-pulling .native-ptr-indicator { transition: none; }
87
146
 
88
- .native-ptr-indicator.is-refreshing {
89
- opacity: 1;
90
- transform: translateX(-50%);
91
- transition: opacity 150ms ease;
147
+ .native-ptr-spinner { position: absolute; inset: 0; }
148
+
149
+ /* Twelve rounded spokes round a centre, as UIActivityIndicatorView draws. */
150
+ .native-ptr-spoke {
151
+ position: absolute;
152
+ top: 0;
153
+ left: 50%;
154
+ width: 9%;
155
+ height: 28%;
156
+ margin-left: -4.5%;
157
+ border-radius: 999px;
158
+ background: currentColor;
159
+ transform: rotate(calc(var(--i) * 30deg));
160
+ transform-origin: 50% 178%;
161
+ /* Revealed one by one as the pull goes on. */
162
+ opacity: clamp(0, calc(var(--native-ptr-progress, 0) * 12 - var(--i)), 1);
163
+ }
164
+
165
+ .native-ptr.is-refreshing .native-ptr-spoke {
166
+ opacity: 0.25;
167
+ animation: native-ptr-spoke 1s linear infinite;
168
+ animation-delay: calc(var(--i) * (1s / 12) - 1s);
92
169
  }
93
170
 
94
- .native-ptr-indicator.is-refreshing .native-ptr-icon { animation: native-ptr-spin 800ms linear infinite; }
171
+ @keyframes native-ptr-spoke {
172
+ from { opacity: 1; }
173
+ to { opacity: 0.25; }
174
+ }
175
+
176
+ /* The content follows the finger, is held while refreshing, and eases back.
177
+ Transformed only while it is moved: a transform at rest, even a zero
178
+ one, would become the containing block of every fixed element on the
179
+ page. */
180
+ .native-ptr-content { transition: transform 380ms cubic-bezier(0.32, 0.72, 0, 1); }
95
181
 
96
- @keyframes native-ptr-spin { to { transform: rotate(360deg); } }
182
+ .native-ptr:is(.is-pulling, .is-refreshing) .native-ptr-content {
183
+ transform: translateY(var(--native-ptr-shift, 0px));
184
+ }
185
+
186
+ .native-ptr.is-pulling .native-ptr-content { transition: none; }
97
187
 
98
188
  @media (prefers-reduced-motion: reduce) {
99
- .native-ptr-indicator.is-refreshing .native-ptr-icon { animation-duration: 2400ms; }
189
+ .native-ptr.is-refreshing .native-ptr-spoke { animation-duration: 3s; }
190
+ .native-ptr-content { transition-duration: 1ms; }
100
191
  }
192
+
193
+ :root[data-native-reduce-motion] .native-ptr.is-refreshing .native-ptr-spoke { animation-duration: 3s; }
101
194
  </style>
@@ -1,12 +1,14 @@
1
1
  <script client>
2
- import { haptics } from '@stacksjs/mobile'
2
+ import { focusSegment, haptics, observeSegmentScrub, segmentForKey } from '@stacksjs/mobile'
3
3
 
4
4
  /**
5
5
  * An iOS segmented control: two to five mutually exclusive views of one screen.
6
6
  *
7
7
  * `options` is a list of strings or `{ label, value }`. Bind the choice with
8
8
  * `v-model:value` or listen for `change`. A selection taps the haptic engine
9
- * the way UISegmentedControl does.
9
+ * the way UISegmentedControl does. As there, the thumb shrinks under a
10
+ * finger and can be dragged across to scrub to another segment, and the
11
+ * arrow keys move between segments.
10
12
  */
11
13
  interface SegmentOption { label: string, value: string }
12
14
 
@@ -20,9 +22,13 @@ const options = derived<SegmentOption[]>(() => rawOptions().map(option =>
20
22
  typeof option === 'string' ? { label: option, value: option } : option))
21
23
 
22
24
  const activeIndex = derived(() => Math.max(0, options().findIndex(option => option.value === (selected() || options()[0]?.value))))
25
+ // Where the thumb is drawn: under the finger while scrubbing, otherwise on the choice.
26
+ const scrubIndex = state<number | null>(null)
27
+ const pressed = state(false)
23
28
  const thumbStyle = derived(() => {
24
29
  const count = Math.max(1, options().length)
25
- return `width:calc((100% - 4px) / ${count});transform:translateX(${activeIndex() * 100}%)`
30
+ const index = scrubIndex() ?? activeIndex()
31
+ return `width:calc((100% - 4px) / ${count});transform:translateX(${index * 100}%)${pressed() ? ' scale(0.95)' : ''}`
26
32
  })
27
33
 
28
34
  function choose(value: string): void {
@@ -31,9 +37,46 @@ function choose(value: string): void {
31
37
  void haptics.selection()
32
38
  emit('change', value)
33
39
  }
40
+
41
+ const track = useRef('nativeSegmented')
42
+ let stopScrub: (() => void) | null = null
43
+ onMount(() => {
44
+ const element = track.current as HTMLElement | null
45
+ if (!element) return
46
+ stopScrub = observeSegmentScrub(element, {
47
+ count: () => options().length,
48
+ selected: () => activeIndex(),
49
+ onPress: (down) => {
50
+ pressed.set(down)
51
+ if (!down) scrubIndex.set(null)
52
+ },
53
+ onScrub: (index) => {
54
+ scrubIndex.set(index)
55
+ void haptics.selection()
56
+ },
57
+ onCommit: (index) => {
58
+ scrubIndex.set(null)
59
+ const option = options()[index]
60
+ if (!option || option.value === selected()) return
61
+ // The scrub already ticked as it crossed each segment.
62
+ selected.set(option.value)
63
+ emit('change', option.value)
64
+ },
65
+ })
66
+ })
67
+ onDestroy(() => stopScrub?.())
68
+
69
+ function onKey(event: KeyboardEvent): void {
70
+ const next = segmentForKey(event.key, activeIndex(), options().length)
71
+ if (next === null) return
72
+ event.preventDefault()
73
+ const option = options()[next]
74
+ if (option) choose(option.value)
75
+ if (track.current) focusSegment(track.current as HTMLElement, next)
76
+ }
34
77
  </script>
35
78
 
36
- <div role="tablist" :aria-label="ariaLabel()" class="native-segmented" :class="className()">
79
+ <div ref="nativeSegmented" role="tablist" :aria-label="ariaLabel()" class="native-segmented" :class="className()" @keydown="onKey">
37
80
  <span aria-hidden="true" class="native-segmented-thumb" :style="thumbStyle()"></span>
38
81
  <button
39
82
  :for="(option, index) in options()"
@@ -42,6 +85,7 @@ function choose(value: string): void {
42
85
  role="tab"
43
86
  class="native-segmented-option"
44
87
  :aria-selected="index === activeIndex() ? 'true' : 'false'"
88
+ :tabindex="index === activeIndex() ? '0' : '-1'"
45
89
  @click="choose(option.value)"
46
90
  >{{ option.label }}</button>
47
91
  </div>
@@ -55,8 +99,10 @@ function choose(value: string): void {
55
99
  padding: 2px;
56
100
  border-radius: 0.6rem;
57
101
  background: var(--native-segmented-track, rgb(118 118 128 / 0.12));
102
+ font-size: var(--native-font-size, 17px);
58
103
  -webkit-user-select: none;
59
104
  user-select: none;
105
+ touch-action: pan-y;
60
106
  }
61
107
 
62
108
  .dark .native-segmented { background: var(--native-segmented-track-dark, rgb(118 118 128 / 0.24)); }
@@ -69,7 +115,7 @@ function choose(value: string): void {
69
115
  border-radius: 0.45rem;
70
116
  background: var(--native-segmented-thumb, white);
71
117
  box-shadow: 0 3px 8px rgb(0 0 0 / 0.12), 0 3px 1px rgb(0 0 0 / 0.04);
72
- transition: transform 220ms cubic-bezier(0.3, 0.7, 0.4, 1);
118
+ transition: transform 280ms cubic-bezier(0.3, 0.7, 0.4, 1.1);
73
119
  }
74
120
 
75
121
  .dark .native-segmented-thumb { background: var(--native-segmented-thumb-dark, rgb(99 99 102)); }
@@ -82,14 +128,20 @@ function choose(value: string): void {
82
128
  border: 0;
83
129
  color: inherit;
84
130
  background: transparent;
85
- font-size: 0.8125rem;
131
+ /* 13pt at the default text size. */
132
+ font-size: 0.765em;
86
133
  font-weight: 500;
87
134
  -webkit-tap-highlight-color: transparent;
135
+ touch-action: manipulation;
88
136
  }
89
137
 
138
+ .native-segmented-option:focus-visible { outline: 2px solid var(--native-accent, rgb(37 99 235)); outline-offset: -2px; border-radius: 0.45rem; }
139
+
90
140
  .native-segmented-option[aria-selected="true"] { font-weight: 600; }
91
141
 
92
142
  @media (prefers-reduced-motion: reduce) {
93
143
  .native-segmented-thumb { transition: none; }
94
144
  }
145
+
146
+ :root[data-native-reduce-motion] .native-segmented-thumb { transition: none; }
95
147
  </style>