@stacksjs/defaults 0.75.96 → 0.75.98

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