@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
|
@@ -1,40 +1,144 @@
|
|
|
1
1
|
<script client>
|
|
2
|
-
import {
|
|
2
|
+
import type { SheetDetent } from '@stacksjs/mobile'
|
|
3
|
+
import { liftToBody, lockBackground, observeSheetDrag, presentPageAsCard, trapFocus } from '@stacksjs/mobile'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* A bottom sheet: a task that belongs to this screen, raised over it.
|
|
6
7
|
*
|
|
7
|
-
* Bind it with `v-model:open`. It
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Bind it with `v-model:open`. It closes from its backdrop, its Done button,
|
|
9
|
+
* Escape, or a drag down from its grabber or header (a flick, or past half
|
|
10
|
+
* way; a shorter drag springs back). While it is up the page behind it is
|
|
11
|
+
* inert and still, and focus stays inside it.
|
|
12
|
+
*
|
|
13
|
+
* `detents="medium large"` makes it a full-height sheet that rests at half
|
|
14
|
+
* the screen or all of it and is dragged between them, the first named being
|
|
15
|
+
* where it opens. At large the page behind shrinks back into a card, as iOS
|
|
16
|
+
* presents a page sheet. Without `detents` it is as tall as its content.
|
|
10
17
|
*/
|
|
11
18
|
const emit = defineEmits()
|
|
12
19
|
const open = useModel('open', { default: false })
|
|
13
20
|
const title = useReactiveProp<string>('title', '')
|
|
14
21
|
const doneLabel = useReactiveProp<string>('doneLabel', 'Done')
|
|
22
|
+
const detentsProp = useReactiveProp<SheetDetent[] | string>('detents', '')
|
|
23
|
+
|
|
24
|
+
const detents = derived<SheetDetent[]>(() => {
|
|
25
|
+
const raw = detentsProp()
|
|
26
|
+
const list = Array.isArray(raw) ? raw : String(raw || '').split(/[\s,]+/)
|
|
27
|
+
const valid = list.filter((value): value is SheetDetent => value === 'medium' || value === 'large')
|
|
28
|
+
return valid.length ? valid : ['large']
|
|
29
|
+
})
|
|
30
|
+
const hasDetents = derived(() => Boolean(detentsProp()) && detentsProp().length > 0)
|
|
31
|
+
const detent = state<SheetDetent>('large')
|
|
32
|
+
|
|
33
|
+
const sheetRoot = useRef('nativeSheet')
|
|
34
|
+
const panel = useRef('nativeSheetPanel')
|
|
35
|
+
const grabber = useRef('nativeSheetGrabber')
|
|
36
|
+
const header = useRef('nativeSheetHeader')
|
|
37
|
+
|
|
38
|
+
let releaseBackground: (() => void) | null = null
|
|
39
|
+
let putBack: (() => void) | null = null
|
|
40
|
+
let putBackTimer: ReturnType<typeof setTimeout> | null = null
|
|
41
|
+
let previousFocus: HTMLElement | null = null
|
|
42
|
+
let carded = false
|
|
43
|
+
let stopDrag: (() => void) | null = null
|
|
44
|
+
|
|
45
|
+
function root(): HTMLElement | null {
|
|
46
|
+
return sheetRoot.current as HTMLElement | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function present(): void {
|
|
50
|
+
const element = root()
|
|
51
|
+
if (!element || releaseBackground) return
|
|
52
|
+
if (putBackTimer) clearTimeout(putBackTimer)
|
|
53
|
+
putBackTimer = null
|
|
54
|
+
detent.set(detents()[0] ?? 'large')
|
|
55
|
+
previousFocus = element.ownerDocument.activeElement as HTMLElement | null
|
|
56
|
+
// Out of the page's stacking contexts and transforms (the card below
|
|
57
|
+
// scales the page), and everything else made inert behind it.
|
|
58
|
+
putBack ??= liftToBody(element)
|
|
59
|
+
releaseBackground = lockBackground(element)
|
|
60
|
+
void nextTick(() => (panel.current as HTMLElement | null)?.focus())
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function dismissed(): void {
|
|
64
|
+
releaseBackground?.()
|
|
65
|
+
releaseBackground = null
|
|
66
|
+
previousFocus?.focus?.()
|
|
67
|
+
previousFocus = null
|
|
68
|
+
// Back where it was written once it has slid away.
|
|
69
|
+
putBackTimer = setTimeout(() => {
|
|
70
|
+
putBackTimer = null
|
|
71
|
+
putBack?.()
|
|
72
|
+
putBack = null
|
|
73
|
+
}, 420)
|
|
74
|
+
}
|
|
15
75
|
|
|
16
76
|
let wasOpen = false
|
|
17
77
|
effect(() => {
|
|
18
78
|
const isOpen = open()
|
|
19
|
-
if (isOpen && !wasOpen)
|
|
79
|
+
if (isOpen && !wasOpen) present()
|
|
80
|
+
else if (!isOpen && wasOpen) dismissed()
|
|
20
81
|
wasOpen = isOpen
|
|
21
82
|
})
|
|
22
83
|
|
|
84
|
+
// The page behind becomes a card while a full-height sheet is at large. Only
|
|
85
|
+
// this sheet's own change is applied, so a closed sheet never undoes an open
|
|
86
|
+
// one's card.
|
|
87
|
+
effect(() => {
|
|
88
|
+
const element = root()
|
|
89
|
+
const want = open() && hasDetents() && detent() === 'large'
|
|
90
|
+
if (!element || want === carded) return
|
|
91
|
+
carded = want
|
|
92
|
+
presentPageAsCard(element.ownerDocument, want)
|
|
93
|
+
})
|
|
94
|
+
|
|
23
95
|
function close(): void {
|
|
24
96
|
open.set(false)
|
|
25
97
|
emit('close')
|
|
26
98
|
}
|
|
27
99
|
|
|
100
|
+
onMount(() => {
|
|
101
|
+
const sheetPanel = panel.current as HTMLElement | null
|
|
102
|
+
if (!sheetPanel) return
|
|
103
|
+
const handles = [grabber.current, header.current].filter(Boolean) as HTMLElement[]
|
|
104
|
+
stopDrag = observeSheetDrag({
|
|
105
|
+
panel: sheetPanel,
|
|
106
|
+
handles,
|
|
107
|
+
detents: () => detents(),
|
|
108
|
+
detent: () => detent(),
|
|
109
|
+
onSettle: (to) => {
|
|
110
|
+
if (to === 'dismiss') close()
|
|
111
|
+
else detent.set(to)
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
if (open()) present()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
onDestroy(() => {
|
|
118
|
+
stopDrag?.()
|
|
119
|
+
releaseBackground?.()
|
|
120
|
+
const element = root()
|
|
121
|
+
if (carded && element) presentPageAsCard(element.ownerDocument, false)
|
|
122
|
+
if (putBackTimer) clearTimeout(putBackTimer)
|
|
123
|
+
putBack?.()
|
|
124
|
+
})
|
|
125
|
+
|
|
28
126
|
useEventListener('keydown', (event: KeyboardEvent) => {
|
|
29
|
-
if (
|
|
127
|
+
if (!open()) return
|
|
128
|
+
if (event.key === 'Escape') close()
|
|
129
|
+
else if (panel.current) trapFocus(panel.current as HTMLElement, event)
|
|
30
130
|
})
|
|
131
|
+
|
|
132
|
+
const sheetClass = derived(() => `${open() ? 'is-open' : ''}${hasDetents() ? ' has-detents' : ''}${detent() === 'medium' ? ' is-medium' : ''}`.trim())
|
|
31
133
|
</script>
|
|
32
134
|
|
|
33
|
-
<div class="native-sheet" :class="
|
|
135
|
+
<div ref="nativeSheet" class="native-sheet" :class="sheetClass()" :aria-hidden="open() ? 'false' : 'true'" data-native-sheet>
|
|
34
136
|
<div class="native-sheet-backdrop" @click="close()"></div>
|
|
35
|
-
<section class="native-sheet-panel" role="dialog" aria-modal="true" :aria-label="title()">
|
|
36
|
-
<
|
|
37
|
-
|
|
137
|
+
<section ref="nativeSheetPanel" class="native-sheet-panel" role="dialog" aria-modal="true" :aria-label="title()" tabindex="-1">
|
|
138
|
+
<div ref="nativeSheetGrabber" class="native-sheet-grabber-area">
|
|
139
|
+
<span aria-hidden="true" class="native-sheet-grabber"></span>
|
|
140
|
+
</div>
|
|
141
|
+
<header ref="nativeSheetHeader" class="native-sheet-header">
|
|
38
142
|
<h2 class="native-sheet-title">{{ title() }}</h2>
|
|
39
143
|
<button type="button" class="native-sheet-done" @click="close()">{{ doneLabel() }}</button>
|
|
40
144
|
</header>
|
|
@@ -51,6 +155,8 @@ useEventListener('keydown', (event: KeyboardEvent) => {
|
|
|
51
155
|
z-index: 70;
|
|
52
156
|
visibility: hidden;
|
|
53
157
|
pointer-events: none;
|
|
158
|
+
font-size: var(--native-font-size, 17px);
|
|
159
|
+
-webkit-tap-highlight-color: transparent;
|
|
54
160
|
}
|
|
55
161
|
|
|
56
162
|
.native-sheet.is-open { visibility: visible; pointer-events: auto; }
|
|
@@ -63,7 +169,8 @@ useEventListener('keydown', (event: KeyboardEvent) => {
|
|
|
63
169
|
transition: opacity 240ms ease;
|
|
64
170
|
}
|
|
65
171
|
|
|
66
|
-
|
|
172
|
+
/* Dragged, the dimming follows the panel (--native-sheet-presence, 1 to 0). */
|
|
173
|
+
.native-sheet.is-open .native-sheet-backdrop { opacity: var(--native-sheet-presence, 1); }
|
|
67
174
|
|
|
68
175
|
.native-sheet-panel {
|
|
69
176
|
position: absolute;
|
|
@@ -75,20 +182,41 @@ useEventListener('keydown', (event: KeyboardEvent) => {
|
|
|
75
182
|
max-height: calc(100dvh - env(safe-area-inset-top, 0px) - 1rem);
|
|
76
183
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
77
184
|
border-radius: 1.25rem 1.25rem 0 0;
|
|
185
|
+
outline: none;
|
|
78
186
|
background: var(--native-sheet-background, rgb(248 250 252));
|
|
79
187
|
box-shadow: 0 -12px 40px rgb(2 6 23 / 0.18);
|
|
80
188
|
transform: translateY(105%);
|
|
81
|
-
transition: transform
|
|
189
|
+
transition: transform 380ms cubic-bezier(0.32, 0.72, 0, 1);
|
|
190
|
+
touch-action: pan-y;
|
|
82
191
|
}
|
|
83
192
|
|
|
84
193
|
.dark .native-sheet-panel { background: var(--native-sheet-background-dark, rgb(15 23 42)); }
|
|
85
194
|
.native-sheet.is-open .native-sheet-panel { transform: none; }
|
|
86
195
|
|
|
196
|
+
/* Following the finger: no easing between where it was and where it is. */
|
|
197
|
+
.native-sheet-panel.is-dragging { transition: none; }
|
|
198
|
+
|
|
199
|
+
/* A sheet with detents is full height, and rests with half the screen's
|
|
200
|
+
worth showing at medium. */
|
|
201
|
+
.native-sheet.has-detents .native-sheet-panel {
|
|
202
|
+
height: calc(100dvh - env(safe-area-inset-top, 0px) - 0.625rem);
|
|
203
|
+
max-height: none;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.native-sheet.is-open.is-medium .native-sheet-panel { transform: translateY(calc(100% - 50dvh)); }
|
|
207
|
+
|
|
208
|
+
/* The grabber's area is a handle, generous for a thumb. */
|
|
209
|
+
.native-sheet-grabber-area {
|
|
210
|
+
display: flex;
|
|
211
|
+
justify-content: center;
|
|
212
|
+
padding: 0.4rem 0 0.15rem;
|
|
213
|
+
touch-action: none;
|
|
214
|
+
cursor: grab;
|
|
215
|
+
}
|
|
216
|
+
|
|
87
217
|
.native-sheet-grabber {
|
|
88
|
-
align-self: center;
|
|
89
218
|
width: 2.25rem;
|
|
90
219
|
height: 0.3rem;
|
|
91
|
-
margin-top: 0.4rem;
|
|
92
220
|
border-radius: 999px;
|
|
93
221
|
background: rgb(148 163 184 / 0.55);
|
|
94
222
|
}
|
|
@@ -99,17 +227,19 @@ useEventListener('keydown', (event: KeyboardEvent) => {
|
|
|
99
227
|
align-items: center;
|
|
100
228
|
justify-content: space-between;
|
|
101
229
|
padding: 0.5rem 1rem 0.25rem;
|
|
230
|
+
touch-action: none;
|
|
102
231
|
}
|
|
103
232
|
|
|
104
|
-
.native-sheet-title { font-size:
|
|
233
|
+
.native-sheet-title { font-size: 1em; font-weight: 600; }
|
|
105
234
|
|
|
106
235
|
.native-sheet-done {
|
|
107
236
|
min-height: 2.75rem;
|
|
108
237
|
border: 0;
|
|
109
238
|
color: var(--native-accent, rgb(37 99 235));
|
|
110
239
|
background: transparent;
|
|
111
|
-
font-size:
|
|
240
|
+
font-size: 1em;
|
|
112
241
|
font-weight: 600;
|
|
242
|
+
touch-action: manipulation;
|
|
113
243
|
}
|
|
114
244
|
|
|
115
245
|
.dark .native-sheet-done { color: var(--native-accent-dark, rgb(96 165 250)); }
|
|
@@ -120,10 +250,33 @@ useEventListener('keydown', (event: KeyboardEvent) => {
|
|
|
120
250
|
overscroll-behavior: contain;
|
|
121
251
|
}
|
|
122
252
|
|
|
253
|
+
.native-sheet.has-detents .native-sheet-body { flex: 1; }
|
|
254
|
+
|
|
255
|
+
/* The page behind a full-height sheet at large: pressed back into a card
|
|
256
|
+
with rounded corners, centred on what was in view, on black. The
|
|
257
|
+
content scales rather than the shell, whose fixed tab bar a transform
|
|
258
|
+
would unpin. */
|
|
259
|
+
.native-app-content {
|
|
260
|
+
transition: transform 380ms cubic-bezier(0.32, 0.72, 0, 1), border-radius 380ms cubic-bezier(0.32, 0.72, 0, 1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
:root[data-native-sheet-card] body { background: #000; }
|
|
264
|
+
|
|
265
|
+
:root[data-native-sheet-card] .native-app-content {
|
|
266
|
+
overflow: hidden;
|
|
267
|
+
border-radius: 0.75rem;
|
|
268
|
+
transform: scale(0.93);
|
|
269
|
+
transform-origin: 50% var(--native-sheet-card-origin, 50vh);
|
|
270
|
+
}
|
|
271
|
+
|
|
123
272
|
@media (prefers-reduced-motion: reduce) {
|
|
124
|
-
.native-sheet-backdrop, .native-sheet-panel { transition: none; }
|
|
273
|
+
.native-sheet-backdrop, .native-sheet-panel, .native-app-content { transition: none; }
|
|
274
|
+
:root[data-native-sheet-card] .native-app-content { transform: none; border-radius: 0; }
|
|
125
275
|
}
|
|
126
276
|
|
|
277
|
+
:root[data-native-reduce-motion] .native-sheet-panel { transition: none; }
|
|
278
|
+
:root[data-native-reduce-motion][data-native-sheet-card] .native-app-content { transform: none; border-radius: 0; }
|
|
279
|
+
|
|
127
280
|
/* The router names the page's <main> for view transitions, which makes it a
|
|
128
281
|
stacking context: a sheet inside it stacked under the tab bar whatever its
|
|
129
282
|
z-index. While a sheet is open <main> gives the name up. Nothing navigates
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<script server>
|
|
2
|
+
interface NativeSymbolProps {
|
|
3
|
+
/** The SF Symbol, e.g. `chevron.backward`. */
|
|
4
|
+
name: string
|
|
5
|
+
/** The Iconify class drawn where there are no SF Symbols (a browser, an older shell). */
|
|
6
|
+
icon?: string
|
|
7
|
+
/** The point size; the surrounding text's size when left out. */
|
|
8
|
+
size?: number
|
|
9
|
+
weight?: 'ultralight' | 'thin' | 'light' | 'regular' | 'medium' | 'semibold' | 'bold' | 'heavy' | 'black'
|
|
10
|
+
/** What the symbol says, for a symbol that stands alone. Decorative when left out. */
|
|
11
|
+
label?: string
|
|
12
|
+
class?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The caller's classes are rendered here too, so the symbol has its size
|
|
16
|
+
// from the first paint rather than once the page has run.
|
|
17
|
+
const { name = '', icon = '', label = '', class: extraClass = '' } = defineProps<NativeSymbolProps>()
|
|
18
|
+
|
|
19
|
+
// The common symbols' Iconify stand-ins, written out here so the stylesheet
|
|
20
|
+
// generator sees every class. Anything else names its own with `icon`.
|
|
21
|
+
const standIns: Record<string, string> = {
|
|
22
|
+
'chevron.backward': 'i-lucide-chevron-left',
|
|
23
|
+
'chevron.left': 'i-lucide-chevron-left',
|
|
24
|
+
'chevron.forward': 'i-lucide-chevron-right',
|
|
25
|
+
'chevron.right': 'i-lucide-chevron-right',
|
|
26
|
+
'chevron.down': 'i-lucide-chevron-down',
|
|
27
|
+
'chevron.up': 'i-lucide-chevron-up',
|
|
28
|
+
'xmark': 'i-lucide-x',
|
|
29
|
+
'plus': 'i-lucide-plus',
|
|
30
|
+
'minus': 'i-lucide-minus',
|
|
31
|
+
'checkmark': 'i-lucide-check',
|
|
32
|
+
'ellipsis': 'i-lucide-ellipsis',
|
|
33
|
+
'ellipsis.circle': 'i-lucide-circle-ellipsis',
|
|
34
|
+
'square.and.arrow.up': 'i-lucide-share',
|
|
35
|
+
'trash': 'i-lucide-trash-2',
|
|
36
|
+
'pencil': 'i-lucide-pencil',
|
|
37
|
+
'doc.on.doc': 'i-lucide-copy',
|
|
38
|
+
'link': 'i-lucide-link',
|
|
39
|
+
'magnifyingglass': 'i-lucide-search',
|
|
40
|
+
'gearshape': 'i-lucide-settings',
|
|
41
|
+
'person.crop.circle': 'i-lucide-circle-user',
|
|
42
|
+
'bell': 'i-lucide-bell',
|
|
43
|
+
'heart': 'i-lucide-heart',
|
|
44
|
+
'star': 'i-lucide-star',
|
|
45
|
+
'bookmark': 'i-lucide-bookmark',
|
|
46
|
+
'calendar': 'i-lucide-calendar',
|
|
47
|
+
'house': 'i-lucide-house',
|
|
48
|
+
'info.circle': 'i-lucide-info',
|
|
49
|
+
'arrow.clockwise': 'i-lucide-rotate-cw',
|
|
50
|
+
'play.fill': 'i-lucide-play',
|
|
51
|
+
'pause.fill': 'i-lucide-pause',
|
|
52
|
+
}
|
|
53
|
+
const standIn = icon || standIns[name] || 'i-lucide-circle'
|
|
54
|
+
const classes = ['native-symbol', standIn, extraClass].filter(Boolean).join(' ')
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<script client>
|
|
58
|
+
import type { SymbolOptions } from '@stacksjs/mobile'
|
|
59
|
+
import { symbols } from '@stacksjs/mobile'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* An SF Symbol, drawn by the phone (`craft.symbols.image`) and painted in the
|
|
63
|
+
* text colour like any icon, so it follows the theme and the tint. Until the
|
|
64
|
+
* shell has drawn it, and wherever it cannot, the Iconify stand-in shows.
|
|
65
|
+
* Drawn symbols are kept in memory, so the next screen's bar has its chevron
|
|
66
|
+
* from the first frame.
|
|
67
|
+
*/
|
|
68
|
+
const symbolName = useReactiveProp<string>('name', '')
|
|
69
|
+
const size = useReactiveProp<number>('size', 0)
|
|
70
|
+
const weight = useReactiveProp<SymbolOptions['weight']>('weight', 'regular')
|
|
71
|
+
const className = useReactiveProp<string>('class', '')
|
|
72
|
+
const image = state<string | null>(null)
|
|
73
|
+
|
|
74
|
+
effect(() => {
|
|
75
|
+
const name = symbolName()
|
|
76
|
+
if (!name) return
|
|
77
|
+
const options: SymbolOptions = { pointSize: Number(size()) || 17, weight: weight() }
|
|
78
|
+
const known = symbols.cached(name, options)
|
|
79
|
+
if (known !== undefined) {
|
|
80
|
+
image.set(known)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
void symbols.image(name, options).then((url) => {
|
|
84
|
+
if (symbolName() === name) image.set(url)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// One class binding: the drawn symbol and the caller's classes together.
|
|
89
|
+
const symbolClass = derived(() => `${image() ? 'is-symbol ' : ''}${className()}`.trim())
|
|
90
|
+
const symbolStyle = derived(() => {
|
|
91
|
+
const px = Number(size()) || 0
|
|
92
|
+
return `${image() ? `--native-symbol:url("${image()}");` : ''}${px ? `font-size:${px}px;` : ''}`
|
|
93
|
+
})
|
|
94
|
+
</script>
|
|
95
|
+
|
|
96
|
+
@if(label)
|
|
97
|
+
<i class="{{ classes }}" :class="symbolClass()" :style="symbolStyle()" role="img" aria-label="{{ label }}" data-native-symbol="{{ name }}"></i>
|
|
98
|
+
@else
|
|
99
|
+
<i class="{{ classes }}" :class="symbolClass()" :style="symbolStyle()" aria-hidden="true" data-native-symbol="{{ name }}"></i>
|
|
100
|
+
@endif
|
|
101
|
+
|
|
102
|
+
<style>
|
|
103
|
+
/* stx's box around the component steps aside, so the symbol sits in the
|
|
104
|
+
line (or the flex row) it was written in. */
|
|
105
|
+
[data-stx-scope]:has(> .native-symbol) { display: contents; }
|
|
106
|
+
|
|
107
|
+
.native-symbol {
|
|
108
|
+
display: inline-block;
|
|
109
|
+
flex-shrink: 0;
|
|
110
|
+
width: 1em;
|
|
111
|
+
height: 1em;
|
|
112
|
+
vertical-align: -0.125em;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* The drawn symbol as a mask over the text colour, in place of the stand-in. */
|
|
116
|
+
.native-symbol.is-symbol {
|
|
117
|
+
background-color: currentColor;
|
|
118
|
+
-webkit-mask: var(--native-symbol) center / contain no-repeat;
|
|
119
|
+
mask: var(--native-symbol) center / contain no-repeat;
|
|
120
|
+
}
|
|
121
|
+
</style>
|
|
@@ -7,24 +7,36 @@ const { label = 'App navigation' } = defineProps<NativeTabBarProps>()
|
|
|
7
7
|
</script>
|
|
8
8
|
|
|
9
9
|
<script client>
|
|
10
|
-
import { haptics, mirrorTabBar } from '@stacksjs/mobile'
|
|
10
|
+
import { handleTabReselect, haptics, mirrorTabBar } from '@stacksjs/mobile'
|
|
11
11
|
|
|
12
12
|
// Inside Craft's iOS shell the phone draws this bar natively (Liquid Glass on
|
|
13
13
|
// iOS 26 and later) and these links stay as its model: the router keeps the
|
|
14
14
|
// current one marked, and a native tap comes back as a click on one of them.
|
|
15
|
+
//
|
|
16
|
+
// The tabs are the stx router's (`data-stx-nav="tab"` on each link): each
|
|
17
|
+
// keeps its own screens and history, a switch adds no back entry, and
|
|
18
|
+
// choosing the current tab again scrolls it to the top, then pops it to its
|
|
19
|
+
// root, announcing `stx:tabreselect`. A native tap reaches the router's
|
|
20
|
+
// selectTab(). handleTabReselect covers a router that predates tabs.
|
|
15
21
|
const bar = useRef('nativeTabBar')
|
|
16
22
|
let mirrored = false
|
|
17
23
|
let stopMirror: (() => void) | null = null
|
|
24
|
+
let stopReselect: (() => void) | null = null
|
|
18
25
|
onMount(() => {
|
|
19
26
|
const nav = bar.current as HTMLElement | null
|
|
20
27
|
if (!nav) return
|
|
21
28
|
stopMirror = mirrorTabBar(nav)
|
|
22
29
|
mirrored = stopMirror !== null
|
|
30
|
+
stopReselect = handleTabReselect(nav)
|
|
31
|
+
})
|
|
32
|
+
onDestroy(() => {
|
|
33
|
+
stopMirror?.()
|
|
34
|
+
stopReselect?.()
|
|
23
35
|
})
|
|
24
|
-
onDestroy(() => stopMirror?.())
|
|
25
36
|
|
|
26
37
|
function selectTab(): void {
|
|
27
|
-
//
|
|
38
|
+
// Only the page's own bar: the phone's native bar plays its own haptic,
|
|
39
|
+
// and a tap on it comes back without one.
|
|
28
40
|
if (!mirrored) void haptics.selection()
|
|
29
41
|
}
|
|
30
42
|
</script>
|
|
@@ -32,7 +32,10 @@ const hasBadge = badge !== '' && badge !== 0 && badge !== '0'
|
|
|
32
32
|
swapped without the router's cross-fade, which iOS never draws between
|
|
33
33
|
tabs (it read as a quarter-second lag on every tap). `data-native-tab`
|
|
34
34
|
and `data-native-tab-symbol` describe the tab to the phone's native bar
|
|
35
|
-
(@stacksjs/mobile mirrorTabBar).
|
|
35
|
+
(@stacksjs/mobile mirrorTabBar). `data-stx-nav="tab"`: the stx router
|
|
36
|
+
treats it as a tab, with its own retained screens and history, no back
|
|
37
|
+
entry for a switch, and re-selection scrolling to the top, then popping
|
|
38
|
+
to the tab's root. -->
|
|
36
39
|
<StxLink
|
|
37
40
|
to="{{ href }}"
|
|
38
41
|
aria-label="{{ label }}"
|
|
@@ -42,6 +45,7 @@ const hasBadge = badge !== '' && badge !== 0 && badge !== '0'
|
|
|
42
45
|
draggable="false"
|
|
43
46
|
data-stx-prefetch="eager"
|
|
44
47
|
data-stx-transition="none"
|
|
48
|
+
data-stx-nav="tab"
|
|
45
49
|
data-native-tab="{{ label }}"
|
|
46
50
|
data-native-tab-symbol="{{ symbol }}"
|
|
47
51
|
>
|
|
@@ -12,7 +12,9 @@ export { default as DashboardNavbar } from './Dashboard/Navbar.stx'
|
|
|
12
12
|
export * from './Dashboard'
|
|
13
13
|
export { default as Audio } from './Audio.stx'
|
|
14
14
|
export { default as Image } from './Image.stx'
|
|
15
|
+
export { default as NativeActionSheet } from './NativeActionSheet.stx'
|
|
15
16
|
export { default as NativeAppShell } from './NativeAppShell.stx'
|
|
17
|
+
export { default as NativeContextMenu } from './NativeContextMenu.stx'
|
|
16
18
|
export { default as NativeHealthButton } from './NativeHealthButton.stx'
|
|
17
19
|
export { default as NativeNavBar } from './NativeNavBar.stx'
|
|
18
20
|
export { default as NativeNetworkBanner } from './NativeNetworkBanner.stx'
|
|
@@ -22,6 +24,7 @@ export { default as NativePullToRefresh } from './NativePullToRefresh.stx'
|
|
|
22
24
|
export { default as NativeSegmentedControl } from './NativeSegmentedControl.stx'
|
|
23
25
|
export { default as NativeShareButton } from './NativeShareButton.stx'
|
|
24
26
|
export { default as NativeSheet } from './NativeSheet.stx'
|
|
27
|
+
export { default as NativeSymbol } from './NativeSymbol.stx'
|
|
25
28
|
export { default as NativeTabBar } from './NativeTabBar.stx'
|
|
26
29
|
export { default as NativeTabItem } from './NativeTabItem.stx'
|
|
27
30
|
export { default as Video } from './Video.stx'
|