@octane-xplat/workshop 0.0.0-nightly.g2e22171
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/LICENSE +21 -0
- package/MOBILE.md +143 -0
- package/README.md +307 -0
- package/dist/node/index.js +50 -0
- package/package.json +127 -0
- package/src/catalog.ts +58 -0
- package/src/contract.ts +257 -0
- package/src/controls.ts +159 -0
- package/src/discover.mjs +584 -0
- package/src/errors.ts +47 -0
- package/src/fixture.ts +158 -0
- package/src/index.ts +6 -0
- package/src/macos/controller.ts +170 -0
- package/src/macos/index.macos.ts +2 -0
- package/src/macos/shell.macos.mjs +673 -0
- package/src/macos/stage.macos.ts +182 -0
- package/src/macos-node.mjs +95 -0
- package/src/macos-vite.mjs +47 -0
- package/src/mobile/controller.ts +134 -0
- package/src/mobile/entry.mobile.ts +607 -0
- package/src/mobile/index.mobile.ts +3 -0
- package/src/mobile/mount.mobile.ts +60 -0
- package/src/mobile/tree.mobile.tsrx +17 -0
- package/src/mobile-node.mjs +126 -0
- package/src/mobile-regex.mjs +60 -0
- package/src/node.d.ts +65 -0
- package/src/node.mjs +536 -0
- package/src/session.ts +520 -0
- package/src/vite-mobile.d.ts +21 -0
- package/src/vite-mobile.mjs +101 -0
- package/src/vite.d.ts +11 -0
- package/src/vite.mjs +315 -0
- package/src/web/entry.web.ts +270 -0
- package/src/web/index.web.ts +7 -0
- package/src/web/mount.web.ts +86 -0
- package/src/web/shell.css +449 -0
- package/src/web/shell.web.tsrx +759 -0
- package/src/windows/preflight.mjs +113 -0
- package/types/generated/.tsrx-typegen-manifest.json +24 -0
- package/types/generated/catalog.d.ts +30 -0
- package/types/generated/contract.d.ts +145 -0
- package/types/generated/controls.d.ts +28 -0
- package/types/generated/errors.d.ts +13 -0
- package/types/generated/fixture.d.ts +53 -0
- package/types/generated/index.d.ts +6 -0
- package/types/generated/macos/controller.d.ts +52 -0
- package/types/generated/macos/index.macos.d.ts +2 -0
- package/types/generated/macos/stage.macos.d.ts +10 -0
- package/types/generated/mobile/controller.d.ts +31 -0
- package/types/generated/mobile/entry.mobile.d.ts +22 -0
- package/types/generated/mobile/index.mobile.d.ts +3 -0
- package/types/generated/mobile/mount.mobile.d.ts +4 -0
- package/types/generated/mobile/tree.mobile.d.ts +4 -0
- package/types/generated/session.d.ts +84 -0
- package/types/generated/web/entry.web.d.ts +53 -0
- package/types/generated/web/index.web.d.ts +7 -0
- package/types/generated/web/mount.web.d.ts +2 -0
- package/types/generated/web/shell.web.d.ts +4 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Application,
|
|
3
|
+
AndroidApplication,
|
|
4
|
+
Button,
|
|
5
|
+
ContentView,
|
|
6
|
+
Frame,
|
|
7
|
+
GridLayout,
|
|
8
|
+
Label,
|
|
9
|
+
Page,
|
|
10
|
+
ScrollView,
|
|
11
|
+
StackLayout,
|
|
12
|
+
TextField,
|
|
13
|
+
View,
|
|
14
|
+
} from '@nativescript/core'
|
|
15
|
+
|
|
16
|
+
import type { CatalogEntry } from '../catalog'
|
|
17
|
+
import type { WorkshopTarget } from '../contract'
|
|
18
|
+
import { UNSET, isUnsettable } from '../controls'
|
|
19
|
+
import { WorkshopSession } from '../session'
|
|
20
|
+
import { MobileController, mobileShellMode } from './controller'
|
|
21
|
+
import { createMobileStageHost } from './mount.mobile'
|
|
22
|
+
import { android, androidx } from '@octane-xplat/platform/android'
|
|
23
|
+
import {
|
|
24
|
+
UIKeyCommand,
|
|
25
|
+
UIKeyModifierFlags,
|
|
26
|
+
UIMenuElementAttributes,
|
|
27
|
+
UIViewController,
|
|
28
|
+
} from '@octane-xplat/platform/ios'
|
|
29
|
+
|
|
30
|
+
export interface MobileWorkshopOptions {
|
|
31
|
+
catalog: readonly CatalogEntry[]
|
|
32
|
+
diagnostics?: readonly { message: string }[]
|
|
33
|
+
target: 'ios' | 'android'
|
|
34
|
+
initialStoryId?: string | null
|
|
35
|
+
globalProviders?: readonly unknown[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MobileWorkshopRuntime {
|
|
39
|
+
controller: MobileController
|
|
40
|
+
page: Page
|
|
41
|
+
dispose(): Promise<void>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let runtime: MobileWorkshopRuntime | undefined
|
|
45
|
+
export function catalogDidUpdate(
|
|
46
|
+
catalog: readonly CatalogEntry[],
|
|
47
|
+
diagnostics: readonly { message: string }[] = [],
|
|
48
|
+
): void {
|
|
49
|
+
runtime?.controller.updateCatalog(catalog, diagnostics)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CSS = `
|
|
53
|
+
.xw-toolbar { padding: 8; background-color: #eeeeee; }
|
|
54
|
+
.xw-panel { padding: 12; background-color: #f5f5f5; }
|
|
55
|
+
.xw-status { padding: 8; }
|
|
56
|
+
.xw-error { color: #a01818; }
|
|
57
|
+
.xw-action { margin: 4; min-height: 44; }
|
|
58
|
+
.xw-action:highlighted { background-color: #cdddf0; }
|
|
59
|
+
.xw-action:focus { border-color: #175cd3; border-width: 2; }
|
|
60
|
+
.xw-field { margin-bottom: 8; min-height: 44; }
|
|
61
|
+
`
|
|
62
|
+
|
|
63
|
+
/** A host-native shell. Its stage ContentView is attached once for its lifetime. */
|
|
64
|
+
export function startWorkshop(options: MobileWorkshopOptions): MobileWorkshopRuntime {
|
|
65
|
+
const actual: WorkshopTarget = Application.android ? 'android' : 'ios'
|
|
66
|
+
if (actual !== options.target) {
|
|
67
|
+
throw new Error(`Workshop target ${options.target} does not match host ${actual}`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (runtime) {
|
|
71
|
+
throw new Error('Dispose the existing workshop before starting another')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const page = new Page()
|
|
75
|
+
page.actionBarHidden = true
|
|
76
|
+
page.css = CSS
|
|
77
|
+
page.iosOverflowSafeArea = false
|
|
78
|
+
const root = new GridLayout()
|
|
79
|
+
root.rows = 'auto,auto,*'
|
|
80
|
+
const toolbar = new GridLayout()
|
|
81
|
+
toolbar.columns = '*,*,*,*'
|
|
82
|
+
toolbar.className = 'xw-toolbar'
|
|
83
|
+
append(root, toolbar)
|
|
84
|
+
const status = new Label()
|
|
85
|
+
status.textWrap = true
|
|
86
|
+
status.className = 'xw-status'
|
|
87
|
+
GridLayout.setRow(status as unknown as View, 1)
|
|
88
|
+
append(root, status)
|
|
89
|
+
const body = new GridLayout()
|
|
90
|
+
GridLayout.setRow(body as unknown as View, 2)
|
|
91
|
+
append(root, body)
|
|
92
|
+
const stage = new ContentView()
|
|
93
|
+
append(body, stage)
|
|
94
|
+
const catalog = new ScrollView()
|
|
95
|
+
catalog.className = 'xw-panel'
|
|
96
|
+
append(body, catalog)
|
|
97
|
+
const controls = new ScrollView()
|
|
98
|
+
controls.className = 'xw-panel'
|
|
99
|
+
append(body, controls)
|
|
100
|
+
page.content = root
|
|
101
|
+
const session = new WorkshopSession({
|
|
102
|
+
host: createMobileStageHost(stage),
|
|
103
|
+
target: actual,
|
|
104
|
+
globalProviders: options.globalProviders,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
let opener: Pick<View, 'focus'> | undefined
|
|
108
|
+
let lastRevision = -1
|
|
109
|
+
let catalogCollapsed = false
|
|
110
|
+
let controlsCollapsed = false
|
|
111
|
+
const collapsedGroups = new Set<string>()
|
|
112
|
+
let disposed = false
|
|
113
|
+
let syncEscapeCommand: (() => void) | undefined
|
|
114
|
+
|
|
115
|
+
// NativeScript's symbol setter index signatures make valid subclasses
|
|
116
|
+
// structurally incompatible under strictFunctionTypes; constrain the
|
|
117
|
+
// cast to this native tree insertion boundary.
|
|
118
|
+
function append(
|
|
119
|
+
parent: GridLayout | StackLayout,
|
|
120
|
+
child: GridLayout | StackLayout | Label | Button | ScrollView | ContentView | TextField,
|
|
121
|
+
): void {
|
|
122
|
+
parent.addChild(child as unknown as View)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function label(text: string): Label {
|
|
126
|
+
const view = new Label()
|
|
127
|
+
view.text = text
|
|
128
|
+
view.textWrap = true
|
|
129
|
+
view.accessibilityLabel = text
|
|
130
|
+
return view
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function action(parent: StackLayout | GridLayout, text: string, run: () => void): Button {
|
|
134
|
+
const button = new Button()
|
|
135
|
+
button.text = text
|
|
136
|
+
button.accessibilityLabel = text
|
|
137
|
+
button.className = 'xw-action'
|
|
138
|
+
button.on(Button.tapEvent, run)
|
|
139
|
+
if (parent === toolbar) {
|
|
140
|
+
GridLayout.setColumn(button as unknown as View, toolbar.getChildrenCount())
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
append(parent, button)
|
|
144
|
+
return button
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function closePanel(): void {
|
|
148
|
+
controller.panel = null
|
|
149
|
+
layout()
|
|
150
|
+
opener?.focus()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Host keys may dismiss shell chrome only while this page owns the host. */
|
|
154
|
+
function dismissAllowed(): boolean {
|
|
155
|
+
return Frame.topmost()?.currentPage === page && !page.modal
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** One Back/Escape step; false leaves the key to the host/story. */
|
|
159
|
+
function tryDismiss(): boolean {
|
|
160
|
+
const dismissed = controller.dismissOverlay()
|
|
161
|
+
if (!dismissed) {
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
layout()
|
|
166
|
+
if (dismissed === 'panel') {
|
|
167
|
+
opener?.focus()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return true
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function toggle(panel: 'stories' | 'controls', button: Button): void {
|
|
174
|
+
opener = button
|
|
175
|
+
if (mobileShellMode(root.getActualSize().width, root.getActualSize().height) === 'wide') {
|
|
176
|
+
if (panel === 'stories') {
|
|
177
|
+
catalogCollapsed = !catalogCollapsed
|
|
178
|
+
} else {
|
|
179
|
+
controlsCollapsed = !controlsCollapsed
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
controller.panel = controller.panel === panel ? null : panel
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
layout()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function layout(): void {
|
|
189
|
+
const size = root.getActualSize()
|
|
190
|
+
storiesButton.visibility = controller.stageOnly ? 'collapse' : 'visible'
|
|
191
|
+
controlsButton.visibility = controller.stageOnly ? 'collapse' : 'visible'
|
|
192
|
+
toolbar.columns = controller.stageOnly ? '0,0,*,*' : '*,*,*,*'
|
|
193
|
+
const wide = mobileShellMode(size.width, size.height) === 'wide' && !controller.stageOnly
|
|
194
|
+
body.columns = wide ? `${catalogCollapsed ? 0 : 240},*,${controlsCollapsed ? 0 : 280}` : '*,0,0'
|
|
195
|
+
GridLayout.setColumn(stage as unknown as View, wide ? 1 : 0)
|
|
196
|
+
GridLayout.setColumn(catalog as unknown as View, wide ? 0 : 1)
|
|
197
|
+
GridLayout.setColumn(controls as unknown as View, 2)
|
|
198
|
+
catalog.visibility = wide && !catalogCollapsed ? 'visible' : 'collapse'
|
|
199
|
+
controls.visibility = wide && !controlsCollapsed ? 'visible' : 'collapse'
|
|
200
|
+
stage.visibility = 'visible'
|
|
201
|
+
if (!wide && controller.panel) {
|
|
202
|
+
const active = controller.panel === 'stories' ? catalog : controls
|
|
203
|
+
GridLayout.setColumn(active as unknown as View, 0)
|
|
204
|
+
active.visibility = 'visible'
|
|
205
|
+
stage.visibility = 'hidden'
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
syncEscapeCommand?.()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function renderCatalog(): void {
|
|
212
|
+
const content = new StackLayout()
|
|
213
|
+
action(content, 'Close Stories', () => {
|
|
214
|
+
catalogCollapsed = true
|
|
215
|
+
closePanel()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const search = new TextField()
|
|
219
|
+
search.hint = 'Find a story by title or ID'
|
|
220
|
+
search.accessibilityLabel = 'Search stories'
|
|
221
|
+
search.text = controller.query
|
|
222
|
+
search.className = 'xw-field'
|
|
223
|
+
const results = new StackLayout()
|
|
224
|
+
function fill(): void {
|
|
225
|
+
results.removeChildren()
|
|
226
|
+
const query = controller.query.toLowerCase()
|
|
227
|
+
const entries = [...controller.catalog].sort(
|
|
228
|
+
(a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
function group(prefix: string, items: readonly CatalogEntry[]): void {
|
|
232
|
+
const groups = new Map<string, CatalogEntry[]>()
|
|
233
|
+
for (const entry of items) {
|
|
234
|
+
const depth = prefix ? prefix.split('/').length : 0
|
|
235
|
+
const tail = entry.title
|
|
236
|
+
.split('/')
|
|
237
|
+
.map((part) => part.trim())
|
|
238
|
+
.filter(Boolean)
|
|
239
|
+
.slice(depth)
|
|
240
|
+
.join(' / ')
|
|
241
|
+
|
|
242
|
+
const separator = tail.indexOf('/')
|
|
243
|
+
if (separator < 0) {
|
|
244
|
+
action(
|
|
245
|
+
results,
|
|
246
|
+
`${entry.title} (${entry.id})${entry.applicable ? '' : ' — unsupported-by-story'}`,
|
|
247
|
+
() => {
|
|
248
|
+
void controller.select(entry.id)
|
|
249
|
+
},
|
|
250
|
+
)
|
|
251
|
+
} else {
|
|
252
|
+
const name = `${prefix}${prefix ? ' / ' : ''}${tail.slice(0, separator).trim()}`
|
|
253
|
+
const members = groups.get(name) ?? []
|
|
254
|
+
members.push(entry)
|
|
255
|
+
groups.set(name, members)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const [name, members] of groups) {
|
|
260
|
+
action(results, `${collapsedGroups.has(name) ? 'Expand' : 'Collapse'} ${name}`, () => {
|
|
261
|
+
if (collapsedGroups.has(name)) {
|
|
262
|
+
collapsedGroups.delete(name)
|
|
263
|
+
} else {
|
|
264
|
+
collapsedGroups.add(name)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
fill()
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
if (query || !collapsedGroups.has(name)) {
|
|
271
|
+
group(name, members)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
group(
|
|
277
|
+
'',
|
|
278
|
+
entries.filter((e) => `${e.title} ${e.id}`.toLowerCase().includes(query)),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if (!entries.length) {
|
|
282
|
+
append(
|
|
283
|
+
results,
|
|
284
|
+
label(
|
|
285
|
+
'No stories. Add src/counter.stories.tsrx with an exported defineStory declaration.',
|
|
286
|
+
),
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
search.on(TextField.textChangeEvent, () => {
|
|
292
|
+
controller.query = search.text
|
|
293
|
+
fill()
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
append(content, search)
|
|
297
|
+
append(content, results)
|
|
298
|
+
fill()
|
|
299
|
+
catalog.content = content
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function renderControls(): void {
|
|
303
|
+
const content = new StackLayout()
|
|
304
|
+
action(content, 'Close Controls', () => {
|
|
305
|
+
controlsCollapsed = true
|
|
306
|
+
closePanel()
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
const story = session.currentStory
|
|
310
|
+
if (story) {
|
|
311
|
+
append(
|
|
312
|
+
content,
|
|
313
|
+
label(
|
|
314
|
+
story.capabilities?.length
|
|
315
|
+
? `Required capabilities: ${story.capabilities.join(', ')}. Permissions are not granted by selection.`
|
|
316
|
+
: 'Local story',
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
for (const [key, control] of Object.entries(story.controls ?? {})) {
|
|
321
|
+
const spec = control as { type: string; options?: readonly (string | number | boolean)[] }
|
|
322
|
+
const applied = label(`${key}: ${String(session.currentProps?.[key] ?? 'Unset')}`)
|
|
323
|
+
append(content, applied)
|
|
324
|
+
const error = label('')
|
|
325
|
+
error.className = 'xw-error'
|
|
326
|
+
const commit = (value: string | number | boolean | typeof UNSET) => {
|
|
327
|
+
error.text = controller.control(key, value) ?? ''
|
|
328
|
+
error.accessibilityLabel = error.text
|
|
329
|
+
if (!error.text) {
|
|
330
|
+
applied.text = `${key}: ${String(session.currentProps?.[key] ?? 'Unset')}`
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (spec.type === 'boolean') {
|
|
335
|
+
action(content, `${key}: true`, () => commit(true))
|
|
336
|
+
action(content, `${key}: false`, () => commit(false))
|
|
337
|
+
} else if (spec.type === 'enum') {
|
|
338
|
+
for (const option of spec.options ?? []) {
|
|
339
|
+
action(content, `${key}: ${String(option)}`, () => commit(option))
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
const field = new TextField()
|
|
343
|
+
field.text = String(session.currentProps?.[key] ?? '')
|
|
344
|
+
field.accessibilityLabel = key
|
|
345
|
+
field.className = 'xw-field'
|
|
346
|
+
// Apply explicitly: composition/drafts remain owned by the field,
|
|
347
|
+
// and invalid numbers never reach the stage.
|
|
348
|
+
append(content, field)
|
|
349
|
+
action(content, `Apply ${key}`, () => commit(field.text))
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (isUnsettable(story, key)) {
|
|
353
|
+
action(content, `Unset ${key}`, () => commit(UNSET))
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
append(content, error)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
append(content, label('Targets — launch another actual host from the CLI'))
|
|
361
|
+
for (const target of ['web', 'ios', 'android', 'macos', 'windows', 'linux'] as const) {
|
|
362
|
+
const selected = controller.catalog.find((e) => e.id === controller.selectedId)
|
|
363
|
+
append(
|
|
364
|
+
content,
|
|
365
|
+
label(
|
|
366
|
+
`${target}: ${selected && !selected.targets.includes(target) ? 'unsupported-by-story' : 'unqualified backend / separate launch'}${target === actual ? ' (current host)' : ''}`,
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
append(content, label('Stage settings'))
|
|
372
|
+
action(content, 'Default background', () => {
|
|
373
|
+
stage.backgroundColor = '#ffffff' as any
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
action(content, 'Dark background', () => {
|
|
377
|
+
stage.backgroundColor = '#242424' as any
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
action(content, 'Available stage size', () => {
|
|
381
|
+
stage.width = 'auto'
|
|
382
|
+
stage.height = 'auto'
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
action(content, 'Bounded stage size: 320 × 400', () => {
|
|
386
|
+
stage.width = 320
|
|
387
|
+
stage.height = 400
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
controls.content = content
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function refresh(): void {
|
|
394
|
+
if (disposed) {
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const snapshot = session.state
|
|
399
|
+
const entry = controller.catalog.find((e) => e.id === controller.selectedId)
|
|
400
|
+
status.text = `${actual} · unqualified backend · ${controller.missingId ? `Missing story: ${controller.missingId}` : entry && !entry.applicable ? `${entry.id}: unsupported-by-story` : (snapshot.storyId ?? 'Choose a story')} · ${snapshot.phase}${snapshot.stale ? ' · STALE' : ''}${snapshot.failure ? ` · ${snapshot.failure.phase}: ${snapshot.failure.message}` : ''}`
|
|
401
|
+
status.accessibilityLabel = status.text
|
|
402
|
+
if (lastRevision !== snapshot.revision) {
|
|
403
|
+
lastRevision = snapshot.revision
|
|
404
|
+
renderControls()
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
layout()
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const controller = new MobileController(session, options.catalog, () => {
|
|
411
|
+
renderCatalog()
|
|
412
|
+
refresh()
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
const storiesButton = action(toolbar, 'Stories', () => toggle('stories', storiesButton))
|
|
416
|
+
const controlsButton = action(toolbar, 'Controls', () => toggle('controls', controlsButton))
|
|
417
|
+
action(toolbar, 'Reset', () => {
|
|
418
|
+
void session.reset()
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
action(toolbar, 'Stage / Return', () => {
|
|
422
|
+
controller.stageOnly = !controller.stageOnly
|
|
423
|
+
controller.panel = null
|
|
424
|
+
layout()
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
const unsubscribe = session.subscribe(refresh)
|
|
428
|
+
root.on(View.layoutChangedEvent, layout)
|
|
429
|
+
const back = (event: any) => {
|
|
430
|
+
// A story-owned modal keeps OS Back. Shell dismissal takes precedence
|
|
431
|
+
// only on this page, never in an unrelated navigation root.
|
|
432
|
+
if (dismissAllowed() && tryDismiss()) {
|
|
433
|
+
event.cancel = true
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
Application.android?.on(AndroidApplication.activityBackPressedEvent, back)
|
|
438
|
+
|
|
439
|
+
// Android hardware Escape: unhandled key events bubble from the focused
|
|
440
|
+
// view to its ancestors, so a story view that consumes Escape keeps it and
|
|
441
|
+
// IME composition is handled before dispatch. Consuming the down and its
|
|
442
|
+
// paired up prevents any host-level Escape-to-back translation when the
|
|
443
|
+
// shell dismissed. The listener lives on this page's root view only.
|
|
444
|
+
function installAndroidDismissal(): () => void {
|
|
445
|
+
const ViewCompat = androidx?.core?.view?.ViewCompat
|
|
446
|
+
if (typeof ViewCompat?.addOnUnhandledKeyEventListener !== 'function') {
|
|
447
|
+
return () => {}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
let attached: android.view.View | undefined
|
|
451
|
+
let escapeDown = false
|
|
452
|
+
const listener = new androidx.core.view.ViewCompat.OnUnhandledKeyEventListenerCompat({
|
|
453
|
+
onUnhandledKeyEvent(_view: android.view.View, event: android.view.KeyEvent): boolean {
|
|
454
|
+
if (event.getKeyCode() !== android.view.KeyEvent.KEYCODE_ESCAPE) {
|
|
455
|
+
return false
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const action = event.getAction()
|
|
459
|
+
if (action === android.view.KeyEvent.ACTION_DOWN) {
|
|
460
|
+
// Repeats only dispatch while we hold the press.
|
|
461
|
+
if (event.getRepeatCount() > 0) {
|
|
462
|
+
return escapeDown
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
escapeDown = dismissAllowed() && tryDismiss()
|
|
466
|
+
return escapeDown
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (action === android.view.KeyEvent.ACTION_UP) {
|
|
470
|
+
const handled = escapeDown
|
|
471
|
+
escapeDown = false
|
|
472
|
+
return handled
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return escapeDown
|
|
476
|
+
},
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
const attach = () => {
|
|
480
|
+
const native = root.android as android.view.View | undefined
|
|
481
|
+
if (!native || attached === native) {
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
ViewCompat.addOnUnhandledKeyEventListener(native, listener)
|
|
486
|
+
attached = native
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
root.on(View.loadedEvent, attach)
|
|
490
|
+
if (root.isLoaded) {
|
|
491
|
+
attach()
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return () => {
|
|
495
|
+
root.off(View.loadedEvent, attach)
|
|
496
|
+
if (attached) {
|
|
497
|
+
ViewCompat.removeOnUnhandledKeyEventListener(attached, listener)
|
|
498
|
+
attached = undefined
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// iOS hardware Escape: a disabled-by-default key command on the page view
|
|
504
|
+
// controller resolves through the responder chain to the page's supported
|
|
505
|
+
// escape hook, so story views nearer the first responder and presented
|
|
506
|
+
// story modals keep priority. Enabling it only while a shell dismissal is
|
|
507
|
+
// available leaves the key (and marked-text composition) with the story.
|
|
508
|
+
// Hosts without UIViewController.addKeyCommand (iOS 15) keep the toolbar
|
|
509
|
+
// and accessibility-escape paths only.
|
|
510
|
+
function installIosDismissal(): () => void {
|
|
511
|
+
const vc = (page as any).ios as UIViewController | undefined
|
|
512
|
+
if (!vc?.respondsToSelector?.('addKeyCommand:')) {
|
|
513
|
+
return () => {}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const command = UIKeyCommand.keyCommandWithInputModifierFlagsAction(
|
|
517
|
+
UIKeyInputEscape,
|
|
518
|
+
0 as UIKeyModifierFlags,
|
|
519
|
+
'accessibilityPerformEscape',
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
const previous = page.onAccessibilityPerformEscape
|
|
523
|
+
// The VoiceOver escape gesture and the key command reach the same
|
|
524
|
+
// hook; both apply shell dismissal before any earlier handler.
|
|
525
|
+
page.onAccessibilityPerformEscape = () => {
|
|
526
|
+
if (dismissAllowed() && tryDismiss()) {
|
|
527
|
+
return true
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return previous?.() ?? false
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
vc.addKeyCommand(command)
|
|
534
|
+
// UIMenuElementAttributes.Disabled is an ambient const enum, which this
|
|
535
|
+
// configuration reads only in type position.
|
|
536
|
+
syncEscapeCommand = () => {
|
|
537
|
+
command.attributes =
|
|
538
|
+
controller.panel || controller.stageOnly
|
|
539
|
+
? (0 as UIMenuElementAttributes)
|
|
540
|
+
: (1 as UIMenuElementAttributes)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
syncEscapeCommand()
|
|
544
|
+
return () => {
|
|
545
|
+
syncEscapeCommand = undefined
|
|
546
|
+
page.onAccessibilityPerformEscape = previous
|
|
547
|
+
vc.removeKeyCommand(command)
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const releaseDismissal = Application.ios
|
|
552
|
+
? installIosDismissal()
|
|
553
|
+
: Application.android
|
|
554
|
+
? installAndroidDismissal()
|
|
555
|
+
: () => {}
|
|
556
|
+
|
|
557
|
+
const exit = () => {
|
|
558
|
+
void runtime
|
|
559
|
+
?.dispose()
|
|
560
|
+
.catch((error) => console.error('Workshop cleanup requires restart:', error))
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
Application.on(Application.exitEvent, exit)
|
|
564
|
+
runtime = {
|
|
565
|
+
controller,
|
|
566
|
+
page,
|
|
567
|
+
async dispose() {
|
|
568
|
+
if (disposed) {
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
disposed = true
|
|
573
|
+
unsubscribe()
|
|
574
|
+
root.off(View.layoutChangedEvent, layout)
|
|
575
|
+
releaseDismissal()
|
|
576
|
+
Application.android?.off(AndroidApplication.activityBackPressedEvent, back)
|
|
577
|
+
Application.off(Application.exitEvent, exit)
|
|
578
|
+
await controller.dispose()
|
|
579
|
+
if (session.state.phase === 'restart-required') {
|
|
580
|
+
throw session.state.failure
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
runtime = undefined
|
|
584
|
+
},
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
renderCatalog()
|
|
588
|
+
renderControls()
|
|
589
|
+
const frame = new Frame()
|
|
590
|
+
frame.navigate({ create: () => page })
|
|
591
|
+
const entryPoint = { create: () => frame }
|
|
592
|
+
if (Application.started) {
|
|
593
|
+
Application.resetRootView(entryPoint)
|
|
594
|
+
} else {
|
|
595
|
+
Application.run(entryPoint)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (options.diagnostics?.length) {
|
|
599
|
+
controller.updateCatalog(options.catalog, options.diagnostics)
|
|
600
|
+
} else if (options.initialStoryId) {
|
|
601
|
+
void controller.select(options.initialStoryId)
|
|
602
|
+
} else {
|
|
603
|
+
controller.panel = 'stories'
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return runtime
|
|
607
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createNativeScriptRoot, type UniversalComponent } from '@nativescript-community/octane'
|
|
2
|
+
import { composeStory } from 'storybook/preview-api'
|
|
3
|
+
import type { ContentView } from '@nativescript/core'
|
|
4
|
+
import type { StageHost, MountRequest } from '../session'
|
|
5
|
+
import { StoryTree } from './tree.mobile.tsrx'
|
|
6
|
+
|
|
7
|
+
/** Owns only this stage root; never detaches a runner or application host. */
|
|
8
|
+
export function createMobileStageHost(stage: ContentView): StageHost {
|
|
9
|
+
return {
|
|
10
|
+
mount(request: MountRequest) {
|
|
11
|
+
// Match the pinned portable composition subset used by the web and
|
|
12
|
+
// private native harness; fixtures/providers remain session-owned.
|
|
13
|
+
const composed = composeStory(
|
|
14
|
+
{ args: request.props },
|
|
15
|
+
{ title: request.story.title, component: request.component } as any,
|
|
16
|
+
{ render: (props: any) => ({ ...request, props }) } as any,
|
|
17
|
+
{},
|
|
18
|
+
'Story',
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const prepare = (props: Record<string, unknown>): MountRequest => ({
|
|
22
|
+
...(composed(props) as unknown as MountRequest),
|
|
23
|
+
// The session owns a complete props bag. Composition must not
|
|
24
|
+
// clone injected stores/fixtures or restore a removed default.
|
|
25
|
+
props,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const root = createNativeScriptRoot(stage)
|
|
29
|
+
try {
|
|
30
|
+
root.render(StoryTree as unknown as UniversalComponent, { request: prepare(request.props) })
|
|
31
|
+
} catch (error) {
|
|
32
|
+
root.unmount()
|
|
33
|
+
throw error
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let disposed = false
|
|
37
|
+
return {
|
|
38
|
+
update(props: Record<string, unknown>) {
|
|
39
|
+
if (disposed) {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
root.render(StoryTree as unknown as UniversalComponent, { request: prepare(props) })
|
|
45
|
+
} catch (error) {
|
|
46
|
+
request.onRenderError(error)
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
unmount() {
|
|
50
|
+
if (disposed) {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
disposed = true
|
|
55
|
+
root.unmount()
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** @jsxImportSource @nativescript-community/octane */
|
|
2
|
+
import type { MountRequest } from '../session'
|
|
3
|
+
|
|
4
|
+
function Providers(p: { request: MountRequest; index: number }) {
|
|
5
|
+
const provider = p.request.providers[p.index] as ((props: any) => unknown) | undefined
|
|
6
|
+
if (!provider) return <{p.request.component} {...p.request.props} />
|
|
7
|
+
return <{provider} fixture={p.request.fixture}><Providers request={p.request} index={p.index + 1} /></{provider}>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function StoryTree(p: { request: MountRequest }) @{
|
|
11
|
+
@try {
|
|
12
|
+
<Providers request={p.request} index={0} />
|
|
13
|
+
} @catch (error) {
|
|
14
|
+
queueMicrotask(() => p.request.onRenderError(error))
|
|
15
|
+
<label text="Story failed; use Reset or Stories." />
|
|
16
|
+
}
|
|
17
|
+
}
|