@stacksjs/defaults 0.75.96 → 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/app/Actions/Dashboard/Feedback/feedback-token.test.ts +5 -1
- 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,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: stacks-dashboard
|
|
3
|
-
description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the
|
|
3
|
+
description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the 413 built-in dashboard components. Covers the dashboard system at storage/framework/defaults/.
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Bun >= 1.3.0, TypeScript
|
|
6
6
|
allowed-tools: Read Edit Write Bash Grep Glob
|
|
@@ -8,7 +8,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
|
|
|
8
8
|
|
|
9
9
|
# Stacks Dashboard
|
|
10
10
|
|
|
11
|
-
The Stacks admin dashboard provides a full-featured admin panel with 100+ route views,
|
|
11
|
+
The Stacks admin dashboard provides a full-featured admin panel with 100+ route views, 413 components, and a multi-section layout.
|
|
12
12
|
|
|
13
13
|
## Key Paths
|
|
14
14
|
- Dashboard components: `storage/framework/defaults/resources/components/Dashboard/`
|
|
@@ -96,6 +96,16 @@ Choose exactly one primary content source:
|
|
|
96
96
|
Only enable capabilities the product uses. Craft turns enabled capabilities
|
|
97
97
|
into native bridge availability and required iOS privacy descriptions.
|
|
98
98
|
|
|
99
|
+
The shell's behaviour around the page defaults to a native app's; set only
|
|
100
|
+
what should differ: `swipeBack` (`'router'`, the stx router's interactive
|
|
101
|
+
swipe, or `'webview'` for WebKit's history swipe), `keyboardAccessory` (off),
|
|
102
|
+
`allowsLinkPreview` (off), `disableZoom` (on), `splashMaxSeconds` (3),
|
|
103
|
+
`requestTimeoutSeconds` (10), `backgroundRefresh: { enabled, identifier?,
|
|
104
|
+
minimumIntervalMinutes? }` and `associatedDomains` (universal links). Leave
|
|
105
|
+
`swipeNavigation` unset: WebKit's swipe alongside the router's fights it, and
|
|
106
|
+
`buddy build:ios` warns. The Liquid Glass chrome needs iOS 26 at run time;
|
|
107
|
+
the default `deploymentTarget` stays, older systems drawing classic bars.
|
|
108
|
+
|
|
99
109
|
## Device search index
|
|
100
110
|
|
|
101
111
|
`spotlight` says what of the application's own content a device may index, so
|
|
@@ -166,7 +176,33 @@ await withNativeFeedback(() => saveActivity())
|
|
|
166
176
|
```
|
|
167
177
|
|
|
168
178
|
The runtime is browser-safe. Craft-backed operations use the native bridge;
|
|
169
|
-
supported web APIs provide fallback behavior outside a native host.
|
|
179
|
+
supported web APIs provide fallback behavior outside a native host. Inside a
|
|
180
|
+
native host whose bridge is still loading (an older shell installs it after
|
|
181
|
+
the page loads), a call waits for it briefly instead of taking the web path.
|
|
182
|
+
Nothing throws for being unsupported: it resolves `null` or `false`.
|
|
183
|
+
|
|
184
|
+
Beyond the services above, each with a web fallback:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import { browser, clipboard, confirmAction, contextMenu, db, dialog, files, haptics, orientation, shortcuts, storeKit, symbols, useActionSheet, widgets } from '@stacksjs/mobile'
|
|
188
|
+
|
|
189
|
+
await dialog.alert({ title: 'Saved' }) // UIAlertController, or an HTML alert
|
|
190
|
+
if (await confirmAction({ title: 'Delete this workout?', confirmLabel: 'Delete', destructive: true })) remove()
|
|
191
|
+
const id = await useActionSheet()({ actions: [{ id: 'skip', title: 'Skip' }] }) // null when cancelled
|
|
192
|
+
await contextMenu.show({ items, anchor }) // UIMenu, or an HTML menu over the blurred page
|
|
193
|
+
await browser.open(url, { mode: 'auth', callbackScheme: 'myapp' }) // ASWebAuthenticationSession
|
|
194
|
+
await symbols.image('heart.fill') // an SF Symbol as a PNG data URL, cached
|
|
195
|
+
await haptics.prepare('impact') // wake the Taptic Engine before a likely tap
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Also `statusBar`, `chrome` (under-page colour, keyboard accessory),
|
|
199
|
+
`refresh` (the native UIRefreshControl), `background.complete`, `clipboard`,
|
|
200
|
+
`db` (the app's SQLite), `files` (PDF, screenshot, QR scan, pick, save,
|
|
201
|
+
download), `shortcuts` (home screen quick actions), `widgets`,
|
|
202
|
+
`orientation`, `auth.signInWithApple` and `storeKit`. The phone's events:
|
|
203
|
+
`onAppearance`, `onResume`, `onMemoryWarning`, and `onSilentPush` /
|
|
204
|
+
`onBackgroundRefresh`, which tell iOS the work is done when the handler
|
|
205
|
+
settles.
|
|
170
206
|
|
|
171
207
|
For a run or ride, `createRouteRecorder({ location, onUpdate })` starts, pauses,
|
|
172
208
|
resumes and stops the native recording, re-attaches to one that outlived the
|
|
@@ -176,6 +212,12 @@ which ignores GPS drift, inaccurate fixes and the ground crossed during a pause.
|
|
|
176
212
|
## STX components
|
|
177
213
|
|
|
178
214
|
- `<NativeAppShell>` applies iOS safe-area insets and reserves tab-bar space.
|
|
215
|
+
In the phone app it sets the system font at the Dynamic Type size (the
|
|
216
|
+
components size in `em` off `--native-font-size`), drops the tap flash and
|
|
217
|
+
double-tap delay, gives `data-native-pressable` elements UIKit's pressed
|
|
218
|
+
state (and wakes the haptics as a finger lands), and keeps the under-page
|
|
219
|
+
colour, status bar, keyboard bar and Reduce Motion
|
|
220
|
+
(`data-native-reduce-motion` on the root) in step with the page.
|
|
179
221
|
- `<NativeTabBar>` provides the accessible navigation shell and selection haptics.
|
|
180
222
|
- `<NativeTabItem>` provides each route, active state, label, and Iconify icon.
|
|
181
223
|
- `<NativeShareButton>` opens the native share sheet and reports feedback.
|
|
@@ -183,15 +225,29 @@ which ignores GPS drift, inaccurate fixes and the ground crossed during a pause.
|
|
|
183
225
|
- `<NativePermissionButton>` wraps permission status, requests, haptics, and the native Settings escape hatch.
|
|
184
226
|
- `<NativeHealthButton>` requests the minimal Apple Health or Android Health Connect grants.
|
|
185
227
|
- `<NativeNavBar>` is the iOS navigation bar: a large title that collapses into
|
|
186
|
-
the bar
|
|
228
|
+
the bar with the scroll, a frame at a time (and stretches when pulled past
|
|
229
|
+
the top), a `chevron.backward` back button (`back="/parent"`) that goes back
|
|
187
230
|
through history when the app pushed the screen, and `leading` / `actions`
|
|
188
231
|
slots. Pass `:large="false"` on a pushed screen; `title` is reactive.
|
|
189
232
|
- `<NativePullToRefresh @refresh="reload">` refreshes the page when pulled from
|
|
190
|
-
its top
|
|
233
|
+
its top: the phone's own UIRefreshControl in the shell, the 12-spoke
|
|
234
|
+
indicator with the content held down elsewhere. The event carries `done()`:
|
|
235
|
+
call it when the new data is in. `scrollHost=".list"` for a screen that
|
|
236
|
+
scrolls in a container.
|
|
191
237
|
- `<NativeSegmentedControl :options="[...]" v-model:value="range">` switches
|
|
192
|
-
between views of one screen, with selection haptics
|
|
238
|
+
between views of one screen, with selection haptics, thumb scrubbing and
|
|
239
|
+
arrow keys.
|
|
193
240
|
- `<NativeSheet v-model:open="editing" title="…">` raises a bottom sheet for a
|
|
194
|
-
task that belongs to the screen
|
|
241
|
+
task that belongs to the screen: dragged down from its grabber or header to
|
|
242
|
+
dismiss, the page behind inert. `detents="medium large"` makes it full
|
|
243
|
+
height with detents, the page pressed into a card at large.
|
|
244
|
+
- `<NativeContextMenu :items="[...]" @select="…">` opens a menu on a
|
|
245
|
+
half-second press: UIMenu in the shell, an HTML menu elsewhere.
|
|
246
|
+
- `<NativeActionSheet v-model:open="choosing" :actions="[...]" @select="…">`
|
|
247
|
+
asks for a choice from the bottom; `confirmAction()` and `useActionSheet()`
|
|
248
|
+
do it from code.
|
|
249
|
+
- `<NativeSymbol name="heart.fill" icon="i-lucide-heart">` draws an SF Symbol
|
|
250
|
+
in the text colour, with an Iconify stand-in where there are none.
|
|
195
251
|
- `<NativeProgressRing :value="percent" :size="176">` fills a ring as something
|
|
196
252
|
completes (sets done, a countdown running out), with its content in the
|
|
197
253
|
slot. Colours follow `--native-ring` and `--native-ring-track`.
|
|
@@ -205,7 +261,9 @@ which ignores GPS drift, inaccurate fixes and the ground crossed during a pause.
|
|
|
205
261
|
|
|
206
262
|
`<NativeTabItem match="/m/workout">` keeps a tab lit on the detail screens
|
|
207
263
|
opened from it. A tab bar is a `<nav>`, where a link is otherwise current only
|
|
208
|
-
on its own page.
|
|
264
|
+
on its own page. Tab links are the stx router's tabs (`data-stx-nav="tab"`):
|
|
265
|
+
each keeps its own screens and history, and choosing the current one again
|
|
266
|
+
scrolls it to the top, then pops it to its root (`stx:tabreselect`).
|
|
209
267
|
|
|
210
268
|
Use Iconify classes for tab icons. Keep native operations inside reusable
|
|
211
269
|
components or TypeScript composables, never through `window.*` in an STX
|
|
@@ -216,8 +274,9 @@ script.
|
|
|
216
274
|
`ios.appearance: 'system'` follows the phone's Light/Dark setting (and the
|
|
217
275
|
page's `prefers-color-scheme` with it), with a status bar that reads on either;
|
|
218
276
|
`'light'` and `'dark'` pin one. `ios.backgroundColorDark` colours the launch
|
|
219
|
-
screen and webview in Dark Mode.
|
|
220
|
-
|
|
277
|
+
screen and webview in Dark Mode. An edge swipe goes back through the stx
|
|
278
|
+
router's own interactive swipe; WebKit's (`ios.swipeBack: 'webview'`) is only
|
|
279
|
+
for an app whose pages are not on the router.
|
|
221
280
|
|
|
222
281
|
Decide native-only chrome with `await whenNativeMobile()`, not
|
|
223
282
|
`isNativeMobile()` at setup: Craft installs its bridge after the page starts.
|
|
@@ -94,7 +94,11 @@ describe('authorizeFeedbackToken', () => {
|
|
|
94
94
|
|
|
95
95
|
it('refuses an expired token, counting the exact moment as expired', () => {
|
|
96
96
|
const now = new Date('2026-10-07T12:00:00.000Z')
|
|
97
|
-
|
|
97
|
+
// Measured from this test's `now`, not the clock: the shared PAST is a day
|
|
98
|
+
// before the real time, which passed this fixed moment on 2026-10-08 and
|
|
99
|
+
// turned "expired" into a token that expires tomorrow.
|
|
100
|
+
const past = new Date(now.getTime() - 86_400_000).toISOString()
|
|
101
|
+
expect(authorizeFeedbackToken({ boardId: 7, expiresAt: past }, now)).toEqual({ ok: false, reason: 'expired' })
|
|
98
102
|
expect(authorizeFeedbackToken({ boardId: 7, expiresAt: now.toISOString() }, now)).toEqual({ ok: false, reason: 'expired' })
|
|
99
103
|
})
|
|
100
104
|
|
package/ide/vscode/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"publisher": "Stacks",
|
|
3
3
|
"name": "vscode-stacks",
|
|
4
4
|
"displayName": "Stacks",
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.98",
|
|
6
6
|
"description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"funding": "https://github.com/sponsors/chrisbbreuer",
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.98",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@iconify-json/f7": "^1.2.2",
|
|
57
57
|
"@iconify-json/hugeicons": "^1.2.35",
|
|
58
|
-
"@stacksjs/mobile": "^0.75.
|
|
58
|
+
"@stacksjs/mobile": "^0.75.98",
|
|
59
59
|
"@stacksjs/sanitizer": "^0.2.406",
|
|
60
60
|
"@xterm/addon-fit": "^0.11.0",
|
|
61
61
|
"@xterm/xterm": "^6.0.0",
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
import type { DialogAction } from '@stacksjs/mobile'
|
|
3
|
+
import { dialog } from '@stacksjs/mobile'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An action sheet: a choice raised from the bottom of the screen, with Cancel
|
|
7
|
+
* set apart. In Craft's iOS shell it is the phone's own UIAlertController;
|
|
8
|
+
* elsewhere the same sheet drawn in HTML.
|
|
9
|
+
*
|
|
10
|
+
* Bind it with `v-model:open` and give it `actions`, each `{ id, title,
|
|
11
|
+
* style? }` where style is `default`, `destructive` (red) or `cancel`. It
|
|
12
|
+
* emits `select` with the chosen id, and `close` however it ended.
|
|
13
|
+
*
|
|
14
|
+
* For a sheet asked from code rather than declared, `useActionSheet()` and
|
|
15
|
+
* `confirmAction()` from @stacksjs/mobile do the same in one call.
|
|
16
|
+
*/
|
|
17
|
+
const emit = defineEmits()
|
|
18
|
+
const open = useModel('open', { default: false })
|
|
19
|
+
const title = useReactiveProp<string>('title', '')
|
|
20
|
+
const message = useReactiveProp<string>('message', '')
|
|
21
|
+
const actions = useReactiveProp<DialogAction[]>('actions', [])
|
|
22
|
+
const cancelLabel = useReactiveProp<string>('cancelLabel', 'Cancel')
|
|
23
|
+
|
|
24
|
+
let showing = false
|
|
25
|
+
effect(() => {
|
|
26
|
+
if (!open() || showing) return
|
|
27
|
+
showing = true
|
|
28
|
+
void dialog.actionSheet({
|
|
29
|
+
title: title() || undefined,
|
|
30
|
+
message: message() || undefined,
|
|
31
|
+
actions: actions(),
|
|
32
|
+
cancelLabel: cancelLabel(),
|
|
33
|
+
}).then((id) => {
|
|
34
|
+
showing = false
|
|
35
|
+
open.set(false)
|
|
36
|
+
if (id) emit('select', id)
|
|
37
|
+
emit('close')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<!-- The sheet is the phone's, or drawn over the page when asked for; nothing
|
|
43
|
+
of it lives here. -->
|
|
44
|
+
<span hidden data-native-action-sheet></span>
|
|
@@ -10,7 +10,7 @@ const { tabBar = false } = defineProps<NativeAppShellProps>()
|
|
|
10
10
|
</script>
|
|
11
11
|
|
|
12
12
|
<script client>
|
|
13
|
-
import { deepLinks as deepLinkService, device, followDeepLinks, isNativeMobile, splash, whenNativeMobile } from '@stacksjs/mobile'
|
|
13
|
+
import { deepLinks as deepLinkService, device, followDeepLinks, installNativeChrome, installPressables, isNativeMobile, splash, whenNativeMobile } from '@stacksjs/mobile'
|
|
14
14
|
|
|
15
15
|
const className = useReactiveProp<string>('class', '')
|
|
16
16
|
// A bare attribute arrives as an empty string.
|
|
@@ -45,9 +45,34 @@ void whenNativeMobile().then((native) => {
|
|
|
45
45
|
// One class binding: a second binding on the same element overwrites the
|
|
46
46
|
// first's classes whenever it runs.
|
|
47
47
|
const shellClass = derived(() => `${nativeMobile() ? 'native-mobile ' : ''}${className()}`.trim())
|
|
48
|
+
|
|
49
|
+
// In the phone app the chrome around the page follows it: the colour iOS
|
|
50
|
+
// shows past its edges, the status bar's text for its colour scheme, no
|
|
51
|
+
// previous/next/Done bar over the keyboard, and the phone's Dynamic Type and
|
|
52
|
+
// Reduce Motion settings as attributes the components read. A finger landing
|
|
53
|
+
// on anything `data-native-pressable` wakes the haptics for the tap to come
|
|
54
|
+
// (and, by listening at all, gives iOS its :active pressed state).
|
|
55
|
+
const shell = useRef('nativeAppShell')
|
|
56
|
+
let stopChrome: (() => void) | null = null
|
|
57
|
+
let stopPressables: (() => void) | null = null
|
|
58
|
+
let destroyed = false
|
|
59
|
+
onMount(() => {
|
|
60
|
+
const root = shell.current as HTMLElement | null
|
|
61
|
+
if (!root) return
|
|
62
|
+
stopPressables = installPressables(root.ownerDocument)
|
|
63
|
+
void whenNativeMobile().then((native) => {
|
|
64
|
+
if (native && !destroyed) stopChrome = installNativeChrome(root.ownerDocument)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
onDestroy(() => {
|
|
68
|
+
destroyed = true
|
|
69
|
+
stopChrome?.()
|
|
70
|
+
stopPressables?.()
|
|
71
|
+
})
|
|
48
72
|
</script>
|
|
49
73
|
|
|
50
74
|
<div
|
|
75
|
+
ref="nativeAppShell"
|
|
51
76
|
data-native-app-shell
|
|
52
77
|
class="native-app-shell"
|
|
53
78
|
:class="shellClass()"
|
|
@@ -76,6 +101,11 @@ const shellClass = derived(() => `${nativeMobile() ? 'native-mobile ' : ''}${cla
|
|
|
76
101
|
/* The height a fixed tab bar takes, which content and sticky footers clear. */
|
|
77
102
|
--native-tab-bar-height: 0px;
|
|
78
103
|
|
|
104
|
+
/* Dynamic Type. The Native components size their text in em off this,
|
|
105
|
+
17px (iOS's body size) at the phone's default text size, scaled by the
|
|
106
|
+
setting the shell reports. */
|
|
107
|
+
--native-font-size: calc(17px * var(--native-font-scale, var(--craft-font-scale, 1)));
|
|
108
|
+
|
|
79
109
|
min-height: 100dvh;
|
|
80
110
|
color: inherit;
|
|
81
111
|
background: inherit;
|
|
@@ -84,6 +114,45 @@ const shellClass = derived(() => `${nativeMobile() ? 'native-mobile ' : ''}${cla
|
|
|
84
114
|
padding-left: var(--native-safe-area-left);
|
|
85
115
|
}
|
|
86
116
|
|
|
117
|
+
/* The system font at the body size the phone's text setting chooses, as a
|
|
118
|
+
native screen's text is. An app keeps a brand face with
|
|
119
|
+
--native-font-family. The line height stays the app's. */
|
|
120
|
+
.native-app-shell.native-mobile {
|
|
121
|
+
font: -apple-system-body;
|
|
122
|
+
font-family: var(--native-font-family, -apple-system, system-ui, sans-serif);
|
|
123
|
+
line-height: inherit;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* A tap does not flash a grey box over what was tapped, and does not wait
|
|
127
|
+
300ms to tell a tap from a double-tap zoom. */
|
|
128
|
+
.native-mobile :is(a, button, [role=button], label, summary),
|
|
129
|
+
[data-native-pressable] {
|
|
130
|
+
-webkit-tap-highlight-color: transparent;
|
|
131
|
+
touch-action: manipulation;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* The press every control shares: dimmed and pressed in at once while the
|
|
135
|
+
finger is down, eased back on release, as UIKit's buttons are.
|
|
136
|
+
`data-native-pressable="opacity"` only dims, for a large row a scale
|
|
137
|
+
would shift. */
|
|
138
|
+
[data-native-pressable] {
|
|
139
|
+
transition: opacity 240ms ease-out, transform 240ms cubic-bezier(0.2, 0.9, 0.3, 1.2);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
[data-native-pressable]:active {
|
|
143
|
+
opacity: 0.55;
|
|
144
|
+
transform: scale(0.97);
|
|
145
|
+
transition: none;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
[data-native-pressable="opacity"]:active { transform: none; }
|
|
149
|
+
|
|
150
|
+
:root[data-native-reduce-motion] [data-native-pressable]:active { transform: none; }
|
|
151
|
+
|
|
152
|
+
@media (prefers-reduced-motion: reduce) {
|
|
153
|
+
[data-native-pressable]:active { transform: none; }
|
|
154
|
+
}
|
|
155
|
+
|
|
87
156
|
/* iOS zooms the page into a text field smaller than 16px when it takes
|
|
88
157
|
focus, and inside an app nothing zooms it back out: the whole screen
|
|
89
158
|
stayed magnified and shifted sideways after typing a comment. A field in
|
|
@@ -147,7 +147,166 @@ describe('Native components', () => {
|
|
|
147
147
|
it('fills a ring to its value, clamped, with its content in the middle', () => {
|
|
148
148
|
const ring = read('NativeProgressRing')
|
|
149
149
|
expect(ring).toContain('Math.max(0, Math.min(100, Number(value()) || 0))')
|
|
150
|
-
expect(ring).toContain('conic-gradient(var(--native-ring')
|
|
151
150
|
expect(ring).toContain('<slot />')
|
|
152
151
|
})
|
|
152
|
+
|
|
153
|
+
it('draws the ring as a stroke with rounded ends that animates to its value', async () => {
|
|
154
|
+
const ring = read('NativeProgressRing')
|
|
155
|
+
expect(ring).not.toContain('conic-gradient')
|
|
156
|
+
expect(ring).toContain('stroke-linecap: round;')
|
|
157
|
+
expect(ring).toContain('transition: stroke-dashoffset')
|
|
158
|
+
expect(ring).toContain('stroke-dashoffset:${100 - percent()}')
|
|
159
|
+
// Its thickness is still the page's --native-ring-width, exactly.
|
|
160
|
+
expect(ring).toContain('stroke-width: var(--native-ring-width, 5px);')
|
|
161
|
+
const html = await render('<NativeProgressRing :value="40" :size="120" label="Rest" />')
|
|
162
|
+
expect(html).toContain('pathLength="100"')
|
|
163
|
+
expect(html).toContain('r="50%"')
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
async function render(template: string): Promise<string> {
|
|
168
|
+
return processDirectives(
|
|
169
|
+
template,
|
|
170
|
+
{},
|
|
171
|
+
join(import.meta.dir, 'page.stx'),
|
|
172
|
+
{ componentsDir: import.meta.dir, partialsDir: import.meta.dir } as Parameters<typeof processDirectives>[3],
|
|
173
|
+
new Set<string>(),
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
describe('Native components feel native', () => {
|
|
178
|
+
it('sets the shell in the system font at the phone\'s text size, and sizes the components off it', () => {
|
|
179
|
+
const shell = read('NativeAppShell')
|
|
180
|
+
expect(shell).toContain('font: -apple-system-body;')
|
|
181
|
+
expect(shell).toContain('--native-font-size: calc(17px * var(--native-font-scale, var(--craft-font-scale, 1)));')
|
|
182
|
+
for (const name of ['NativeNavBar', 'NativeSheet', 'NativeSegmentedControl'])
|
|
183
|
+
expect(read(name)).toContain('var(--native-font-size, 17px)')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('drops the tap flash and the double-tap delay, and gives pressables one pressed state', () => {
|
|
187
|
+
const shell = read('NativeAppShell')
|
|
188
|
+
expect(shell).toContain('-webkit-tap-highlight-color: transparent;')
|
|
189
|
+
expect(shell).toContain('touch-action: manipulation;')
|
|
190
|
+
expect(shell).toContain('[data-native-pressable]:active {')
|
|
191
|
+
// Pressed at once, released with an ease.
|
|
192
|
+
expect(shell).toMatch(/\[data-native-pressable\]:active \{[^}]*transition: none;/)
|
|
193
|
+
expect(shell).toContain('stopPressables = installPressables(root.ownerDocument)')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('keeps the native chrome in step with the page once in the phone app', () => {
|
|
197
|
+
const shell = read('NativeAppShell')
|
|
198
|
+
expect(shell).toContain('if (native && !destroyed) stopChrome = installNativeChrome(root.ownerDocument)')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('links the bar to the scroll a frame at a time, and stretches the large title past the top', () => {
|
|
202
|
+
const bar = read('NativeNavBar')
|
|
203
|
+
expect(bar).toContain('stopScroll = observeLargeTitle(root, {')
|
|
204
|
+
expect(bar).toContain('opacity: max(0.001, min(1, var(--native-nav-progress) * 1.5));')
|
|
205
|
+
expect(bar).toContain('transform: scale(var(--native-nav-stretch, 1));')
|
|
206
|
+
expect(bar).toContain('transform: translateY(calc((1 - var(--native-nav-progress)) * 0.5em));')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('draws the back chevron as the SF Symbol, with its Iconify stand-in in the page', async () => {
|
|
210
|
+
const html = await render('<NativeNavBar title="Workout" back="/m" />')
|
|
211
|
+
expect(html).toContain('data-native-symbol="chevron.backward"')
|
|
212
|
+
// Sized by the bar's class from the first paint.
|
|
213
|
+
expect(html).toContain('class="native-symbol i-lucide-chevron-left native-nav-back-icon"')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('draws an SF Symbol from the shell, cached, over an Iconify stand-in', async () => {
|
|
217
|
+
const symbol = read('NativeSymbol')
|
|
218
|
+
expect(symbol).toContain('symbols.cached(name, options)')
|
|
219
|
+
expect(symbol).toContain('mask: var(--native-symbol) center / contain no-repeat;')
|
|
220
|
+
const labelled = await render('<NativeSymbol name="star" label="Favourite" />')
|
|
221
|
+
expect(labelled).toContain('class="native-symbol i-lucide-star"')
|
|
222
|
+
expect(labelled).toContain('role="img" aria-label="Favourite"')
|
|
223
|
+
const own = await render('<NativeSymbol name="figure.run" icon="i-lucide-footprints" />')
|
|
224
|
+
expect(own).toContain('class="native-symbol i-lucide-footprints"')
|
|
225
|
+
expect(own).toContain('aria-hidden="true"')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('drags a sheet by its grabber and header, between detents, and away', async () => {
|
|
229
|
+
const sheet = read('NativeSheet')
|
|
230
|
+
expect(sheet).toContain('stopDrag = observeSheetDrag({')
|
|
231
|
+
expect(sheet).toContain('const handles = [grabber.current, header.current]')
|
|
232
|
+
expect(sheet).toContain("if (to === 'dismiss') close()")
|
|
233
|
+
expect(sheet).toContain('.native-sheet.is-open.is-medium .native-sheet-panel { transform: translateY(calc(100% - 50dvh)); }')
|
|
234
|
+
const html = await render('<NativeSheet title="Edit" detents="medium large"><p>Body</p></NativeSheet>')
|
|
235
|
+
expect(html).toContain('data-stx-ref="nativeSheetGrabber"')
|
|
236
|
+
expect(html).toContain('data-stx-ref="nativeSheetHeader"')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('opens a sheet without a haptic, holds the page behind it, and keeps focus inside', () => {
|
|
240
|
+
const sheet = read('NativeSheet')
|
|
241
|
+
expect(sheet).not.toContain('haptics')
|
|
242
|
+
expect(sheet).toContain('releaseBackground = lockBackground(element)')
|
|
243
|
+
expect(sheet).toContain('trapFocus(panel.current as HTMLElement, event)')
|
|
244
|
+
expect(sheet).toContain('putBack ??= liftToBody(element)')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('presses the page back into a card behind a large sheet, unless motion is reduced', () => {
|
|
248
|
+
const sheet = read('NativeSheet')
|
|
249
|
+
expect(sheet).toContain('presentPageAsCard(element.ownerDocument, want)')
|
|
250
|
+
expect(sheet).toContain(':root[data-native-sheet-card] .native-app-content {')
|
|
251
|
+
expect(sheet).toContain(':root[data-native-reduce-motion][data-native-sheet-card] .native-app-content { transform: none; border-radius: 0; }')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('refreshes with the native control where the shell has it, and the 12-spoke indicator elsewhere', async () => {
|
|
255
|
+
const refresh = read('NativePullToRefresh')
|
|
256
|
+
expect(refresh).toContain('stop = observeNativeRefresh(element, {')
|
|
257
|
+
expect(refresh).toContain("if (element && !scrollHost() && refresh.isAvailable())")
|
|
258
|
+
expect(refresh).toContain('host: resolveScrollHost(scrollHost()),')
|
|
259
|
+
const html = await render('<NativePullToRefresh><p>List</p></NativePullToRefresh>')
|
|
260
|
+
expect(html.match(/class="native-ptr-spoke"/g)?.length).toBe(12)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('holds the content down while refreshing, and never leaves a transform on it at rest', () => {
|
|
264
|
+
const refresh = read('NativePullToRefresh')
|
|
265
|
+
expect(refresh).toContain('const shift = refreshing() ? HOLD : Math.max(0, distance() - bounce())')
|
|
266
|
+
expect(refresh).toContain('.native-ptr:is(.is-pulling, .is-refreshing) .native-ptr-content {')
|
|
267
|
+
expect(refresh).not.toMatch(/\n {2}\.native-ptr-content \{[^}]*transform:/)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('opens a context menu on a half-second press, natively or over a blurred page', async () => {
|
|
271
|
+
const menu = read('NativeContextMenu')
|
|
272
|
+
expect(menu).toContain("useReactiveProp<number>('delay', 500)")
|
|
273
|
+
expect(menu).toContain("void haptics.impact('medium')")
|
|
274
|
+
expect(menu).toContain('showContextMenuFor(element, { items: items(), title: title() || undefined })')
|
|
275
|
+
expect(menu).toContain("emit('select', id)")
|
|
276
|
+
expect(menu).toContain('-webkit-touch-callout: none;')
|
|
277
|
+
const html = await render('<NativeContextMenu :items="[{ id: \'copy\', title: \'Copy\' }]"><p>Row</p></NativeContextMenu>')
|
|
278
|
+
expect(html).toContain('data-native-context-menu')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('raises an action sheet through the shell, or drawn in HTML', async () => {
|
|
282
|
+
const sheet = read('NativeActionSheet')
|
|
283
|
+
expect(sheet).toContain('void dialog.actionSheet({')
|
|
284
|
+
expect(sheet).toContain("if (id) emit('select', id)")
|
|
285
|
+
expect(sheet).toContain("useModel('open'")
|
|
286
|
+
const html = await render('<NativeActionSheet title="Workout" :actions="[{ id: \'skip\', title: \'Skip\' }]" />')
|
|
287
|
+
expect(html).toContain('data-native-action-sheet')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('scrubs, shrinks under a finger, and moves with the arrow keys', async () => {
|
|
291
|
+
const control = read('NativeSegmentedControl')
|
|
292
|
+
expect(control).toContain('stopScrub = observeSegmentScrub(element, {')
|
|
293
|
+
expect(control).toContain("${pressed() ? ' scale(0.95)' : ''}")
|
|
294
|
+
expect(control).toContain('const next = segmentForKey(event.key, activeIndex(), options().length)')
|
|
295
|
+
expect(control).toContain(':tabindex="index === activeIndex() ? \'0\' : \'-1\'"')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('darkens the offline banner with the app\'s theme, not the system\'s', () => {
|
|
299
|
+
const banner = read('NativeNetworkBanner')
|
|
300
|
+
expect(banner).not.toContain('prefers-color-scheme')
|
|
301
|
+
expect(banner).toContain('.dark .native-network-banner {')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('marks each tab for the router, and scrolls a re-selected tab to its top', async () => {
|
|
305
|
+
const item = read('NativeTabItem')
|
|
306
|
+
expect(item).toContain('data-stx-nav="tab"')
|
|
307
|
+
const bar = read('NativeTabBar')
|
|
308
|
+
expect(bar).toContain('stopReselect = handleTabReselect(nav)')
|
|
309
|
+
// No haptic for a tap the phone's own bar made.
|
|
310
|
+
expect(bar).toContain('if (!mirrored) void haptics.selection()')
|
|
311
|
+
})
|
|
153
312
|
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
import type { ContextMenuItem } from '@stacksjs/mobile'
|
|
3
|
+
import { haptics, observeLongPress, showContextMenuFor } from '@stacksjs/mobile'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A context menu on whatever is inside: a long press (half a second, as on
|
|
7
|
+
* iOS) presses it in, taps the haptic engine and opens the menu beside it.
|
|
8
|
+
* In Craft's iOS shell that is the phone's own UIMenu; elsewhere a menu drawn
|
|
9
|
+
* over the blurred page. The keyboard's context-menu key (or Shift+F10)
|
|
10
|
+
* opens it too.
|
|
11
|
+
*
|
|
12
|
+
* `items` are `{ id, title, symbol?, icon?, destructive?, disabled? }`:
|
|
13
|
+
* `symbol` an SF Symbol for the native menu, `icon` an Iconify class for the
|
|
14
|
+
* web one. Listen for `select`, which carries the chosen id.
|
|
15
|
+
*/
|
|
16
|
+
const emit = defineEmits()
|
|
17
|
+
const items = useReactiveProp<ContextMenuItem[]>('items', [])
|
|
18
|
+
const title = useReactiveProp<string>('title', '')
|
|
19
|
+
const delay = useReactiveProp<number>('delay', 500)
|
|
20
|
+
const pressing = state(false)
|
|
21
|
+
const target = useRef('nativeContextMenu')
|
|
22
|
+
let stop: (() => void) | null = null
|
|
23
|
+
|
|
24
|
+
async function openMenu(element: HTMLElement): Promise<void> {
|
|
25
|
+
if (items().length === 0) return
|
|
26
|
+
const id = await showContextMenuFor(element, { items: items(), title: title() || undefined })
|
|
27
|
+
if (id) emit('select', id)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
onMount(() => {
|
|
31
|
+
const element = target.current as HTMLElement | null
|
|
32
|
+
if (!element) return
|
|
33
|
+
stop = observeLongPress(element, {
|
|
34
|
+
delay: Number(delay()) || 500,
|
|
35
|
+
onPressStart: () => {
|
|
36
|
+
pressing.set(true)
|
|
37
|
+
void haptics.prepare('impact')
|
|
38
|
+
},
|
|
39
|
+
onPressEnd: () => pressing.set(false),
|
|
40
|
+
onLongPress: () => {
|
|
41
|
+
pressing.set(false)
|
|
42
|
+
void haptics.impact('medium')
|
|
43
|
+
void openMenu(element)
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
onDestroy(() => stop?.())
|
|
48
|
+
|
|
49
|
+
function onKey(event: KeyboardEvent): void {
|
|
50
|
+
if (event.key !== 'ContextMenu' && !(event.shiftKey && event.key === 'F10')) return
|
|
51
|
+
event.preventDefault()
|
|
52
|
+
const element = target.current as HTMLElement | null
|
|
53
|
+
if (element) void openMenu(element)
|
|
54
|
+
}
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<div ref="nativeContextMenu" class="native-context-menu" :class="pressing() ? 'is-pressing' : ''" @keydown="onKey" data-native-context-menu>
|
|
58
|
+
<slot />
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<style>
|
|
62
|
+
/* No text selection, magnifier or link callout on a long press: the press
|
|
63
|
+
is the menu's. */
|
|
64
|
+
.native-context-menu {
|
|
65
|
+
-webkit-touch-callout: none;
|
|
66
|
+
-webkit-user-select: none;
|
|
67
|
+
user-select: none;
|
|
68
|
+
-webkit-tap-highlight-color: transparent;
|
|
69
|
+
transition: transform 260ms cubic-bezier(0.2, 0.9, 0.3, 1.2);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Pressed in slowly over the long press, as iOS does before the menu lifts. */
|
|
73
|
+
.native-context-menu.is-pressing {
|
|
74
|
+
transform: scale(0.97);
|
|
75
|
+
transition: transform 500ms cubic-bezier(0.3, 0, 0.6, 1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@media (prefers-reduced-motion: reduce) {
|
|
79
|
+
.native-context-menu, .native-context-menu.is-pressing { transform: none; transition: none; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
:root[data-native-reduce-motion] .native-context-menu.is-pressing { transform: none; }
|
|
83
|
+
</style>
|