@stacksjs/defaults 0.75.97 → 0.75.98
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 +160 -1
- package/resources/components/NativeContextMenu.stx +83 -0
- package/resources/components/NativeNavBar.stx +51 -11
- 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 +170 -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 } from '@stacksjs/mobile'
|
|
22
22
|
|
|
23
23
|
const isLarge = useReactiveProp<boolean>('large', true)
|
|
24
24
|
const onScroll = useReactiveProp<boolean>('titleOnScroll', false)
|
|
@@ -40,9 +40,22 @@ 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()
|
|
@@ -52,7 +65,7 @@ onDestroy(() => stopScroll?.())
|
|
|
52
65
|
|
|
53
66
|
const barClass = derived(() => {
|
|
54
67
|
const collapsed = (!isLarge() && !onScroll()) || scrolled()
|
|
55
|
-
return `native-nav-bar${collapsed ? ' is-collapsed' : ''}${scrolled() ? ' is-scrolled' : ''}`
|
|
68
|
+
return `native-nav-bar${isLarge() ? ' is-large' : ''}${collapsed ? ' is-collapsed' : ''}${scrolled() ? ' is-scrolled' : ''}`
|
|
56
69
|
})
|
|
57
70
|
|
|
58
71
|
function tapBack(event: MouseEvent): void {
|
|
@@ -63,12 +76,12 @@ function tapBack(event: MouseEvent): void {
|
|
|
63
76
|
|
|
64
77
|
<!-- `display: contents` on this box and on stx's own wrapper: a sticky element
|
|
65
78
|
sticks within its parent, and either box would give the bar no room. -->
|
|
66
|
-
<div class="native-nav" data-native-nav-bar>
|
|
79
|
+
<div ref="nativeNav" class="native-nav" data-native-nav-bar>
|
|
67
80
|
<header :class="barClass()">
|
|
68
81
|
<div class="native-nav-side">
|
|
69
82
|
@if(back)
|
|
70
83
|
<a href="{{ back }}" class="native-nav-back" :aria-label="backText()" draggable="false" @click="tapBack">
|
|
71
|
-
<
|
|
84
|
+
<NativeSymbol name="chevron.backward" icon="i-lucide-chevron-left" weight="semibold" class="native-nav-back-icon" />
|
|
72
85
|
<span class="native-nav-back-label">{{ backText() }}</span>
|
|
73
86
|
</a>
|
|
74
87
|
@endif
|
|
@@ -80,7 +93,7 @@ function tapBack(event: MouseEvent): void {
|
|
|
80
93
|
</div>
|
|
81
94
|
</header>
|
|
82
95
|
@if(large)
|
|
83
|
-
<h1 class="native-nav-large-title" :class="navTitle() ? '' : 'is-empty'">{{ navTitle() }}</h1>
|
|
96
|
+
<h1 ref="nativeNavLargeTitle" class="native-nav-large-title" :class="navTitle() ? '' : 'is-empty'">{{ navTitle() }}</h1>
|
|
84
97
|
@endif
|
|
85
98
|
<slot />
|
|
86
99
|
</div>
|
|
@@ -102,6 +115,9 @@ function tapBack(event: MouseEvent): void {
|
|
|
102
115
|
align-items: center;
|
|
103
116
|
min-height: 2.75rem;
|
|
104
117
|
padding: 0 0.75rem;
|
|
118
|
+
/* Dynamic Type, capped as UIKit caps its bars: a bar's text stops
|
|
119
|
+
growing well before the content's does. */
|
|
120
|
+
font-size: min(var(--native-font-size, 17px), 23px);
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
/* The frosted material is always there and only fades. Adding or removing
|
|
@@ -125,6 +141,13 @@ function tapBack(event: MouseEvent): void {
|
|
|
125
141
|
|
|
126
142
|
.native-nav-bar.is-scrolled::before { opacity: 1; }
|
|
127
143
|
|
|
144
|
+
/* Scroll-linked once the page has run: the material comes in with the
|
|
145
|
+
scroll itself, no transition. */
|
|
146
|
+
.native-nav[style*="--native-nav-progress"] .native-nav-bar::before {
|
|
147
|
+
opacity: max(0.001, min(1, var(--native-nav-progress) * 1.5));
|
|
148
|
+
transition: none;
|
|
149
|
+
}
|
|
150
|
+
|
|
128
151
|
.dark .native-nav-bar::before {
|
|
129
152
|
border-color: var(--native-bar-border-dark, rgb(51 65 85 / 0.6));
|
|
130
153
|
background: var(--native-bar-background-dark, rgb(2 6 23 / 0.86));
|
|
@@ -139,7 +162,7 @@ function tapBack(event: MouseEvent): void {
|
|
|
139
162
|
.native-nav-title {
|
|
140
163
|
max-width: 60vw;
|
|
141
164
|
overflow: hidden;
|
|
142
|
-
font-size:
|
|
165
|
+
font-size: 1em;
|
|
143
166
|
font-weight: 600;
|
|
144
167
|
text-overflow: ellipsis;
|
|
145
168
|
white-space: nowrap;
|
|
@@ -150,6 +173,14 @@ function tapBack(event: MouseEvent): void {
|
|
|
150
173
|
|
|
151
174
|
.native-nav-bar.is-collapsed .native-nav-title { opacity: 1; transform: none; }
|
|
152
175
|
|
|
176
|
+
/* Under a large title the small one rises into the bar with the scroll,
|
|
177
|
+
appearing over the last part of it. */
|
|
178
|
+
.native-nav[style*="--native-nav-progress"] .native-nav-bar.is-large .native-nav-title {
|
|
179
|
+
opacity: clamp(0, calc((var(--native-nav-progress) - 0.6) * 2.5), 1);
|
|
180
|
+
transform: translateY(calc((1 - var(--native-nav-progress)) * 0.5em));
|
|
181
|
+
transition: none;
|
|
182
|
+
}
|
|
183
|
+
|
|
153
184
|
/* A title that is not showing takes no room from the sides either. */
|
|
154
185
|
.native-nav-bar:not(.is-collapsed) .native-nav-title { max-width: 0; }
|
|
155
186
|
|
|
@@ -162,14 +193,16 @@ function tapBack(event: MouseEvent): void {
|
|
|
162
193
|
margin-left: -0.4rem;
|
|
163
194
|
padding-right: 0.5rem;
|
|
164
195
|
color: var(--native-accent, rgb(37 99 235));
|
|
165
|
-
font-size:
|
|
196
|
+
font-size: 1em;
|
|
166
197
|
text-decoration: none;
|
|
167
198
|
-webkit-touch-callout: none;
|
|
168
199
|
}
|
|
169
200
|
|
|
170
201
|
.dark .native-nav-back { color: var(--native-accent-dark, rgb(96 165 250)); }
|
|
171
202
|
.native-nav-back:active { opacity: 0.55; }
|
|
172
|
-
.
|
|
203
|
+
/* SF's chevron.backward at the bar's text size, drawn a little heavier and
|
|
204
|
+
larger than the label as UIKit draws it. */
|
|
205
|
+
.native-nav-back-icon { flex-shrink: 0; width: 1.35em; height: 1.35em; }
|
|
173
206
|
.native-nav-back-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
174
207
|
/* Where the centred title leaves too little room for a word, the chevron
|
|
175
208
|
alone, as on iOS: at 4.5rem a long title still left "Wo…" for "Workout"
|
|
@@ -183,16 +216,23 @@ function tapBack(event: MouseEvent): void {
|
|
|
183
216
|
.native-nav-large-title {
|
|
184
217
|
margin: 0.125rem 0 0.5rem;
|
|
185
218
|
padding: 0 1rem;
|
|
186
|
-
|
|
219
|
+
/* 34pt at the default text size: twice the body, as on iOS. */
|
|
220
|
+
font-size: calc(var(--native-font-size, 17px) * 2);
|
|
187
221
|
font-weight: 700;
|
|
188
222
|
line-height: 1.15;
|
|
189
223
|
letter-spacing: -0.02em;
|
|
224
|
+
/* Pulled past the top, it grows from its leading edge. */
|
|
225
|
+
transform: scale(var(--native-nav-stretch, 1));
|
|
226
|
+
transform-origin: 0 50%;
|
|
190
227
|
}
|
|
191
228
|
|
|
192
229
|
@media (prefers-reduced-motion: reduce) {
|
|
193
230
|
.native-nav-bar::before, .native-nav-title { transition: none; }
|
|
231
|
+
.native-nav-large-title { transform: none; }
|
|
194
232
|
}
|
|
195
233
|
|
|
234
|
+
:root[data-native-reduce-motion] .native-nav-large-title { transform: none; }
|
|
235
|
+
|
|
196
236
|
@media (prefers-reduced-transparency: reduce) {
|
|
197
237
|
.native-nav-bar::before { backdrop-filter: none; -webkit-backdrop-filter: none; background: var(--native-bar-solid, rgb(248 250 252)); }
|
|
198
238
|
.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>
|