@stacksjs/defaults 0.75.22 → 0.75.24
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 +29 -1
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/NativeAppShell.stx +55 -6
- package/resources/components/NativeComponents.test.ts +53 -0
- package/resources/components/NativeNavBar.stx +151 -0
- package/resources/components/NativeNetworkBanner.stx +11 -2
- package/resources/components/NativePullToRefresh.stx +99 -0
- package/resources/components/NativeSegmentedControl.stx +95 -0
- package/resources/components/NativeSheet.stx +132 -0
- package/resources/components/NativeTabBar.stx +22 -3
- package/resources/components/NativeTabItem.stx +21 -2
- package/resources/components/index.ts +4 -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 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,
|
|
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.
|
|
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.
|
package/ide/vscode/package.json
CHANGED
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.24",
|
|
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.
|
|
58
|
+
"@stacksjs/mobile": "^0.75.24",
|
|
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
|
|
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)
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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('[data-stx-scope]:has(> .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,151 @@
|
|
|
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 this box and on stx's own wrapper: a sticky element
|
|
42
|
+
sticks within its parent, and either box would give the bar 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
|
+
/* The element stx wraps each component in is a box too, as tall as the bar
|
|
67
|
+
and its title; the bar stuck inside it and scrolled away with the title.
|
|
68
|
+
Both boxes step aside so the bar sticks to the page. */
|
|
69
|
+
.native-nav,
|
|
70
|
+
[data-stx-scope]:has(> .native-nav) { display: contents; }
|
|
71
|
+
|
|
72
|
+
.native-nav-bar {
|
|
73
|
+
position: sticky;
|
|
74
|
+
top: env(safe-area-inset-top, 0px);
|
|
75
|
+
z-index: 30;
|
|
76
|
+
display: grid;
|
|
77
|
+
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
|
78
|
+
gap: 0.5rem;
|
|
79
|
+
align-items: center;
|
|
80
|
+
min-height: 2.75rem;
|
|
81
|
+
padding: 0 0.75rem;
|
|
82
|
+
border-bottom: 1px solid transparent;
|
|
83
|
+
transition: background-color 180ms ease, border-color 180ms ease;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.native-nav-bar.is-scrolled {
|
|
87
|
+
border-color: var(--native-bar-border, rgb(148 163 184 / 0.25));
|
|
88
|
+
background: var(--native-bar-background, rgb(248 250 252 / 0.86));
|
|
89
|
+
backdrop-filter: blur(20px) saturate(180%);
|
|
90
|
+
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.dark .native-nav-bar.is-scrolled {
|
|
94
|
+
border-color: var(--native-bar-border-dark, rgb(51 65 85 / 0.6));
|
|
95
|
+
background: var(--native-bar-background-dark, rgb(2 6 23 / 0.86));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.native-nav-side { display: flex; gap: 0.25rem; align-items: center; min-width: 0; }
|
|
99
|
+
.native-nav-actions { justify-content: flex-end; }
|
|
100
|
+
|
|
101
|
+
.native-nav-title {
|
|
102
|
+
max-width: 60vw;
|
|
103
|
+
overflow: hidden;
|
|
104
|
+
font-size: 1.0625rem;
|
|
105
|
+
font-weight: 600;
|
|
106
|
+
text-overflow: ellipsis;
|
|
107
|
+
white-space: nowrap;
|
|
108
|
+
opacity: 0;
|
|
109
|
+
transform: translateY(4px);
|
|
110
|
+
transition: opacity 180ms ease, transform 180ms ease;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.native-nav-bar.is-collapsed .native-nav-title { opacity: 1; transform: none; }
|
|
114
|
+
|
|
115
|
+
.native-nav-back {
|
|
116
|
+
display: inline-flex;
|
|
117
|
+
gap: 0.1rem;
|
|
118
|
+
align-items: center;
|
|
119
|
+
min-height: 2.75rem;
|
|
120
|
+
margin-left: -0.4rem;
|
|
121
|
+
padding-right: 0.5rem;
|
|
122
|
+
color: var(--native-accent, rgb(37 99 235));
|
|
123
|
+
font-size: 1.0625rem;
|
|
124
|
+
text-decoration: none;
|
|
125
|
+
-webkit-touch-callout: none;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.dark .native-nav-back { color: var(--native-accent-dark, rgb(96 165 250)); }
|
|
129
|
+
.native-nav-back:active { opacity: 0.55; }
|
|
130
|
+
.native-nav-back-icon { width: 1.6rem; height: 1.6rem; }
|
|
131
|
+
|
|
132
|
+
.native-nav-large-title.is-empty { display: none; }
|
|
133
|
+
|
|
134
|
+
.native-nav-large-title {
|
|
135
|
+
margin: 0.125rem 0 0.5rem;
|
|
136
|
+
padding: 0 1rem;
|
|
137
|
+
font-size: 2.125rem;
|
|
138
|
+
font-weight: 700;
|
|
139
|
+
line-height: 1.15;
|
|
140
|
+
letter-spacing: -0.02em;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@media (prefers-reduced-motion: reduce) {
|
|
144
|
+
.native-nav-bar, .native-nav-title { transition: none; }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@media (prefers-reduced-transparency: reduce) {
|
|
148
|
+
.native-nav-bar.is-scrolled { backdrop-filter: none; -webkit-backdrop-filter: none; background: var(--native-bar-solid, rgb(248 250 252)); }
|
|
149
|
+
.dark .native-nav-bar.is-scrolled { background: var(--native-bar-solid-dark, rgb(2 6 23)); }
|
|
150
|
+
}
|
|
151
|
+
</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
|
-
<
|
|
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'
|