@stacksjs/defaults 0.75.21 → 0.75.23

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 401 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 405 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, 401 components, and a multi-section layout.
11
+ The Stacks admin dashboard provides a full-featured admin panel with 100+ route views, 405 components, and a multi-section layout.
12
12
 
13
13
  ## Key Paths
14
14
  - Dashboard components: `storage/framework/defaults/resources/components/Dashboard/`
@@ -122,15 +122,43 @@ supported web APIs provide fallback behavior outside a native host.
122
122
  - `<NativeNetworkBanner>` reflects native connectivity changes and announces offline state accessibly.
123
123
  - `<NativePermissionButton>` wraps permission status, requests, haptics, and the native Settings escape hatch.
124
124
  - `<NativeHealthButton>` requests the minimal Apple Health or Android Health Connect grants.
125
+ - `<NativeNavBar>` is the iOS navigation bar: a large title that collapses into
126
+ the bar as the page scrolls, a back button (`back="/parent"`) that goes back
127
+ through history when the app pushed the screen, and `leading` / `actions`
128
+ slots. Pass `:large="false"` on a pushed screen; `title` is reactive.
129
+ - `<NativePullToRefresh @refresh="reload">` refreshes the page when pulled from
130
+ its top. The event carries `done()`: call it when the new data is in.
131
+ - `<NativeSegmentedControl :options="[...]" v-model:value="range">` switches
132
+ between views of one screen, with selection haptics.
133
+ - `<NativeSheet v-model:open="editing" title="…">` raises a bottom sheet for a
134
+ task that belongs to the screen, above the tab bar.
135
+
136
+ `<NativeTabItem match="/m/workout">` keeps a tab lit on the detail screens
137
+ opened from it. A tab bar is a `<nav>`, where a link is otherwise current only
138
+ on its own page.
125
139
 
126
140
  Use Iconify classes for tab icons. Keep native operations inside reusable
127
141
  components or TypeScript composables, never through `window.*` in an STX
128
142
  script.
129
143
 
144
+ ## Appearance and navigation
145
+
146
+ `ios.appearance: 'system'` follows the phone's Light/Dark setting (and the
147
+ page's `prefers-color-scheme` with it), with a status bar that reads on either;
148
+ `'light'` and `'dark'` pin one. `ios.backgroundColorDark` colours the launch
149
+ screen and webview in Dark Mode. `ios.swipeNavigation: true` lets an edge swipe
150
+ go back through the page's history, pushed routes included.
151
+
152
+ Decide native-only chrome with `await whenNativeMobile()`, not
153
+ `isNativeMobile()` at setup: Craft installs its bridge after the page starts.
154
+
130
155
  ## Health and watch surfaces
131
156
 
132
157
  Enable `healthKit` on iOS or `healthConnect` on Android, then use the shared
133
- `health` service to request only the record types the product needs. Completed
158
+ `health` service to request only the record types the product needs. On iOS,
159
+ `health.getWorkouts()` lists Apple Health workouts (keyed on HealthKit's UUID)
160
+ and `health.getDailyStatistics(type)` returns one value per day for steps,
161
+ energy, distance, heart rate, resting heart rate, HRV, weight and sleep. Completed
134
162
  recordings can be written back with `health.saveWorkout(...)`; treat permission
135
163
  revocation as a normal runtime state and never block saving the application's
136
164
  own activity when a health write fails.
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.21",
5
+ "version": "0.75.23",
6
6
  "description": "A modern Stacks development environment.",
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.21",
5
+ "version": "0.75.23",
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.27",
58
- "@stacksjs/mobile": "^0.75.21",
58
+ "@stacksjs/mobile": "^0.75.23",
59
59
  "@stacksjs/sanitizer": "^0.2.113",
60
60
  "ts-qr-codes": "^0.1.8"
61
61
  }
@@ -2,16 +2,49 @@
2
2
  interface NativeAppShellProps {
3
3
  class?: string
4
4
  tabBar?: boolean
5
+ /** Open the app's deep links (`myapp://path`, or a universal link) at their path. */
6
+ deepLinks?: boolean
5
7
  }
6
8
 
7
9
  const { tabBar = false } = defineProps<NativeAppShellProps>()
8
10
  </script>
9
11
 
10
12
  <script client>
13
+ import { deepLinks as deepLinkService, device, followDeepLinks, isNativeMobile, whenNativeMobile } from '@stacksjs/mobile'
14
+
11
15
  const className = useReactiveProp<string>('class', '')
16
+ // A bare attribute arrives as an empty string.
17
+ const deepLinkProp = useReactiveProp<unknown>('deepLinks', false)
18
+ let stopDeepLinks: (() => void) | null = null
19
+ onMount(() => {
20
+ const on = deepLinkProp()
21
+ if (on === true || on === '' || on === 'true')
22
+ stopDeepLinks = followDeepLinks(path => navigate(path), deepLinkService)
23
+ })
24
+ onDestroy(() => stopDeepLinks?.())
25
+
26
+ // Asked twice on purpose. Craft installs its bridge after the document starts,
27
+ // so on a phone the answer at setup is often `false`, and every rule hanging
28
+ // off `.native-mobile` would never apply. whenNativeMobile() answers once the
29
+ // host is recognisable or the bridge is ready.
30
+ const nativeMobile = state(isNativeMobile())
31
+ const platform = state('')
32
+ void whenNativeMobile().then((native) => {
33
+ nativeMobile.set(native)
34
+ if (native) platform.set(device.isIOS() ? 'ios' : device.isAndroid() ? 'android' : '')
35
+ })
36
+
37
+ // One class binding: a second binding on the same element overwrites the
38
+ // first's classes whenever it runs.
39
+ const shellClass = derived(() => `${nativeMobile() ? 'native-mobile ' : ''}${className()}`.trim())
12
40
  </script>
13
41
 
14
- <div data-native-app-shell class="native-app-shell" :class="className()">
42
+ <div
43
+ data-native-app-shell
44
+ class="native-app-shell"
45
+ :class="shellClass()"
46
+ :data-native-platform="platform()"
47
+ >
15
48
  <!-- The application layout owns the single <main> landmark. This shell is
16
49
  structural chrome; making it another <main> creates invalid nested
17
50
  landmarks and gives both elements the router's stx-content view-
@@ -32,6 +65,8 @@ const className = useReactiveProp<string>('class', '')
32
65
  --native-safe-area-right: env(safe-area-inset-right, 0px);
33
66
  --native-safe-area-bottom: env(safe-area-inset-bottom, 0px);
34
67
  --native-safe-area-left: env(safe-area-inset-left, 0px);
68
+ /* The height a fixed tab bar takes, which content and sticky footers clear. */
69
+ --native-tab-bar-height: 0px;
35
70
 
36
71
  min-height: 100dvh;
37
72
  color: inherit;
@@ -68,27 +103,41 @@ const className = useReactiveProp<string>('class', '')
68
103
  * footer that a visitor could scroll into, which reads as a broken page.
69
104
  *
70
105
  * Scoped to below `md` (768px, the framework's own breakpoint and where
71
- * NativeTabBar stops being shown) so the clearance exists exactly when the
72
- * thing it is clearing does.
106
+ * NativeTabBar stops being shown) — or to the phone app at any width — so
107
+ * the clearance exists exactly when the thing it is clearing does.
73
108
  */
74
109
  @media (max-width: 767.98px) {
110
+ .native-app-shell:has(.native-app-tab-slot) { --native-tab-bar-height: 4.5rem; }
75
111
  .native-app-shell:has(.native-app-tab-slot) .native-app-content {
76
112
  padding-bottom: calc(5rem + var(--native-safe-area-bottom));
77
113
  }
78
114
  }
79
115
 
116
+ .native-app-shell.native-mobile:has(.native-app-tab-slot) { --native-tab-bar-height: 4.5rem; }
117
+ .native-app-shell.native-mobile:has(.native-app-tab-slot) .native-app-content {
118
+ padding-bottom: calc(5rem + var(--native-safe-area-bottom));
119
+ }
120
+
80
121
  .native-app-sticky-top {
81
122
  top: var(--native-safe-area-top);
82
123
  }
83
124
 
125
+ /* No inset padding here: the bar inside pads itself, so its background runs
126
+ under the home indicator instead of leaving a see-through strip there. */
84
127
  .native-app-tab-slot {
85
128
  position: fixed;
86
129
  right: 0;
87
130
  bottom: 0;
88
131
  left: 0;
89
132
  z-index: 30;
90
- padding-right: var(--native-safe-area-right);
91
- padding-bottom: var(--native-safe-area-bottom);
92
- padding-left: var(--native-safe-area-left);
133
+ }
134
+
135
+ /* A phone app does not select its chrome on a long press, or flash it grey
136
+ on a tap. Text inside the content is left selectable. */
137
+ .native-app-shell.native-mobile .native-app-tab-slot,
138
+ .native-app-shell.native-mobile [data-native-nav-bar] {
139
+ -webkit-user-select: none;
140
+ user-select: none;
141
+ -webkit-tap-highlight-color: transparent;
93
142
  }
94
143
  </style>
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ const read = (name: string) => readFileSync(join(import.meta.dir, `${name}.stx`), 'utf8')
6
+
7
+ describe('Native components', () => {
8
+ it('decides native-only chrome once the bridge can answer', () => {
9
+ const shell = read('NativeAppShell')
10
+ expect(shell).toContain('whenNativeMobile()')
11
+ // One class binding: a second one would overwrite the first.
12
+ expect(shell.match(/:class=/g)?.length).toBe(1)
13
+ // Deep links are opt-in, and open at their path through the router.
14
+ expect(shell).toContain('followDeepLinks(path => navigate(path), deepLinkService)')
15
+ })
16
+
17
+ it('keeps a tab lit on the paths it names, and never drags a tab', () => {
18
+ const item = read('NativeTabItem')
19
+ expect(item).toContain('activeMatch="{{ match }}"')
20
+ expect(item).toContain('draggable="false"')
21
+ })
22
+
23
+ it('shows the offline banner by class, so it can come back', () => {
24
+ const banner = read('NativeNetworkBanner')
25
+ expect(banner).not.toContain(':if="!connected()"')
26
+ expect(banner).toContain('native-network-banner--quiet')
27
+ })
28
+
29
+ it('backs out through history, and sticks within the page rather than its wrapper', () => {
30
+ const bar = read('NativeNavBar')
31
+ expect(bar).toContain('if (goBack()) event.preventDefault()')
32
+ expect(bar).toContain('.native-nav { display: contents; }')
33
+ expect(bar).toContain("useReactiveProp<string>('title', '')")
34
+ })
35
+
36
+ it('lifts an open sheet out of the router\'s stacking context', () => {
37
+ const sheet = read('NativeSheet')
38
+ expect(sheet).toContain('main:has(.native-sheet.is-open) { view-transition-name: none; }')
39
+ expect(sheet).toContain("useModel('open'")
40
+ })
41
+
42
+ it('refreshes through the shared gesture and hands the page done()', () => {
43
+ const refresh = read('NativePullToRefresh')
44
+ expect(refresh).toContain('observePullToRefresh(')
45
+ expect(refresh).toContain("emit('refresh', { done: finish })")
46
+ })
47
+
48
+ it('reports a segment change with a selection haptic', () => {
49
+ const control = read('NativeSegmentedControl')
50
+ expect(control).toContain('haptics.selection()')
51
+ expect(control).toContain("emit('change', value)")
52
+ })
53
+ })
@@ -0,0 +1,147 @@
1
+ <script>
2
+ interface NativeNavBarProps {
3
+ /** The screen's name: display-sized under the bar, then in the bar once scrolled. */
4
+ title?: string
5
+ /** A parent to go back to. History is used when the app pushed this screen. */
6
+ back?: string
7
+ backLabel?: string
8
+ /** `false` for a pushed screen: the title sits in the bar from the start. */
9
+ large?: boolean
10
+ }
11
+
12
+ const { title = '', back = '', backLabel = 'Back', large = true } = defineProps<NativeNavBarProps>()
13
+ </script>
14
+
15
+ <script client>
16
+ import { goBack, haptics, observePageScroll } from '@stacksjs/mobile'
17
+
18
+ const isLarge = useReactiveProp<boolean>('large', true)
19
+ // Reactive, so a screen whose name arrives with its data can pass it in.
20
+ const navTitle = useReactiveProp<string>('title', '')
21
+ const scrolled = state(false)
22
+ let stopScroll: (() => void) | null = null
23
+
24
+ onMount(() => {
25
+ // The large title is about 44px tall; past it, its name belongs in the bar.
26
+ stopScroll = observePageScroll(offset => scrolled.set(offset > 40))
27
+ })
28
+ onDestroy(() => stopScroll?.())
29
+
30
+ const barClass = derived(() => {
31
+ const collapsed = !isLarge() || scrolled()
32
+ return `native-nav-bar${collapsed ? ' is-collapsed' : ''}${scrolled() ? ' is-scrolled' : ''}`
33
+ })
34
+
35
+ function tapBack(event: MouseEvent): void {
36
+ void haptics.selection()
37
+ if (goBack()) event.preventDefault()
38
+ }
39
+ </script>
40
+
41
+ <!-- `display: contents` on the wrapper: a sticky element sticks within its
42
+ parent, and a wrapper exactly the bar's height would give it no room. -->
43
+ <div class="native-nav" data-native-nav-bar>
44
+ <header :class="barClass()">
45
+ <div class="native-nav-side">
46
+ @if(back)
47
+ <a href="{{ back }}" class="native-nav-back" aria-label="{{ backLabel }}" draggable="false" @click="tapBack">
48
+ <i aria-hidden="true" class="i-lucide-chevron-left native-nav-back-icon"></i>
49
+ <span>{{ backLabel }}</span>
50
+ </a>
51
+ @endif
52
+ <slot name="leading" />
53
+ </div>
54
+ <p class="native-nav-title" aria-hidden="{{ large ? 'true' : 'false' }}">{{ navTitle() }}</p>
55
+ <div class="native-nav-side native-nav-actions">
56
+ <slot name="actions" />
57
+ </div>
58
+ </header>
59
+ @if(large)
60
+ <h1 class="native-nav-large-title" :class="navTitle() ? '' : 'is-empty'">{{ navTitle() }}</h1>
61
+ @endif
62
+ <slot />
63
+ </div>
64
+
65
+ <style>
66
+ .native-nav { display: contents; }
67
+
68
+ .native-nav-bar {
69
+ position: sticky;
70
+ top: env(safe-area-inset-top, 0px);
71
+ z-index: 30;
72
+ display: grid;
73
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
74
+ gap: 0.5rem;
75
+ align-items: center;
76
+ min-height: 2.75rem;
77
+ padding: 0 0.75rem;
78
+ border-bottom: 1px solid transparent;
79
+ transition: background-color 180ms ease, border-color 180ms ease;
80
+ }
81
+
82
+ .native-nav-bar.is-scrolled {
83
+ border-color: var(--native-bar-border, rgb(148 163 184 / 0.25));
84
+ background: var(--native-bar-background, rgb(248 250 252 / 0.86));
85
+ backdrop-filter: blur(20px) saturate(180%);
86
+ -webkit-backdrop-filter: blur(20px) saturate(180%);
87
+ }
88
+
89
+ .dark .native-nav-bar.is-scrolled {
90
+ border-color: var(--native-bar-border-dark, rgb(51 65 85 / 0.6));
91
+ background: var(--native-bar-background-dark, rgb(2 6 23 / 0.86));
92
+ }
93
+
94
+ .native-nav-side { display: flex; gap: 0.25rem; align-items: center; min-width: 0; }
95
+ .native-nav-actions { justify-content: flex-end; }
96
+
97
+ .native-nav-title {
98
+ max-width: 60vw;
99
+ overflow: hidden;
100
+ font-size: 1.0625rem;
101
+ font-weight: 600;
102
+ text-overflow: ellipsis;
103
+ white-space: nowrap;
104
+ opacity: 0;
105
+ transform: translateY(4px);
106
+ transition: opacity 180ms ease, transform 180ms ease;
107
+ }
108
+
109
+ .native-nav-bar.is-collapsed .native-nav-title { opacity: 1; transform: none; }
110
+
111
+ .native-nav-back {
112
+ display: inline-flex;
113
+ gap: 0.1rem;
114
+ align-items: center;
115
+ min-height: 2.75rem;
116
+ margin-left: -0.4rem;
117
+ padding-right: 0.5rem;
118
+ color: var(--native-accent, rgb(37 99 235));
119
+ font-size: 1.0625rem;
120
+ text-decoration: none;
121
+ -webkit-touch-callout: none;
122
+ }
123
+
124
+ .dark .native-nav-back { color: var(--native-accent-dark, rgb(96 165 250)); }
125
+ .native-nav-back:active { opacity: 0.55; }
126
+ .native-nav-back-icon { width: 1.6rem; height: 1.6rem; }
127
+
128
+ .native-nav-large-title.is-empty { display: none; }
129
+
130
+ .native-nav-large-title {
131
+ margin: 0.125rem 0 0.5rem;
132
+ padding: 0 1rem;
133
+ font-size: 2.125rem;
134
+ font-weight: 700;
135
+ line-height: 1.15;
136
+ letter-spacing: -0.02em;
137
+ }
138
+
139
+ @media (prefers-reduced-motion: reduce) {
140
+ .native-nav-bar, .native-nav-title { transition: none; }
141
+ }
142
+
143
+ @media (prefers-reduced-transparency: reduce) {
144
+ .native-nav-bar.is-scrolled { backdrop-filter: none; -webkit-backdrop-filter: none; background: var(--native-bar-solid, rgb(248 250 252)); }
145
+ .dark .native-nav-bar.is-scrolled { background: var(--native-bar-solid-dark, rgb(2 6 23)); }
146
+ }
147
+ </style>
@@ -19,12 +19,18 @@ onMount(() => {
19
19
  onDestroy(() => removeListener?.())
20
20
  </script>
21
21
 
22
+ <!--
23
+ Visibility is a class, not `:if`. The banner starts connected, and a
24
+ component's `:if` that removed it on the first pass did not bring it back
25
+ inside Craft's WebView when the signal changed, so the app never said it had
26
+ gone offline. Both states go through the one `:class`: a second class
27
+ binding on the element would overwrite the first.
28
+ -->
22
29
  <div
23
- :if="!connected()"
24
30
  role="status"
25
31
  aria-live="polite"
26
32
  class="native-network-banner"
27
- :class="className()"
33
+ :class="(connected() ? 'native-network-banner--quiet ' : '') + className()"
28
34
  >
29
35
  <i aria-hidden="true" class="h-4 w-4 i-hugeicons-wifi-disconnected-01"></i>
30
36
  <span>{{ offlineLabel() }}</span>
@@ -47,6 +53,9 @@ onDestroy(() => removeListener?.())
47
53
  font-weight: 600;
48
54
  }
49
55
 
56
+ /* After the base rule and doubled up, so it wins over `display: flex`. */
57
+ .native-network-banner.native-network-banner--quiet { display: none; }
58
+
50
59
  @media (prefers-color-scheme: dark) {
51
60
  .native-network-banner { color: rgb(254 243 199); background: rgb(120 53 15 / 0.96); }
52
61
  }
@@ -0,0 +1,99 @@
1
+ <script client>
2
+ import { observePullToRefresh } from '@stacksjs/mobile'
3
+
4
+ /**
5
+ * Pull the page down from its top to refresh it.
6
+ *
7
+ * On iOS the pull rides the system's own rubber band, so it feels like a
8
+ * native list; elsewhere the finger's travel drives it. Crossing the threshold
9
+ * taps the haptic engine once. The `refresh` event carries `done()`: call it
10
+ * when the new data is in, and the spinner goes away. A handler that never
11
+ * calls it is let go after 15 seconds.
12
+ */
13
+ const emit = defineEmits()
14
+ const threshold = useReactiveProp<number>('threshold', 72)
15
+ const label = useReactiveProp<string>('label', 'Refreshing')
16
+ const distance = state(0)
17
+ const armed = state(false)
18
+ const refreshing = state(false)
19
+ let stop: (() => void) | null = null
20
+
21
+ onMount(() => {
22
+ stop = observePullToRefresh({
23
+ threshold: threshold(),
24
+ onPull: (pulled, isArmed) => {
25
+ distance.set(pulled)
26
+ armed.set(isArmed)
27
+ },
28
+ onRefresh: () => new Promise<void>((resolve) => {
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
+ }),
38
+ })
39
+ })
40
+ onDestroy(() => stop?.())
41
+
42
+ const indicatorClass = derived(() => {
43
+ const visible = refreshing() || distance() > 8
44
+ return `native-ptr-indicator${visible ? ' is-visible' : ''}${refreshing() ? ' is-refreshing' : ''}${armed() ? ' is-armed' : ''}`
45
+ })
46
+ const indicatorStyle = derived(() => {
47
+ if (refreshing()) return ''
48
+ const progress = Math.min(1, distance() / threshold())
49
+ return `opacity:${progress};transform:translateX(-50%) rotate(${Math.round(progress * 270)}deg) scale(${0.6 + progress * 0.4})`
50
+ })
51
+ </script>
52
+
53
+ <div class="native-ptr">
54
+ <div :class="indicatorClass()" :style="indicatorStyle()" role="status" :aria-label="refreshing() ? label() : ''">
55
+ <i aria-hidden="true" class="i-lucide-loader-circle native-ptr-icon"></i>
56
+ </div>
57
+ <slot />
58
+ </div>
59
+
60
+ <style>
61
+ .native-ptr { position: relative; }
62
+
63
+ .native-ptr-indicator {
64
+ position: fixed;
65
+ top: calc(env(safe-area-inset-top, 0px) + 3.25rem);
66
+ left: 50%;
67
+ z-index: 25;
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ width: 2.25rem;
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);
77
+ opacity: 0;
78
+ pointer-events: none;
79
+ transform: translateX(-50%) scale(0.6);
80
+ }
81
+
82
+ .dark .native-ptr-indicator { color: var(--native-accent-dark, rgb(96 165 250)); background: var(--native-ptr-background-dark, rgb(30 41 59)); }
83
+ .native-ptr-indicator.is-visible { opacity: 1; }
84
+ .native-ptr-icon { width: 1.25rem; height: 1.25rem; }
85
+
86
+ .native-ptr-indicator.is-refreshing {
87
+ opacity: 1;
88
+ transform: translateX(-50%);
89
+ transition: opacity 150ms ease;
90
+ }
91
+
92
+ .native-ptr-indicator.is-refreshing .native-ptr-icon { animation: native-ptr-spin 800ms linear infinite; }
93
+
94
+ @keyframes native-ptr-spin { to { transform: rotate(360deg); } }
95
+
96
+ @media (prefers-reduced-motion: reduce) {
97
+ .native-ptr-indicator.is-refreshing .native-ptr-icon { animation-duration: 2400ms; }
98
+ }
99
+ </style>
@@ -0,0 +1,95 @@
1
+ <script client>
2
+ import { haptics } from '@stacksjs/mobile'
3
+
4
+ /**
5
+ * An iOS segmented control: two to five mutually exclusive views of one screen.
6
+ *
7
+ * `options` is a list of strings or `{ label, value }`. Bind the choice with
8
+ * `v-model:value` or listen for `change`. A selection taps the haptic engine
9
+ * the way UISegmentedControl does.
10
+ */
11
+ interface SegmentOption { label: string, value: string }
12
+
13
+ const emit = defineEmits()
14
+ const rawOptions = useReactiveProp<Array<string | SegmentOption>>('options', [])
15
+ const ariaLabel = useReactiveProp<string>('label', 'View')
16
+ const className = useReactiveProp<string>('class', '')
17
+ const selected = useModel('value', { default: '' })
18
+
19
+ const options = derived<SegmentOption[]>(() => rawOptions().map(option =>
20
+ typeof option === 'string' ? { label: option, value: option } : option))
21
+
22
+ const activeIndex = derived(() => Math.max(0, options().findIndex(option => option.value === (selected() || options()[0]?.value))))
23
+ const thumbStyle = derived(() => {
24
+ const count = Math.max(1, options().length)
25
+ return `width:calc((100% - 4px) / ${count});transform:translateX(${activeIndex() * 100}%)`
26
+ })
27
+
28
+ function choose(value: string): void {
29
+ if (value === selected()) return
30
+ selected.set(value)
31
+ void haptics.selection()
32
+ emit('change', value)
33
+ }
34
+ </script>
35
+
36
+ <div role="tablist" :aria-label="ariaLabel()" class="native-segmented" :class="className()">
37
+ <span aria-hidden="true" class="native-segmented-thumb" :style="thumbStyle()"></span>
38
+ <button
39
+ :for="(option, index) in options()"
40
+ :key="option.value"
41
+ type="button"
42
+ role="tab"
43
+ class="native-segmented-option"
44
+ :aria-selected="index === activeIndex() ? 'true' : 'false'"
45
+ @click="choose(option.value)"
46
+ >{{ option.label }}</button>
47
+ </div>
48
+
49
+ <style>
50
+ .native-segmented {
51
+ position: relative;
52
+ display: grid;
53
+ grid-auto-columns: 1fr;
54
+ grid-auto-flow: column;
55
+ padding: 2px;
56
+ border-radius: 0.6rem;
57
+ background: var(--native-segmented-track, rgb(118 118 128 / 0.12));
58
+ -webkit-user-select: none;
59
+ user-select: none;
60
+ }
61
+
62
+ .dark .native-segmented { background: var(--native-segmented-track-dark, rgb(118 118 128 / 0.24)); }
63
+
64
+ .native-segmented-thumb {
65
+ position: absolute;
66
+ top: 2px;
67
+ bottom: 2px;
68
+ left: 2px;
69
+ border-radius: 0.45rem;
70
+ background: var(--native-segmented-thumb, white);
71
+ box-shadow: 0 3px 8px rgb(0 0 0 / 0.12), 0 3px 1px rgb(0 0 0 / 0.04);
72
+ transition: transform 220ms cubic-bezier(0.3, 0.7, 0.4, 1);
73
+ }
74
+
75
+ .dark .native-segmented-thumb { background: var(--native-segmented-thumb-dark, rgb(99 99 102)); }
76
+
77
+ .native-segmented-option {
78
+ position: relative;
79
+ z-index: 1;
80
+ min-height: 2rem;
81
+ padding: 0 0.75rem;
82
+ border: 0;
83
+ color: inherit;
84
+ background: transparent;
85
+ font-size: 0.8125rem;
86
+ font-weight: 500;
87
+ -webkit-tap-highlight-color: transparent;
88
+ }
89
+
90
+ .native-segmented-option[aria-selected="true"] { font-weight: 600; }
91
+
92
+ @media (prefers-reduced-motion: reduce) {
93
+ .native-segmented-thumb { transition: none; }
94
+ }
95
+ </style>
@@ -0,0 +1,132 @@
1
+ <script client>
2
+ import { haptics } from '@stacksjs/mobile'
3
+
4
+ /**
5
+ * A bottom sheet: a task that belongs to this screen, raised over it.
6
+ *
7
+ * Bind it with `v-model:open`. It opens with a light haptic tap, closes from
8
+ * its backdrop, its Done button or Escape, and keeps its content clear of the
9
+ * home indicator.
10
+ */
11
+ const emit = defineEmits()
12
+ const open = useModel('open', { default: false })
13
+ const title = useReactiveProp<string>('title', '')
14
+ const doneLabel = useReactiveProp<string>('doneLabel', 'Done')
15
+
16
+ let wasOpen = false
17
+ effect(() => {
18
+ const isOpen = open()
19
+ if (isOpen && !wasOpen) void haptics.impact('light')
20
+ wasOpen = isOpen
21
+ })
22
+
23
+ function close(): void {
24
+ open.set(false)
25
+ emit('close')
26
+ }
27
+
28
+ useEventListener('keydown', (event: KeyboardEvent) => {
29
+ if (event.key === 'Escape' && open()) close()
30
+ })
31
+ </script>
32
+
33
+ <div class="native-sheet" :class="open() ? 'is-open' : ''" :aria-hidden="open() ? 'false' : 'true'">
34
+ <div class="native-sheet-backdrop" @click="close()"></div>
35
+ <section class="native-sheet-panel" role="dialog" aria-modal="true" :aria-label="title()">
36
+ <span aria-hidden="true" class="native-sheet-grabber"></span>
37
+ <header class="native-sheet-header">
38
+ <h2 class="native-sheet-title">{{ title() }}</h2>
39
+ <button type="button" class="native-sheet-done" @click="close()">{{ doneLabel() }}</button>
40
+ </header>
41
+ <div class="native-sheet-body">
42
+ <slot />
43
+ </div>
44
+ </section>
45
+ </div>
46
+
47
+ <style>
48
+ .native-sheet {
49
+ position: fixed;
50
+ inset: 0;
51
+ z-index: 70;
52
+ visibility: hidden;
53
+ pointer-events: none;
54
+ }
55
+
56
+ .native-sheet.is-open { visibility: visible; pointer-events: auto; }
57
+
58
+ .native-sheet-backdrop {
59
+ position: absolute;
60
+ inset: 0;
61
+ background: rgb(2 6 23 / 0.38);
62
+ opacity: 0;
63
+ transition: opacity 240ms ease;
64
+ }
65
+
66
+ .native-sheet.is-open .native-sheet-backdrop { opacity: 1; }
67
+
68
+ .native-sheet-panel {
69
+ position: absolute;
70
+ right: 0;
71
+ bottom: 0;
72
+ left: 0;
73
+ display: flex;
74
+ flex-direction: column;
75
+ max-height: calc(100dvh - env(safe-area-inset-top, 0px) - 1rem);
76
+ padding-bottom: env(safe-area-inset-bottom, 0px);
77
+ border-radius: 1.25rem 1.25rem 0 0;
78
+ background: var(--native-sheet-background, rgb(248 250 252));
79
+ box-shadow: 0 -12px 40px rgb(2 6 23 / 0.18);
80
+ transform: translateY(105%);
81
+ transition: transform 320ms cubic-bezier(0.32, 0.72, 0, 1);
82
+ }
83
+
84
+ .dark .native-sheet-panel { background: var(--native-sheet-background-dark, rgb(15 23 42)); }
85
+ .native-sheet.is-open .native-sheet-panel { transform: none; }
86
+
87
+ .native-sheet-grabber {
88
+ align-self: center;
89
+ width: 2.25rem;
90
+ height: 0.3rem;
91
+ margin-top: 0.4rem;
92
+ border-radius: 999px;
93
+ background: rgb(148 163 184 / 0.55);
94
+ }
95
+
96
+ .native-sheet-header {
97
+ display: flex;
98
+ gap: 0.75rem;
99
+ align-items: center;
100
+ justify-content: space-between;
101
+ padding: 0.5rem 1rem 0.25rem;
102
+ }
103
+
104
+ .native-sheet-title { font-size: 1.0625rem; font-weight: 600; }
105
+
106
+ .native-sheet-done {
107
+ min-height: 2.75rem;
108
+ border: 0;
109
+ color: var(--native-accent, rgb(37 99 235));
110
+ background: transparent;
111
+ font-size: 1.0625rem;
112
+ font-weight: 600;
113
+ }
114
+
115
+ .dark .native-sheet-done { color: var(--native-accent-dark, rgb(96 165 250)); }
116
+
117
+ .native-sheet-body {
118
+ overflow-y: auto;
119
+ padding: 0.5rem 1rem 1rem;
120
+ overscroll-behavior: contain;
121
+ }
122
+
123
+ @media (prefers-reduced-motion: reduce) {
124
+ .native-sheet-backdrop, .native-sheet-panel { transition: none; }
125
+ }
126
+
127
+ /* The router names the page's <main> for view transitions, which makes it a
128
+ stacking context: a sheet inside it stacked under the tab bar whatever its
129
+ z-index. While a sheet is open <main> gives the name up. Nothing navigates
130
+ with a sheet open, so no transition is lost. */
131
+ main:has(.native-sheet.is-open) { view-transition-name: none; }
132
+ </style>
@@ -23,7 +23,8 @@ function selectTab(): void {
23
23
  display: grid;
24
24
  grid-auto-flow: column;
25
25
  grid-auto-columns: 1fr;
26
- min-height: 4.5rem;
26
+ min-height: calc(4.5rem + env(safe-area-inset-bottom, 0px));
27
+ padding: 0 env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
27
28
  border-top: 1px solid rgb(148 163 184 / 0.22);
28
29
  background: rgb(255 255 255 / 0.9);
29
30
  backdrop-filter: blur(24px) saturate(160%);
@@ -44,10 +45,28 @@ function selectTab(): void {
44
45
  text-decoration: none;
45
46
  transition: color 150ms ease, transform 150ms ease;
46
47
  -webkit-tap-highlight-color: transparent;
48
+ /* No link preview or callout on a long press: a tab is not a link to share. */
49
+ -webkit-touch-callout: none;
50
+ }
51
+
52
+ .native-tab-glyph { position: relative; display: inline-flex; }
53
+
54
+ .native-tab-badge {
55
+ position: absolute;
56
+ top: -0.3rem;
57
+ right: -0.55rem;
58
+ min-width: 1rem;
59
+ padding: 0 0.25rem;
60
+ border-radius: 999px;
61
+ color: white;
62
+ background: rgb(225 29 72);
63
+ font-size: 0.625rem;
64
+ line-height: 1rem;
65
+ text-align: center;
47
66
  }
48
67
 
49
68
  .native-tab-item:active { transform: scale(0.96); }
50
- .native-tab-item.is-active { color: rgb(5 150 105); }
69
+ .native-tab-item.is-active { color: var(--native-tab-active, rgb(5 150 105)); }
51
70
  .native-tab-icon { width: 1.35rem; height: 1.35rem; }
52
71
 
53
72
  .dark .native-tab-bar {
@@ -56,7 +75,7 @@ function selectTab(): void {
56
75
  }
57
76
 
58
77
  .dark .native-tab-item { color: rgb(148 163 184); }
59
- .dark .native-tab-item.is-active { color: rgb(52 211 153); }
78
+ .dark .native-tab-item.is-active { color: var(--native-tab-active-dark, rgb(52 211 153)); }
60
79
 
61
80
  @media (prefers-reduced-transparency: reduce) {
62
81
  .native-tab-bar {
@@ -3,17 +3,36 @@ interface NativeTabItemProps {
3
3
  label: string
4
4
  href: string
5
5
  icon: string
6
+ /** A count on the icon. Zero and empty draw nothing. */
7
+ badge?: string | number
8
+ /**
9
+ * More paths that belong to this tab, space-separated: a workout opened from
10
+ * the calendar keeps the Calendar tab lit. A tab bar is a <nav>, where a
11
+ * link is otherwise current only on its own page.
12
+ */
13
+ match?: string
6
14
  }
7
15
 
8
- const { href, icon, label } = defineProps<NativeTabItemProps>()
16
+ const { href, icon, label, badge = '', match = '' } = defineProps<NativeTabItemProps>()
17
+ const hasBadge = badge !== '' && badge !== 0 && badge !== '0'
9
18
  </script>
10
19
 
20
+ <!-- `draggable="false"`: a slow tap on a busy page reaches the WebView as a
21
+ long press, and a draggable link starts a native drag instead of
22
+ navigating. -->
11
23
  <StxLink
12
24
  to="{{ href }}"
13
25
  aria-label="{{ label }}"
14
26
  activeClass="is-active"
27
+ activeMatch="{{ match }}"
15
28
  class="native-tab-item"
29
+ draggable="false"
16
30
  >
17
- <i aria-hidden="true" class="{{ icon }} native-tab-icon"></i>
31
+ <span class="native-tab-glyph">
32
+ <i aria-hidden="true" class="{{ icon }} native-tab-icon"></i>
33
+ @if(hasBadge)
34
+ <span class="native-tab-badge">{{ badge }}</span>
35
+ @endif
36
+ </span>
18
37
  <span>{{ label }}</span>
19
38
  </StxLink>
@@ -14,9 +14,13 @@ export { default as Audio } from './Audio.stx'
14
14
  export { default as Image } from './Image.stx'
15
15
  export { default as NativeAppShell } from './NativeAppShell.stx'
16
16
  export { default as NativeHealthButton } from './NativeHealthButton.stx'
17
+ export { default as NativeNavBar } from './NativeNavBar.stx'
17
18
  export { default as NativeNetworkBanner } from './NativeNetworkBanner.stx'
18
19
  export { default as NativePermissionButton } from './NativePermissionButton.stx'
20
+ export { default as NativePullToRefresh } from './NativePullToRefresh.stx'
21
+ export { default as NativeSegmentedControl } from './NativeSegmentedControl.stx'
19
22
  export { default as NativeShareButton } from './NativeShareButton.stx'
23
+ export { default as NativeSheet } from './NativeSheet.stx'
20
24
  export { default as NativeTabBar } from './NativeTabBar.stx'
21
25
  export { default as NativeTabItem } from './NativeTabItem.stx'
22
26
  export { default as Video } from './Video.stx'