@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.
- package/ai/skills/stacks-dashboard/SKILL.md +2 -2
- package/ai/skills/stacks-mobile/SKILL.md +67 -8
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/NativeActionSheet.stx +44 -0
- package/resources/components/NativeAppShell.stx +70 -1
- package/resources/components/NativeComponents.test.ts +168 -1
- package/resources/components/NativeContextMenu.stx +83 -0
- package/resources/components/NativeNavBar.stx +56 -12
- package/resources/components/NativeNetworkBanner.stx +3 -3
- package/resources/components/NativeProgressRing.stx +44 -8
- package/resources/components/NativePullToRefresh.stx +146 -53
- package/resources/components/NativeSegmentedControl.stx +58 -6
- package/resources/components/NativeSheet.stx +193 -17
- package/resources/components/NativeSymbol.stx +121 -0
- package/resources/components/NativeTabBar.stx +15 -3
- package/resources/components/NativeTabItem.stx +5 -1
- package/resources/components/index.ts +3 -0
|
@@ -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,
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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:
|
|
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:
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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.
|
|
7
|
-
*
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
*
|
|
8
|
-
* native list
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
43
|
-
const
|
|
44
|
-
return
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
55
|
-
<
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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:
|
|
80
|
-
|
|
81
|
-
will-change: transform, opacity;
|
|
140
|
+
transform: scale(0.85);
|
|
141
|
+
transition: opacity 200ms ease, transform 200ms ease;
|
|
82
142
|
}
|
|
83
143
|
|
|
84
|
-
.
|
|
85
|
-
.native-ptr
|
|
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-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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>
|