@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.
@@ -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 410 built-in dashboard components. Covers the dashboard system at storage/framework/defaults/.
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, 410 components, and a multi-section layout.
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 as the page scrolls, a back button (`back="/parent"`) that goes back
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. The event carries `done()`: call it when the new data is in.
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, above the tab bar.
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. `ios.swipeNavigation: true` lets an edge swipe
220
- go back through the page's history, pushed routes included.
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.
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.97",
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.97",
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.97",
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>