@krapwoo/ds-viewer 0.4.9 → 0.5.0
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/README.md +43 -0
- package/dist/cli/doctor.js +50 -2
- package/dist/cli/main.js +11 -2
- package/dist/cli/render.js +247 -0
- package/dist/cli/staticPage.js +10 -4
- package/native/catalog/CatalogButton.tsx +46 -0
- package/native/catalog/OverlayDemo.tsx +52 -0
- package/native/catalog/PhoneScreen.tsx +51 -0
- package/native/catalog/index.ts +3 -0
- package/native/catalog/overlayDemoState.ts +8 -0
- package/package.json +1 -1
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +38 -48
- package/starter-kit/components/Dialog/Dialog.catalog.tsx +16 -16
- package/starter-kit/components/Toast/Toast.catalog.tsx +9 -10
- package/starter-kit/pages/ReportIssue.catalog.tsx +17 -13
- package/starter-kit/pages/SavedTrips.catalog.tsx +114 -119
package/README.md
CHANGED
|
@@ -272,6 +272,28 @@ actually apply:
|
|
|
272
272
|
in an iPhone SE viewport (375 × 667 points), scaled down only when its cell is narrower. In the
|
|
273
273
|
browser viewer it opens that example in its own device-sized document, so overlays that portal
|
|
274
274
|
to the page (React Native's `Modal`) open inside the phone, not over the catalog.
|
|
275
|
+
- **`OverlayDemo`** — a phone with a catalog button that opens an overlay. Children get
|
|
276
|
+
`{ open, close }`; keep the overlay mounted and drive its own `visible` from `open`, so its
|
|
277
|
+
animations play. `keepTrigger` makes the button a toggle; `triggerLabel` can follow the state;
|
|
278
|
+
`initiallyOpen` starts it open.
|
|
279
|
+
```tsx
|
|
280
|
+
<OverlayDemo triggerLabel="Open sheet">
|
|
281
|
+
{({ open, close }) => (
|
|
282
|
+
<BottomSheet visible={open} onDismiss={close} footer={<Dock><Button label="Confirm" onPress={close} /></Dock>}>
|
|
283
|
+
…
|
|
284
|
+
</BottomSheet>
|
|
285
|
+
)}
|
|
286
|
+
</OverlayDemo>
|
|
287
|
+
```
|
|
288
|
+
- **`PhoneScreen`** — a whole app screen inside a `PhoneFrame`: `header` stays at the top,
|
|
289
|
+
`footer` at the phone's bottom, and the children scroll between them instead of pushing the
|
|
290
|
+
footer off the screen. `floating` is a layer over the top for toasts (position its content
|
|
291
|
+
yourself); `bodyWrapper` puts your own surface around the scrolling body.
|
|
292
|
+
```tsx
|
|
293
|
+
<PhoneFrame>
|
|
294
|
+
<PhoneScreen header={<TopNav title="Saved trips" />} footer={<Dock>…</Dock>}>…</PhoneScreen>
|
|
295
|
+
</PhoneFrame>
|
|
296
|
+
```
|
|
275
297
|
- Full-width (`itemsFill`) variants and states wrap at most **3** across; set `maxColumns` to
|
|
276
298
|
change that. A filled example that keeps its own fixed width is centred in its cell.
|
|
277
299
|
Default to one preview; add a second width only when a narrower (or wider) viewport actually
|
|
@@ -463,6 +485,27 @@ Button
|
|
|
463
485
|
dynamically-constructed references remain uncheckable rather than being guessed at. A
|
|
464
486
|
declared `intentionalStaticPreview` is taken as the author's own call, not independently
|
|
465
487
|
verified; only a real browser check confirms actual behavior.
|
|
488
|
+
- `overlay-without-device-frame` (warning) — the component imports React Native's `Modal`, which
|
|
489
|
+
opens over the whole catalog page on the web, but its page imports no `PhoneFrame`,
|
|
490
|
+
`OverlayDemo`, `PhoneScreen` or `BoundedOverlayViewport`. Conservative: it can't see
|
|
491
|
+
`import * as RN`, a re-exported `Modal`, or a Modal opened by a sub-component.
|
|
492
|
+
|
|
493
|
+
### `doctor --render`
|
|
494
|
+
|
|
495
|
+
`npx ds-viewer doctor --render` also starts the viewer, opens every page in a headless browser,
|
|
496
|
+
and adds what only a real render shows:
|
|
497
|
+
|
|
498
|
+
- `render-error` / `render-no-heading` (errors): a console error, or a page that didn't render.
|
|
499
|
+
- `render-layout` (warning): an example wider than its cell (the viewer's own check), with the
|
|
500
|
+
page and file. `render-catalog-warning` (warning): any other `[Catalog]` warning, reported once.
|
|
501
|
+
- `render-unavailable` / `render-failed` (errors): no browser, or the catalog didn't start.
|
|
502
|
+
|
|
503
|
+
It needs `puppeteer` in your project (`npm install --save-dev puppeteer`); ds-viewer doesn't
|
|
504
|
+
install a browser. It takes about two minutes for 50 pages and works with `--json` (progress
|
|
505
|
+
goes to stderr), `--ci` and `doctor.strict`, so CI can run `npx ds-viewer doctor --render --ci`.
|
|
506
|
+
It stops the viewer it started, including on Ctrl-C (exit codes 130, 143 or 129 for SIGINT,
|
|
507
|
+
SIGTERM or SIGHUP). On Windows it stops the viewer with `taskkill`; that path is unit-tested, not
|
|
508
|
+
run on a real Windows machine.
|
|
466
509
|
|
|
467
510
|
## Known gaps
|
|
468
511
|
|
package/dist/cli/doctor.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { discoverAllPageFiles, resolvePageId } from "./pageIndex.js";
|
|
5
5
|
import { readHostTsconfigOptions } from "./props.js";
|
|
6
|
-
import { readStaticPages } from "./staticPage.js";
|
|
6
|
+
import { importedNames, readStaticPages } from "./staticPage.js";
|
|
7
7
|
import { resolveGlob } from "./glob.js";
|
|
8
8
|
import { sync } from "./sync.js";
|
|
9
9
|
import { gridColumnLimit } from "../native/catalog/comparison.js";
|
|
@@ -208,6 +208,43 @@ function checkMissingComposition(pages, components) {
|
|
|
208
208
|
}
|
|
209
209
|
return issues;
|
|
210
210
|
}
|
|
211
|
+
/** The viewer exports that frame an example as a phone (or another bounded document), so an
|
|
212
|
+
* overlay that portals to the document body opens inside it instead of over the catalog. */
|
|
213
|
+
const DEVICE_FRAME_EXPORTS = ['PhoneFrame', 'OverlayDemo', 'PhoneScreen', 'BoundedOverlayViewport'];
|
|
214
|
+
/** Whether a component's own source imports React Native's `Modal` (by its imported name, so an
|
|
215
|
+
* alias still counts and an unrelated local `Modal` doesn't). Conservative by design: it can't see
|
|
216
|
+
* `import * as RN`, a re-exported `Modal`, or a Modal opened by a sub-component. */
|
|
217
|
+
function importsModal(file) {
|
|
218
|
+
try {
|
|
219
|
+
const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.ES2022, true, ts.ScriptKind.TSX);
|
|
220
|
+
return [...importedNames(source, 'react-native').values()].includes('Modal');
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** `overlay-without-device-frame`: a page whose component opens a React Native `Modal` (which
|
|
227
|
+
* portals to the document body on the web, covering the whole catalog) but whose page imports no
|
|
228
|
+
* device frame. A page whose imports couldn't be read is skipped, never assumed unframed. */
|
|
229
|
+
export function checkOverlaysFramed(pages, components, projectRoot) {
|
|
230
|
+
const byName = new Map(components.map((c) => [c.name, c]));
|
|
231
|
+
const issues = [];
|
|
232
|
+
for (const page of pages) {
|
|
233
|
+
if (!page.component || page.viewerImports === undefined)
|
|
234
|
+
continue;
|
|
235
|
+
if (page.viewerImports.some((name) => DEVICE_FRAME_EXPORTS.includes(name)))
|
|
236
|
+
continue;
|
|
237
|
+
const component = byName.get(page.component);
|
|
238
|
+
if (!component || !importsModal(path.resolve(projectRoot, component.file)))
|
|
239
|
+
continue;
|
|
240
|
+
issues.push(issue({
|
|
241
|
+
id: 'overlay-without-device-frame', severity: 'warning', ...pageRef(page),
|
|
242
|
+
message: `"${component.name}" opens a React Native Modal, which covers the whole catalog page unless its example is inside a phone.`,
|
|
243
|
+
fix: 'Wrap the example in <PhoneFrame> (or use <OverlayDemo>), so it opens inside the phone.',
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
return issues;
|
|
247
|
+
}
|
|
211
248
|
/** A function-typed prop's printed type always contains `=>` (`cli/props.ts`'s own displayed type
|
|
212
249
|
* strings — "(value: string) => void", "() => void", …); a plain data prop's never does. */
|
|
213
250
|
function isFunctionPropType(type) {
|
|
@@ -509,6 +546,15 @@ export function checkPageTypeErrors(pages, options = {}) {
|
|
|
509
546
|
}
|
|
510
547
|
return issues;
|
|
511
548
|
}
|
|
549
|
+
/** Adds findings to a finished result: applies `doctor.strict`, recounts errors and warnings, and
|
|
550
|
+
* resets the exit code. */
|
|
551
|
+
export function withExtraIssues(result, extra, strict) {
|
|
552
|
+
const added = strict ? extra.map((i) => (i.severity === 'warning' ? { ...i, severity: 'error' } : i)) : extra;
|
|
553
|
+
const issues = [...result.issues, ...added];
|
|
554
|
+
const errors = issues.filter((i) => i.severity === 'error').length;
|
|
555
|
+
const summary = { ...result.summary, errors, warnings: issues.length - errors };
|
|
556
|
+
return { ...result, issues, summary, exitCode: errors > 0 ? 1 : 0 };
|
|
557
|
+
}
|
|
512
558
|
/** "With examples" means what `checkComponentNoExamples` (Task 4) checks the *absence* of: at
|
|
513
559
|
* least one checkable page with a real example, not merely "has a page" (an `init`-drafted
|
|
514
560
|
* "Needs examples" page has one, with none). */
|
|
@@ -572,6 +618,7 @@ export function runDoctor(config) {
|
|
|
572
618
|
let issues = [
|
|
573
619
|
...collectIssues(pages, components),
|
|
574
620
|
...checkPageTypeErrors(pages, { fallbackPaths, hostRoot: config.projectRoot }),
|
|
621
|
+
...checkOverlaysFramed(pages, components, config.projectRoot),
|
|
575
622
|
...collectGlobalIssues({ syncWarnings: syncResult.warnings, tokenFileCount }),
|
|
576
623
|
];
|
|
577
624
|
// Design's own `--json` example gives a project-relative `file`; `StaticPage.file` is absolute
|
|
@@ -588,7 +635,8 @@ export function runDoctor(config) {
|
|
|
588
635
|
withExamples: countWithExamples(pages, components),
|
|
589
636
|
unboundExamples: countUnboundExamples(pages),
|
|
590
637
|
};
|
|
591
|
-
|
|
638
|
+
const pageFileById = new Map(pages.map((p) => [resolvePageId(p, p.file), path.relative(config.projectRoot, p.file)]));
|
|
639
|
+
return { version: 1, summary, update: null, issues, exitCode: summary.errors > 0 ? 1 : 0, pageFiles: pageFileById };
|
|
592
640
|
}
|
|
593
641
|
export function toDoctorJson(result) {
|
|
594
642
|
const { version, summary, update, issues } = result;
|
package/dist/cli/main.js
CHANGED
|
@@ -5,7 +5,8 @@ import { resolveConfig } from "./config.js";
|
|
|
5
5
|
import { initExistingProject, initNewProject, promptInitMode } from "./init.js";
|
|
6
6
|
import { dev } from "./dev.js";
|
|
7
7
|
import { sync } from "./sync.js";
|
|
8
|
-
import { formatHuman, runDoctor, toDoctorJson } from "./doctor.js";
|
|
8
|
+
import { formatHuman, runDoctor, toDoctorJson, withExtraIssues } from "./doctor.js";
|
|
9
|
+
import { renderCheck } from "./render.js";
|
|
9
10
|
import { explainPage, formatExplain, parseHeights } from "./explain.js";
|
|
10
11
|
import { readOwnVersion } from "./packageVersion.js";
|
|
11
12
|
import { checkForUpdate, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck } from "./updateCheck.js";
|
|
@@ -27,6 +28,7 @@ Commands:
|
|
|
27
28
|
doctor Check every page for drift and coverage gaps
|
|
28
29
|
--json print the machine-readable report instead
|
|
29
30
|
--ci exit 1 when any error was found (never on a warning alone)
|
|
31
|
+
--render also open every page in a headless browser (needs puppeteer)
|
|
30
32
|
explain <Page>
|
|
31
33
|
Print why a page's specimens are laid out the way they are
|
|
32
34
|
--heights <first>,<second> check side-by-side placement with these measured heights
|
|
@@ -116,7 +118,14 @@ export async function runInit(projectRoot, rest, options = {}) {
|
|
|
116
118
|
* plain `doctor` (no `--ci`) always returns 0 here regardless of errors found. */
|
|
117
119
|
export async function runDoctorCommand(projectRoot, rest, options = {}) {
|
|
118
120
|
const config = resolveConfig(projectRoot);
|
|
119
|
-
|
|
121
|
+
let result = runDoctor(config);
|
|
122
|
+
// `--render`: open every page in a headless browser and add what only a real render shows.
|
|
123
|
+
// Progress goes to stderr, so `--json` output stays pure JSON.
|
|
124
|
+
if (rest.includes('--render')) {
|
|
125
|
+
const files = result.pageFiles;
|
|
126
|
+
const rendered = await (options.renderCheck ?? renderCheck)(config.projectRoot, { files, log: (line) => process.stderr.write(`${line}\n`) });
|
|
127
|
+
result = withExtraIssues(result, rendered, Boolean(config.doctor?.strict));
|
|
128
|
+
}
|
|
120
129
|
// Design §4 "`--ci`: ... no update check." — `--ci` skips this whole block even when
|
|
121
130
|
// `updateCheck` is otherwise enabled; `result.update` stays `null`.
|
|
122
131
|
// Same precedence as the viewer: CI's environment variable, your own switch, the project config.
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// cli/render.ts — `doctor --render`: open every catalog page in a headless browser and report what
|
|
2
|
+
// only a real render shows (console errors, pages that fail to render, the viewer's own [Catalog]
|
|
3
|
+
// layout warnings). The browser is resolved from the project; ds-viewer never installs one.
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
const OVERFLOW = /is \d+px wide in a \d+px cell/;
|
|
9
|
+
/**
|
|
10
|
+
* Turns one page's console output into doctor findings. Console errors and a missing heading are
|
|
11
|
+
* errors. The viewer's own `[Catalog]` warnings are warnings: `render-layout` for an example that
|
|
12
|
+
* overflows its cell, `render-catalog-warning` for anything else. Other console noise (React Native
|
|
13
|
+
* web deprecations, logs) is ignored. `seen` is shared across the whole run, so a warning the
|
|
14
|
+
* catalog logs on every page (a duplicate id, an unknown `composedOf` name) is reported once.
|
|
15
|
+
*/
|
|
16
|
+
export function issuesFromPage(page, messages, hasHeading, seen = new Set()) {
|
|
17
|
+
const ref = { page: page.id, ...(page.file ? { file: page.file } : {}) };
|
|
18
|
+
const issues = [];
|
|
19
|
+
if (!hasHeading) {
|
|
20
|
+
issues.push({ id: 'render-no-heading', severity: 'error', ...ref, message: 'The page rendered without its heading, so it probably failed to render.', fix: 'Open the page in `ds-viewer dev` and check the browser console.' });
|
|
21
|
+
}
|
|
22
|
+
const pageErrors = new Set();
|
|
23
|
+
for (const m of messages) {
|
|
24
|
+
const first = m.text.split('\n')[0];
|
|
25
|
+
if (m.type === 'error' || m.type === 'pageerror') {
|
|
26
|
+
if (pageErrors.has(first))
|
|
27
|
+
continue;
|
|
28
|
+
pageErrors.add(first);
|
|
29
|
+
issues.push({ id: 'render-error', severity: 'error', ...ref, message: `Console error while rendering: ${first}`, fix: 'Open the page in `ds-viewer dev` and fix the error in the browser console.' });
|
|
30
|
+
}
|
|
31
|
+
else if ((m.type === 'warn' || m.type === 'warning') && first.startsWith('[Catalog] ')) {
|
|
32
|
+
const text = first.slice('[Catalog] '.length);
|
|
33
|
+
if (seen.has(text))
|
|
34
|
+
continue;
|
|
35
|
+
seen.add(text);
|
|
36
|
+
issues.push(OVERFLOW.test(text)
|
|
37
|
+
? { id: 'render-layout', severity: 'warning', ...ref, message: text, fix: 'Give the page a wider specimenSize; see the README page guide.' }
|
|
38
|
+
: { id: 'render-catalog-warning', severity: 'warning', ...ref, message: text, fix: 'Follow the message; the static doctor rule of the same name has the details.' });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return issues;
|
|
42
|
+
}
|
|
43
|
+
/** The project's own puppeteer, or `undefined` when it isn't installed. */
|
|
44
|
+
export function loadProjectPuppeteer(projectRoot) {
|
|
45
|
+
try {
|
|
46
|
+
return createRequire(path.join(path.resolve(projectRoot), 'package.json'))('puppeteer');
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export const RENDER_UNAVAILABLE = {
|
|
53
|
+
id: 'render-unavailable',
|
|
54
|
+
severity: 'error',
|
|
55
|
+
message: "`doctor --render` needs a headless browser, and puppeteer isn't installed in this project.",
|
|
56
|
+
fix: 'npm install --save-dev puppeteer (then run doctor --render again).',
|
|
57
|
+
};
|
|
58
|
+
const DEV_START_TIMEOUT_MS = 120_000;
|
|
59
|
+
const FIRST_LOAD_TIMEOUT_MS = 300_000;
|
|
60
|
+
const PAGE_TIMEOUT_MS = 120_000;
|
|
61
|
+
const SETTLE_MS = 900;
|
|
62
|
+
/** Stops `dev` and everything it started (Expo/Metro): the whole process tree on Windows, the
|
|
63
|
+
* whole process group elsewhere (Expo stops through dev's own signal handler). Releases the pipes
|
|
64
|
+
* so this process can exit. `os` is injectable for tests. */
|
|
65
|
+
export function stopDev(child, os = {}) {
|
|
66
|
+
const platform = os.platform ?? process.platform;
|
|
67
|
+
if (child.pid) {
|
|
68
|
+
try {
|
|
69
|
+
if (platform === 'win32')
|
|
70
|
+
(os.spawnSync ?? spawnSync)('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
71
|
+
else
|
|
72
|
+
(os.kill ?? ((pid, signal) => process.kill(pid, signal)))(-child.pid, 'SIGTERM');
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Already exited.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
child.stdout?.destroy();
|
|
79
|
+
child.stderr?.destroy();
|
|
80
|
+
child.unref();
|
|
81
|
+
}
|
|
82
|
+
/** The last few KB of dev's output, so a failed start can say why. */
|
|
83
|
+
function outputTail() {
|
|
84
|
+
let tail = '';
|
|
85
|
+
return {
|
|
86
|
+
push: (data) => {
|
|
87
|
+
tail = (tail + String(data)).slice(-4000);
|
|
88
|
+
},
|
|
89
|
+
text: () => {
|
|
90
|
+
const lines = tail.trim().split('\n').slice(-8).join('\n');
|
|
91
|
+
return lines ? `\n${lines}` : '';
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Starts the project's own `dev` in its own process group. `onSpawn` receives the child at once,
|
|
96
|
+
* so a signal during startup can still stop it. */
|
|
97
|
+
function startDev(projectRoot, onSpawn) {
|
|
98
|
+
const main = path.join(path.dirname(fileURLToPath(import.meta.url)), 'main.js');
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const child = spawn(process.execPath, [main, 'dev'], {
|
|
101
|
+
cwd: projectRoot,
|
|
102
|
+
env: { ...process.env, CI: '1', DS_VIEWER_NO_UPDATE_CHECK: '1' },
|
|
103
|
+
detached: process.platform !== 'win32',
|
|
104
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
105
|
+
});
|
|
106
|
+
onSpawn(child);
|
|
107
|
+
const output = outputTail();
|
|
108
|
+
let done = false;
|
|
109
|
+
const fail = (message) => {
|
|
110
|
+
if (done)
|
|
111
|
+
return;
|
|
112
|
+
done = true;
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
stopDev(child);
|
|
115
|
+
reject(new Error(`${message}${output.text()}`));
|
|
116
|
+
};
|
|
117
|
+
const timer = setTimeout(() => fail('Timed out waiting for ds-viewer dev to start.'), DEV_START_TIMEOUT_MS);
|
|
118
|
+
// Read continuously (so Metro's output never fills the pipe) and keep the tail for errors.
|
|
119
|
+
child.stderr?.on('data', output.push);
|
|
120
|
+
child.stdout?.on('data', (data) => {
|
|
121
|
+
output.push(data);
|
|
122
|
+
const match = String(data).match(/Starting the catalog at http:\/\/localhost:(\d+)/);
|
|
123
|
+
if (match && !done) {
|
|
124
|
+
done = true;
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
resolve({ child, port: Number(match[1]) });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
child.on('exit', (code) => fail(`ds-viewer dev exited early (code ${code}).`));
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
|
|
133
|
+
/** A sidebar href's page id; a malformed escape falls back to the raw text (like `idFromHash`). */
|
|
134
|
+
function idFromHref(href) {
|
|
135
|
+
const raw = href.slice(1);
|
|
136
|
+
try {
|
|
137
|
+
return decodeURIComponent(raw);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return raw;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Opens every sidebar page and returns its findings. `files` maps page ids to project-relative
|
|
144
|
+
* page files, for the findings' `file`. `log` receives progress lines (stderr in the CLI). `deps`
|
|
145
|
+
* replaces the browser, dev process control and exit for tests. */
|
|
146
|
+
export async function renderCheck(projectRoot, options = {}) {
|
|
147
|
+
projectRoot = path.resolve(projectRoot);
|
|
148
|
+
const deps = options.deps ?? {};
|
|
149
|
+
const puppeteer = (deps.puppeteer ?? loadProjectPuppeteer(projectRoot));
|
|
150
|
+
if (!puppeteer)
|
|
151
|
+
return [RENDER_UNAVAILABLE];
|
|
152
|
+
const stop = deps.stopDev ?? ((child) => stopDev(child));
|
|
153
|
+
const exit = deps.exit ?? ((code) => process.exit(code));
|
|
154
|
+
const log = options.log ?? (() => { });
|
|
155
|
+
// Set as soon as dev is spawned, before it's ready, so a signal during startup still stops it.
|
|
156
|
+
let devChild;
|
|
157
|
+
let port = 0;
|
|
158
|
+
let browser;
|
|
159
|
+
// Ctrl-C or a CI cancel must still stop Metro: `dev` runs in its own process group, so the
|
|
160
|
+
// terminal's signal never reaches it. Registered before the browser launches, so it runs ahead
|
|
161
|
+
// of puppeteer's own handler.
|
|
162
|
+
const handlers = Object.keys(SIGNAL_EXIT_CODES).map((signal) => {
|
|
163
|
+
const handler = () => {
|
|
164
|
+
if (devChild)
|
|
165
|
+
stop(devChild);
|
|
166
|
+
exit(SIGNAL_EXIT_CODES[signal]);
|
|
167
|
+
};
|
|
168
|
+
process.on(signal, handler);
|
|
169
|
+
return [signal, handler];
|
|
170
|
+
});
|
|
171
|
+
try {
|
|
172
|
+
// The browser first: a missing browser download shouldn't start Metro for nothing.
|
|
173
|
+
try {
|
|
174
|
+
browser = await puppeteer.launch({ headless: true });
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
return [{ ...RENDER_UNAVAILABLE, message: `Couldn't start the headless browser: ${error.message.split('\n')[0]}`, fix: 'npx puppeteer browsers install chrome' }];
|
|
178
|
+
}
|
|
179
|
+
({ port } = await (deps.startDev ?? startDev)(projectRoot, (child) => {
|
|
180
|
+
devChild = child;
|
|
181
|
+
}));
|
|
182
|
+
const home = await browser.newPage();
|
|
183
|
+
const deadline = Date.now() + FIRST_LOAD_TIMEOUT_MS;
|
|
184
|
+
for (;;) {
|
|
185
|
+
try {
|
|
186
|
+
await home.goto(`http://localhost:${port}`, { waitUntil: 'networkidle0', timeout: FIRST_LOAD_TIMEOUT_MS });
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (!String(error).includes('ERR_CONNECTION_REFUSED') || Date.now() > deadline)
|
|
191
|
+
throw error;
|
|
192
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Runs in the browser; `document` is typed loosely because the CLI compiles without DOM types.
|
|
196
|
+
const hrefs = await home.evaluate(() => {
|
|
197
|
+
const doc = globalThis.document;
|
|
198
|
+
return [...new Set(Array.from(doc.querySelectorAll('a[href^="#"]')).map((a) => a.getAttribute('href') ?? ''))];
|
|
199
|
+
});
|
|
200
|
+
await home.close();
|
|
201
|
+
const ids = hrefs.map(idFromHref).filter(Boolean);
|
|
202
|
+
if (ids.length === 0) {
|
|
203
|
+
return [{ id: 'render-failed', severity: 'error', message: 'The catalog rendered no sidebar links, so it probably failed to render at all.', fix: 'Run `ds-viewer dev` and check the browser console.' }];
|
|
204
|
+
}
|
|
205
|
+
log(`Rendering ${ids.length} pages…`);
|
|
206
|
+
const seen = new Set();
|
|
207
|
+
const issues = [];
|
|
208
|
+
for (const id of ids) {
|
|
209
|
+
// One page failing (a navigation timeout) is that page's finding; the others still run.
|
|
210
|
+
let page;
|
|
211
|
+
try {
|
|
212
|
+
page = await browser.newPage();
|
|
213
|
+
await page.setViewport({ width: 1280, height: 800 });
|
|
214
|
+
const messages = [];
|
|
215
|
+
page.on('console', (m) => messages.push({ type: m.type(), text: m.text() }));
|
|
216
|
+
page.on('pageerror', (e) => messages.push({ type: 'pageerror', text: String(e) }));
|
|
217
|
+
await page.goto(`http://localhost:${port}/#${encodeURIComponent(id)}`, { waitUntil: 'networkidle0', timeout: PAGE_TIMEOUT_MS });
|
|
218
|
+
await new Promise((r) => setTimeout(r, options.settleMs ?? SETTLE_MS));
|
|
219
|
+
const hasHeading = await page.evaluate(() => {
|
|
220
|
+
const doc = globalThis.document;
|
|
221
|
+
return Boolean(doc.querySelector('[role="heading"]')?.textContent?.trim());
|
|
222
|
+
});
|
|
223
|
+
issues.push(...issuesFromPage({ id, file: options.files?.get(id) }, messages, hasHeading, seen));
|
|
224
|
+
await page.close();
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
// A stuck page's tab is closed, so it doesn't keep loading next to the remaining pages.
|
|
228
|
+
await page?.close().catch(() => { });
|
|
229
|
+
const file = options.files?.get(id);
|
|
230
|
+
issues.push({ id: 'render-error', severity: 'error', page: id, ...(file ? { file } : {}), message: `Couldn't open the page: ${error.message.split('\n')[0]}`, fix: 'Open the page in `ds-viewer dev`; it may be very slow to load or stuck.' });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
log(`Checked ${ids.length} pages.`);
|
|
234
|
+
return issues;
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
return [{ id: 'render-failed', severity: 'error', message: `Couldn't render the catalog: ${error.message}`, fix: "Run `ds-viewer dev` to see why the catalog doesn't start." }];
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
for (const [signal, handler] of handlers)
|
|
241
|
+
process.off(signal, handler);
|
|
242
|
+
if (browser)
|
|
243
|
+
await browser.close().catch(() => { });
|
|
244
|
+
if (devChild)
|
|
245
|
+
stop(devChild);
|
|
246
|
+
}
|
|
247
|
+
}
|
package/dist/cli/staticPage.js
CHANGED
|
@@ -42,6 +42,7 @@ function readStaticPage(file) {
|
|
|
42
42
|
}
|
|
43
43
|
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TSX);
|
|
44
44
|
const imports = importedNames(sourceFile, '@krapwoo/ds-viewer');
|
|
45
|
+
const viewerImports = [...new Set(imports.values())];
|
|
45
46
|
const defineCatalogPageNames = localNamesFor(imports, 'defineCatalogPage');
|
|
46
47
|
const gridNames = localNamesFor(imports, 'grid');
|
|
47
48
|
const call = defaultExportCall(sourceFile);
|
|
@@ -49,13 +50,13 @@ function readStaticPage(file) {
|
|
|
49
50
|
// Not a recognized `defineCatalogPage(...)` default export — e.g. a hand-written object, or a
|
|
50
51
|
// shadowed/renamed import this reader can't trace. Not a parse error: the file is valid
|
|
51
52
|
// TypeScript, just not literal-checkable.
|
|
52
|
-
return { file, checkable: false };
|
|
53
|
+
return { file, checkable: false, viewerImports };
|
|
53
54
|
}
|
|
54
55
|
const consts = topLevelConsts(sourceFile);
|
|
55
56
|
const pageObject = resolve(call.arguments[0], consts, new Set());
|
|
56
57
|
if (!ts.isObjectLiteralExpression(pageObject))
|
|
57
|
-
return { file, checkable: false };
|
|
58
|
-
const page = { file, checkable: true };
|
|
58
|
+
return { file, checkable: false, viewerImports };
|
|
59
|
+
const page = { file, checkable: true, viewerImports };
|
|
59
60
|
const idProp = findProp(pageObject, 'id');
|
|
60
61
|
const componentProp = findProp(pageObject, 'component');
|
|
61
62
|
const groupProp = findProp(pageObject, 'group');
|
|
@@ -193,16 +194,21 @@ function readComposedOf(node, consts) {
|
|
|
193
194
|
return entries;
|
|
194
195
|
}
|
|
195
196
|
/** Local name -> imported name, for every named import from `moduleSpecifier`. */
|
|
196
|
-
function importedNames(sourceFile, moduleSpecifier) {
|
|
197
|
+
export function importedNames(sourceFile, moduleSpecifier) {
|
|
197
198
|
const names = new Map();
|
|
198
199
|
for (const statement of sourceFile.statements) {
|
|
199
200
|
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== moduleSpecifier) {
|
|
200
201
|
continue;
|
|
201
202
|
}
|
|
203
|
+
// Type-only imports bring no runtime value (`import type { X }`, `import { type X }`).
|
|
204
|
+
if (statement.importClause?.isTypeOnly)
|
|
205
|
+
continue;
|
|
202
206
|
const bindings = statement.importClause?.namedBindings;
|
|
203
207
|
if (!bindings || !ts.isNamedImports(bindings))
|
|
204
208
|
continue;
|
|
205
209
|
for (const element of bindings.elements) {
|
|
210
|
+
if (element.isTypeOnly)
|
|
211
|
+
continue;
|
|
206
212
|
names.set(element.name.text, element.propertyName ? element.propertyName.text : element.name.text);
|
|
207
213
|
}
|
|
208
214
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React, { forwardRef, useState } from 'react';
|
|
2
|
+
import { Pressable, StyleSheet, Text, type View } from 'react-native';
|
|
3
|
+
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_TYPE } from './tokens';
|
|
4
|
+
|
|
5
|
+
/** The viewer's own button, for catalog chrome and demo triggers (not an app component). Same look
|
|
6
|
+
* as the update page's buttons. */
|
|
7
|
+
export const CatalogButton = forwardRef<View, {
|
|
8
|
+
label: string;
|
|
9
|
+
onPress: () => void;
|
|
10
|
+
variant?: 'primary' | 'secondary';
|
|
11
|
+
accessibilityLabel?: string;
|
|
12
|
+
}>(function CatalogButton({ label, onPress, variant = 'primary', accessibilityLabel }, ref) {
|
|
13
|
+
const [focused, setFocused] = useState(false);
|
|
14
|
+
const primary = variant === 'primary';
|
|
15
|
+
return (
|
|
16
|
+
<Pressable
|
|
17
|
+
ref={ref}
|
|
18
|
+
onPress={onPress}
|
|
19
|
+
onFocus={() => setFocused(true)}
|
|
20
|
+
onBlur={() => setFocused(false)}
|
|
21
|
+
accessibilityRole="button"
|
|
22
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
23
|
+
style={({ pressed }) => [
|
|
24
|
+
styles.base,
|
|
25
|
+
primary ? styles.primary : styles.secondary,
|
|
26
|
+
pressed && (primary ? styles.primaryPressed : styles.secondaryPressed),
|
|
27
|
+
focused && styles.focus,
|
|
28
|
+
]}
|
|
29
|
+
>
|
|
30
|
+
<Text style={[styles.label, primary ? styles.labelPrimary : styles.labelSecondary]}>{label}</Text>
|
|
31
|
+
</Pressable>
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const styles = StyleSheet.create({
|
|
36
|
+
base: { height: CATALOG_LAYOUT.controlSize, paddingHorizontal: 18, borderRadius: 22, alignItems: 'center', justifyContent: 'center', alignSelf: 'center' },
|
|
37
|
+
primary: { backgroundColor: CATALOG_COLOR.text },
|
|
38
|
+
primaryPressed: { opacity: 0.85 },
|
|
39
|
+
secondary: { borderWidth: 1, borderColor: CATALOG_COLOR.borderStrong, backgroundColor: CATALOG_COLOR.surface },
|
|
40
|
+
secondaryPressed: { backgroundColor: CATALOG_COLOR.surfacePressed },
|
|
41
|
+
// Same ring as the sidebar's: an outline, so focusing never shifts the layout.
|
|
42
|
+
focus: { outlineWidth: CATALOG_LAYOUT.focusRingWidth, outlineStyle: 'solid', outlineColor: CATALOG_COLOR.focusRing } as object,
|
|
43
|
+
label: { fontSize: CATALOG_TYPE.md, fontWeight: '700' },
|
|
44
|
+
labelPrimary: { color: '#ffffff' },
|
|
45
|
+
labelSecondary: { color: CATALOG_COLOR.text },
|
|
46
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { StyleSheet, View } from 'react-native';
|
|
3
|
+
import { PhoneFrame } from './PhoneFrame';
|
|
4
|
+
import { CatalogButton } from './CatalogButton';
|
|
5
|
+
import { overlayTrigger } from './overlayDemoState';
|
|
6
|
+
|
|
7
|
+
// Not `StyleSheet.absoluteFill`: React Native 0.81's types reject spreading it.
|
|
8
|
+
const FILL = { position: 'absolute' as const, top: 0, right: 0, bottom: 0, left: 0 };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A phone with a catalog button that opens an overlay: a sheet, a dialog, a toast. The overlay
|
|
12
|
+
* (`children`) fills the device above the centred trigger and gets `{ open, close }`; keep it
|
|
13
|
+
* mounted and drive its own `visible` from `open`, so its open and close animations play.
|
|
14
|
+
*/
|
|
15
|
+
export function OverlayDemo({
|
|
16
|
+
triggerLabel,
|
|
17
|
+
initiallyOpen = false,
|
|
18
|
+
keepTrigger = false,
|
|
19
|
+
children,
|
|
20
|
+
}: {
|
|
21
|
+
triggerLabel: string | ((open: boolean) => string);
|
|
22
|
+
initiallyOpen?: boolean;
|
|
23
|
+
/** Keep the trigger visible while open, as a toggle. @default false */
|
|
24
|
+
keepTrigger?: boolean;
|
|
25
|
+
children: (state: { open: boolean; close: () => void }) => React.ReactNode;
|
|
26
|
+
}) {
|
|
27
|
+
const [open, setOpen] = useState(initiallyOpen);
|
|
28
|
+
const trigger = overlayTrigger(open, { triggerLabel, keepTrigger });
|
|
29
|
+
// When the overlay closes, keyboard focus returns to the trigger instead of the page body.
|
|
30
|
+
const triggerRef = useRef<View>(null);
|
|
31
|
+
const wasOpen = useRef(open);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (wasOpen.current && !open) (triggerRef.current as unknown as { focus?: () => void } | null)?.focus?.();
|
|
34
|
+
wasOpen.current = open;
|
|
35
|
+
}, [open]);
|
|
36
|
+
return (
|
|
37
|
+
<PhoneFrame>
|
|
38
|
+
<View style={styles.screen}>
|
|
39
|
+
{trigger.visible && (
|
|
40
|
+
<CatalogButton ref={triggerRef} label={trigger.label} onPress={() => setOpen((value) => (keepTrigger ? !value : true))} />
|
|
41
|
+
)}
|
|
42
|
+
<View style={FILL} pointerEvents="box-none">
|
|
43
|
+
{children({ open, close: () => setOpen(false) })}
|
|
44
|
+
</View>
|
|
45
|
+
</View>
|
|
46
|
+
</PhoneFrame>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const styles = StyleSheet.create({
|
|
51
|
+
screen: { flex: 1, alignSelf: 'stretch', alignItems: 'center', justifyContent: 'center' },
|
|
52
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ScrollView, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A whole app screen inside a `PhoneFrame`: `header` pinned to the top, `footer` to the bottom,
|
|
6
|
+
* and the children in a body between them that scrolls instead of pushing the footer off the
|
|
7
|
+
* device. `floating` is a layer over the top of the screen for toasts; it adds no inset of its
|
|
8
|
+
* own, so the app positions its content. `bodyWrapper` lets the app put its own surface or
|
|
9
|
+
* context provider around the scrolling body; give its root `flex: 1, minHeight: 0`.
|
|
10
|
+
*/
|
|
11
|
+
export function PhoneScreen({
|
|
12
|
+
header,
|
|
13
|
+
footer,
|
|
14
|
+
floating,
|
|
15
|
+
bodyWrapper,
|
|
16
|
+
scroll = true,
|
|
17
|
+
contentContainerStyle,
|
|
18
|
+
children,
|
|
19
|
+
}: {
|
|
20
|
+
header?: React.ReactNode;
|
|
21
|
+
footer?: React.ReactNode;
|
|
22
|
+
floating?: React.ReactNode;
|
|
23
|
+
bodyWrapper?: (body: React.ReactNode) => React.ReactNode;
|
|
24
|
+
/** Scroll the body (default), or lay it out to fit. */
|
|
25
|
+
scroll?: boolean;
|
|
26
|
+
contentContainerStyle?: StyleProp<ViewStyle>;
|
|
27
|
+
children?: React.ReactNode;
|
|
28
|
+
}) {
|
|
29
|
+
const body = scroll ? (
|
|
30
|
+
<ScrollView style={styles.flex} contentContainerStyle={contentContainerStyle}>{children}</ScrollView>
|
|
31
|
+
) : (
|
|
32
|
+
<View style={[styles.flex, contentContainerStyle]}>{children}</View>
|
|
33
|
+
);
|
|
34
|
+
return (
|
|
35
|
+
<View style={styles.screen}>
|
|
36
|
+
{header}
|
|
37
|
+
<View style={styles.flex}>{bodyWrapper ? bodyWrapper(body) : body}</View>
|
|
38
|
+
{footer}
|
|
39
|
+
{floating ? (
|
|
40
|
+
<View style={styles.floating} pointerEvents="box-none">{floating}</View>
|
|
41
|
+
) : null}
|
|
42
|
+
</View>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const styles = StyleSheet.create({
|
|
47
|
+
// Fills the device; `minHeight: 0` lets a flex child shrink below its content on the web.
|
|
48
|
+
screen: { flex: 1, alignSelf: 'stretch', minHeight: 0 },
|
|
49
|
+
flex: { flex: 1, minHeight: 0 },
|
|
50
|
+
floating: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 20 },
|
|
51
|
+
});
|
package/native/catalog/index.ts
CHANGED
|
@@ -31,6 +31,9 @@ export { TokenSections, TokenGrid, TokenTile } from './TokenLayouts';
|
|
|
31
31
|
export { DividedStack } from './DividedStack';
|
|
32
32
|
export { Swatch } from './Swatch';
|
|
33
33
|
export { PhoneFrame } from './PhoneFrame';
|
|
34
|
+
export { PhoneScreen } from './PhoneScreen';
|
|
35
|
+
export { OverlayDemo } from './OverlayDemo';
|
|
36
|
+
export { CatalogButton } from './CatalogButton';
|
|
34
37
|
export { BoundedOverlayViewport } from './BoundedOverlayViewport';
|
|
35
38
|
export type { BoundedOverlayViewportProps } from './BoundedOverlayViewport';
|
|
36
39
|
export { comparisonCellItemKey } from './overlayViewport';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** What `OverlayDemo` shows for a given open state. Kept free of runtime imports for `node --test`. */
|
|
2
|
+
export function overlayTrigger(
|
|
3
|
+
open: boolean,
|
|
4
|
+
options: { triggerLabel: string | ((open: boolean) => string); keepTrigger?: boolean },
|
|
5
|
+
): { visible: boolean; label: string } {
|
|
6
|
+
const label = typeof options.triggerLabel === 'function' ? options.triggerLabel(open) : options.triggerLabel;
|
|
7
|
+
return { visible: !open || Boolean(options.keepTrigger), label };
|
|
8
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import React
|
|
1
|
+
import React from 'react';
|
|
2
2
|
import { View, Text, StyleSheet } from 'react-native';
|
|
3
|
-
import { defineCatalogPage,
|
|
3
|
+
import { defineCatalogPage, OverlayDemo } from '@krapwoo/ds-viewer';
|
|
4
4
|
import { Button } from '../Button';
|
|
5
5
|
import { Dock } from '../Dock';
|
|
6
6
|
import { TopNav } from '../TopNav';
|
|
@@ -17,53 +17,43 @@ const styles = StyleSheet.create({
|
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
function BottomSheetDemo() {
|
|
20
|
-
const [visible, setVisible] = useState(false);
|
|
21
20
|
return (
|
|
22
|
-
<
|
|
23
|
-
{
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
planned maintenance.
|
|
59
|
-
</Text>
|
|
60
|
-
<Text style={styles.cardBody}>
|
|
61
|
-
Elevators are available at 168 St, 137 St, and 125 St — check the map for accessible
|
|
62
|
-
entrances before you travel.
|
|
63
|
-
</Text>
|
|
64
|
-
</View>
|
|
65
|
-
</BottomSheet>
|
|
66
|
-
</PhoneFrame>
|
|
21
|
+
<OverlayDemo triggerLabel="Open sheet">
|
|
22
|
+
{({ open, close }) => (
|
|
23
|
+
<BottomSheet
|
|
24
|
+
visible={open}
|
|
25
|
+
onDismiss={close}
|
|
26
|
+
header={
|
|
27
|
+
<TopNav
|
|
28
|
+
title="Trip details"
|
|
29
|
+
trailing={<Button variant="secondary" size="small" showIcon showLabel={false} iconName="clear" accessibilityLabel="Close" onPress={close} />}
|
|
30
|
+
/>
|
|
31
|
+
}
|
|
32
|
+
footer={
|
|
33
|
+
<Dock>
|
|
34
|
+
<Button label="Confirm" onPress={close} />
|
|
35
|
+
</Dock>
|
|
36
|
+
}
|
|
37
|
+
>
|
|
38
|
+
<View style={styles.sheetContent}>
|
|
39
|
+
<Text style={styles.cardTitle}>Uptown & The Bronx</Text>
|
|
40
|
+
<Text style={styles.cardBody}>Next train in 4 min · every 6–8 min.</Text>
|
|
41
|
+
{/* Long enough to overflow the phone frame's fixed height — demonstrates the Dock footer's
|
|
42
|
+
`elevated` shadow turning on automatically once this content area actually scrolls. */}
|
|
43
|
+
<Text style={styles.cardBody}>Board at the front car for a faster transfer at Union Sq.</Text>
|
|
44
|
+
<Text style={styles.cardBody}>
|
|
45
|
+
This line runs express between 96 St and 168 St during rush hours, skipping local stops in
|
|
46
|
+
between. Weekend service runs local along the full route, with some stations closed for
|
|
47
|
+
planned maintenance.
|
|
48
|
+
</Text>
|
|
49
|
+
<Text style={styles.cardBody}>
|
|
50
|
+
Elevators are available at 168 St, 137 St, and 125 St — check the map for accessible
|
|
51
|
+
entrances before you travel.
|
|
52
|
+
</Text>
|
|
53
|
+
</View>
|
|
54
|
+
</BottomSheet>
|
|
55
|
+
)}
|
|
56
|
+
</OverlayDemo>
|
|
67
57
|
);
|
|
68
58
|
}
|
|
69
59
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import React
|
|
1
|
+
import React from 'react';
|
|
2
2
|
import { View, Text, StyleSheet } from 'react-native';
|
|
3
|
-
import { defineCatalogPage,
|
|
3
|
+
import { defineCatalogPage, OverlayDemo } from '@krapwoo/ds-viewer';
|
|
4
4
|
import { Button } from '../Button';
|
|
5
5
|
import { ButtonGroup } from '../ButtonGroup';
|
|
6
6
|
import { Dialog } from './Dialog';
|
|
@@ -13,21 +13,21 @@ const styles = StyleSheet.create({
|
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
function DialogDemo() {
|
|
16
|
-
const [visible, setVisible] = useState(false);
|
|
17
16
|
return (
|
|
18
|
-
<
|
|
19
|
-
{
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
17
|
+
<OverlayDemo triggerLabel="Open dialog">
|
|
18
|
+
{({ open, close }) => (
|
|
19
|
+
<Dialog visible={open} onDismiss={close}>
|
|
20
|
+
<Text style={styles.cardTitle}>Delete this trip?</Text>
|
|
21
|
+
<Text style={styles.cardBody}>This can't be undone.</Text>
|
|
22
|
+
<View style={styles.dialogActions}>
|
|
23
|
+
<ButtonGroup>
|
|
24
|
+
<Button label="Cancel" variant="tertiary" onPress={close} />
|
|
25
|
+
<Button label="Delete" onPress={close} />
|
|
26
|
+
</ButtonGroup>
|
|
27
|
+
</View>
|
|
28
|
+
</Dialog>
|
|
29
|
+
)}
|
|
30
|
+
</OverlayDemo>
|
|
31
31
|
);
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import React
|
|
1
|
+
import React from 'react';
|
|
2
2
|
import { View, StyleSheet } from 'react-native';
|
|
3
|
-
import { defineCatalogPage,
|
|
4
|
-
import { Button } from '../Button';
|
|
3
|
+
import { defineCatalogPage, OverlayDemo } from '@krapwoo/ds-viewer';
|
|
5
4
|
import { Toast } from './Toast';
|
|
6
5
|
import { DS_SPACING } from '../../tokens';
|
|
7
6
|
|
|
@@ -15,14 +14,14 @@ const styles = StyleSheet.create({
|
|
|
15
14
|
// pattern) so this demo actually exercises the slide-in-from-top/fade animation, not just the two
|
|
16
15
|
// static end states.
|
|
17
16
|
function ToastDemo() {
|
|
18
|
-
const [visible, setVisible] = useState(true);
|
|
19
17
|
return (
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
<OverlayDemo triggerLabel={(open) => (open ? 'Hide toast' : 'Show toast')} keepTrigger initiallyOpen>
|
|
19
|
+
{({ open }) => (
|
|
20
|
+
<View style={styles.toastDemoOverlay} pointerEvents="box-none">
|
|
21
|
+
<Toast message="Trip saved" variant="success" visible={open} />
|
|
22
|
+
</View>
|
|
23
|
+
)}
|
|
24
|
+
</OverlayDemo>
|
|
26
25
|
);
|
|
27
26
|
}
|
|
28
27
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
2
|
import { View, Text, StyleSheet } from 'react-native';
|
|
3
|
-
import { defineCatalogPage,
|
|
3
|
+
import { defineCatalogPage, OverlayDemo } from '@krapwoo/ds-viewer';
|
|
4
4
|
import { Banner } from '../components/Banner';
|
|
5
5
|
import { BottomSheet } from '../components/BottomSheet';
|
|
6
6
|
import { Button } from '../components/Button';
|
|
@@ -51,7 +51,6 @@ const REPORT_ISSUE_TYPE_PILLS: PillRowItem[] = [
|
|
|
51
51
|
];
|
|
52
52
|
|
|
53
53
|
function ReportIssueDemo() {
|
|
54
|
-
const [visible, setVisible] = useState(false);
|
|
55
54
|
const [confirmDiscard, setConfirmDiscard] = useState(false);
|
|
56
55
|
const [issueType, setIssueType] = useState('delay');
|
|
57
56
|
const [line, setLine] = useState('4');
|
|
@@ -60,17 +59,19 @@ function ReportIssueDemo() {
|
|
|
60
59
|
const [visibility, setVisibility] = useState('public');
|
|
61
60
|
const [notify, setNotify] = useState(true);
|
|
62
61
|
|
|
63
|
-
const requestClose = () => setConfirmDiscard(true);
|
|
64
|
-
const discard = () => {
|
|
65
|
-
setConfirmDiscard(false);
|
|
66
|
-
setVisible(false);
|
|
67
|
-
};
|
|
68
|
-
|
|
69
62
|
return (
|
|
70
|
-
<
|
|
71
|
-
{
|
|
63
|
+
<OverlayDemo triggerLabel="Report an issue">
|
|
64
|
+
{({ open, close }) => {
|
|
65
|
+
// Closing asks first; discarding confirms and closes the sheet.
|
|
66
|
+
const requestClose = () => setConfirmDiscard(true);
|
|
67
|
+
const discard = () => {
|
|
68
|
+
setConfirmDiscard(false);
|
|
69
|
+
close();
|
|
70
|
+
};
|
|
71
|
+
return (
|
|
72
|
+
<>
|
|
72
73
|
<BottomSheet
|
|
73
|
-
visible={
|
|
74
|
+
visible={open}
|
|
74
75
|
onDismiss={requestClose}
|
|
75
76
|
header={
|
|
76
77
|
<TopNav
|
|
@@ -82,7 +83,7 @@ function ReportIssueDemo() {
|
|
|
82
83
|
}
|
|
83
84
|
footer={
|
|
84
85
|
<Dock>
|
|
85
|
-
<Button label="Submit report" onPress={
|
|
86
|
+
<Button label="Submit report" onPress={close} />
|
|
86
87
|
</Dock>
|
|
87
88
|
}
|
|
88
89
|
>
|
|
@@ -135,7 +136,10 @@ function ReportIssueDemo() {
|
|
|
135
136
|
</ButtonGroup>
|
|
136
137
|
</View>
|
|
137
138
|
</Dialog>
|
|
138
|
-
|
|
139
|
+
</>
|
|
140
|
+
);
|
|
141
|
+
}}
|
|
142
|
+
</OverlayDemo>
|
|
139
143
|
);
|
|
140
144
|
}
|
|
141
145
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { View, Text, ScrollView, StyleSheet } from 'react-native';
|
|
3
|
-
import { defineCatalogPage, PhoneFrame } from '@krapwoo/ds-viewer';
|
|
3
|
+
import { defineCatalogPage, PhoneFrame, PhoneScreen } from '@krapwoo/ds-viewer';
|
|
4
4
|
import { Avatar } from '../components/Avatar';
|
|
5
5
|
import { Badge } from '../components/Badge';
|
|
6
6
|
import { Button } from '../components/Button';
|
|
@@ -23,13 +23,6 @@ import { Icon } from '../icons/Icon.native';
|
|
|
23
23
|
import { DS_ICON_SIZE, DS_MOTION_DURATION, DS_SEMANTIC, DS_SPACING, DS_TYPOGRAPHY } from '../tokens';
|
|
24
24
|
|
|
25
25
|
const styles = StyleSheet.create({
|
|
26
|
-
// Saved trips recipe — fills PhoneFrame edge-to-edge (like dropdownFrameContent), with a
|
|
27
|
-
// fixed TopNav header and Dock footer around a scrollable middle, the same fixed-header/
|
|
28
|
-
// scrollable-body/fixed-footer shape a real screen would use.
|
|
29
|
-
savedTripsScreen: { flex: 1, alignSelf: 'stretch' },
|
|
30
|
-
// Takes the space between the TopNav and the Dock and scrolls (`minHeight: 0` lets a flex child
|
|
31
|
-
// shrink below its content on the web).
|
|
32
|
-
savedTripsScroll: { flex: 1, minHeight: 0 },
|
|
33
26
|
// The muted area between TopNav and Dock takes the remaining height, so the list scrolls there.
|
|
34
27
|
savedTripsBody: { flex: 1, minHeight: 0 },
|
|
35
28
|
savedTripsScrollContent: { padding: DS_SPACING[800], gap: DS_SPACING[600] },
|
|
@@ -40,7 +33,7 @@ const styles = StyleSheet.create({
|
|
|
40
33
|
// Floats over the top of the screen — same top/left/right inset Toast's own catalog demo uses —
|
|
41
34
|
// instead of sitting inline in the flex flow and pushing the Dock down. A column with a small
|
|
42
35
|
// gap, since back-to-back removals stack one toast per removal (newest on top).
|
|
43
|
-
savedTripsToastOverlay: {
|
|
36
|
+
savedTripsToastOverlay: { marginTop: DS_SPACING[800], marginHorizontal: DS_SPACING[600], gap: DS_SPACING[300] },
|
|
44
37
|
// Map mode's stand-in — a real map isn't in scope for this recipe, just enough to show the
|
|
45
38
|
// SegmentedToggle actually switches the screen's content, not just its own thumb.
|
|
46
39
|
savedTripsMapPlaceholder: { alignItems: 'center', justifyContent: 'center', gap: DS_SPACING[400], paddingVertical: DS_SPACING[2400] },
|
|
@@ -111,130 +104,132 @@ function SavedTripsDemo() {
|
|
|
111
104
|
|
|
112
105
|
return (
|
|
113
106
|
<PhoneFrame>
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
107
|
+
<PhoneScreen
|
|
108
|
+
header={
|
|
109
|
+
<TopNav
|
|
110
|
+
title="Saved trips"
|
|
111
|
+
trailing={
|
|
112
|
+
<Tooltip visible={showFilterTip} label="Filter by mode" placement="bottom" align="right">
|
|
113
|
+
<Button
|
|
114
|
+
variant="secondary"
|
|
115
|
+
size="small"
|
|
116
|
+
showIcon
|
|
117
|
+
showLabel={false}
|
|
118
|
+
iconName="menu"
|
|
119
|
+
accessibilityLabel="Filters"
|
|
120
|
+
onPress={() => setShowFilterTip((v) => !v)}
|
|
121
|
+
/>
|
|
122
|
+
</Tooltip>
|
|
123
|
+
}
|
|
124
|
+
/>
|
|
125
|
+
}
|
|
126
|
+
bodyWrapper={(body) => <Surface tone="muted" style={styles.savedTripsBody}>{body}</Surface>}
|
|
127
|
+
contentContainerStyle={styles.savedTripsScrollContent}
|
|
128
|
+
// Toasts float over the top of the screen instead of pushing the Dock down. One per
|
|
129
|
+
// pending removal, newest on top; each stays mounted (visible: false) through its exit.
|
|
130
|
+
floating={
|
|
131
|
+
<View style={styles.savedTripsToastOverlay} pointerEvents="box-none">
|
|
132
|
+
{toasts.map((t) => (
|
|
133
|
+
<Toast
|
|
134
|
+
key={t.key}
|
|
135
|
+
visible={t.visible}
|
|
136
|
+
message={`${t.trip.title} removed`}
|
|
137
|
+
action={{ label: 'Undo', onPress: () => undoRemove(t.key, t.trip) }}
|
|
127
138
|
/>
|
|
128
|
-
|
|
129
|
-
|
|
139
|
+
))}
|
|
140
|
+
</View>
|
|
141
|
+
}
|
|
142
|
+
footer={
|
|
143
|
+
<Dock>
|
|
144
|
+
<Button label="Plan a new trip" onPress={() => {}} />
|
|
145
|
+
</Dock>
|
|
146
|
+
}
|
|
147
|
+
>
|
|
148
|
+
<SearchField value={query} onChangeText={setQuery} placeholder="Search stations" />
|
|
149
|
+
<SegmentedToggle
|
|
150
|
+
value={viewMode}
|
|
151
|
+
onChange={setViewMode}
|
|
152
|
+
options={[
|
|
153
|
+
{ value: 'list', label: 'List', iconName: 'menu' },
|
|
154
|
+
{ value: 'map', label: 'Map', iconName: 'map' },
|
|
155
|
+
]}
|
|
130
156
|
/>
|
|
131
|
-
|
|
132
|
-
<
|
|
133
|
-
<
|
|
134
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
157
|
+
{viewMode === 'map' ? (
|
|
158
|
+
<View style={styles.savedTripsMapPlaceholder}>
|
|
159
|
+
<Icon name="map" size={DS_ICON_SIZE.xl} color={DS_SEMANTIC.text.muted} />
|
|
160
|
+
<Text style={styles.savedTripsMapPlaceholderText}>Map view</Text>
|
|
161
|
+
</View>
|
|
162
|
+
) : (
|
|
163
|
+
<>
|
|
164
|
+
<UnderlineTabs
|
|
165
|
+
value={tab}
|
|
166
|
+
onChange={setTab}
|
|
137
167
|
options={[
|
|
138
|
-
{ value: '
|
|
139
|
-
{ value: '
|
|
168
|
+
{ value: 'all', label: 'All' },
|
|
169
|
+
{ value: 'nearby', label: 'Nearby' },
|
|
170
|
+
{ value: 'favorites', label: 'Favorites' },
|
|
140
171
|
]}
|
|
141
172
|
/>
|
|
142
|
-
{
|
|
143
|
-
<
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
173
|
+
{tab === 'favorites' ? (
|
|
174
|
+
<EmptyState
|
|
175
|
+
iconName="waypoints"
|
|
176
|
+
title="No favorites yet"
|
|
177
|
+
description="Star a trip to see it here."
|
|
178
|
+
action={{ label: 'Browse trips', onPress: () => setTab('all') }}
|
|
179
|
+
secondaryAction={{ label: 'Not now', onPress: () => {} }}
|
|
180
|
+
/>
|
|
147
181
|
) : (
|
|
148
182
|
<>
|
|
149
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
options={[
|
|
153
|
-
{ value: 'all', label: 'All' },
|
|
154
|
-
{ value: 'nearby', label: 'Nearby' },
|
|
155
|
-
{ value: 'favorites', label: 'Favorites' },
|
|
156
|
-
]}
|
|
183
|
+
<PillRow
|
|
184
|
+
pills={pills.map((p) => ({ ...p, onPress: () => setPills((prev) => prev.map((x) => ({ ...x, variant: x.id === p.id ? 'selected' : 'not_selected' }))) }))}
|
|
185
|
+
showAddPill={false}
|
|
157
186
|
/>
|
|
158
|
-
{
|
|
187
|
+
<View style={styles.savedTripsInlineRow}>
|
|
188
|
+
<Loading size={14} />
|
|
189
|
+
<Text style={styles.savedTripsLoadingText}>Updating arrival times…</Text>
|
|
190
|
+
</View>
|
|
191
|
+
{/* Dropdown's own `label` prop, not a hand-rolled Text above it — the component
|
|
192
|
+
already owns the labeled-field pattern (PillRow, which has no label prop, is
|
|
193
|
+
the case where an external label is legitimately the only option). */}
|
|
194
|
+
<Dropdown label="Sort by" value={sortBy} onChange={setSortBy} options={SAVED_TRIPS_SORT_OPTIONS} />
|
|
195
|
+
<SectionHeader title="Nearby stations" />
|
|
196
|
+
{trips.length > 0 ? (
|
|
197
|
+
<List>
|
|
198
|
+
{trips.map((trip) => (
|
|
199
|
+
<ListItem
|
|
200
|
+
key={trip.id}
|
|
201
|
+
title={trip.title}
|
|
202
|
+
subtitle={trip.subtitle}
|
|
203
|
+
leading={<Avatar iconName={trip.iconName} size={40} />}
|
|
204
|
+
trailing={
|
|
205
|
+
<View style={styles.savedTripsInlineRow}>
|
|
206
|
+
<Badge variant={trip.badgeVariant} label={trip.badgeLabel} />
|
|
207
|
+
<Button
|
|
208
|
+
variant="ghost"
|
|
209
|
+
size="small"
|
|
210
|
+
showIcon
|
|
211
|
+
showLabel={false}
|
|
212
|
+
iconName="clear"
|
|
213
|
+
accessibilityLabel={`Remove ${trip.title}`}
|
|
214
|
+
onPress={() => removeTrip(trip.id)}
|
|
215
|
+
/>
|
|
216
|
+
</View>
|
|
217
|
+
}
|
|
218
|
+
/>
|
|
219
|
+
))}
|
|
220
|
+
</List>
|
|
221
|
+
) : (
|
|
159
222
|
<EmptyState
|
|
160
223
|
iconName="waypoints"
|
|
161
|
-
title="No
|
|
162
|
-
description="
|
|
163
|
-
action={{ label: 'Browse trips', onPress: () => setTab('all') }}
|
|
164
|
-
secondaryAction={{ label: 'Not now', onPress: () => {} }}
|
|
224
|
+
title="No nearby trips"
|
|
225
|
+
description="Removed trips you save will show up here again."
|
|
165
226
|
/>
|
|
166
|
-
) : (
|
|
167
|
-
<>
|
|
168
|
-
<PillRow
|
|
169
|
-
pills={pills.map((p) => ({ ...p, onPress: () => setPills((prev) => prev.map((x) => ({ ...x, variant: x.id === p.id ? 'selected' : 'not_selected' }))) }))}
|
|
170
|
-
showAddPill={false}
|
|
171
|
-
/>
|
|
172
|
-
<View style={styles.savedTripsInlineRow}>
|
|
173
|
-
<Loading size={14} />
|
|
174
|
-
<Text style={styles.savedTripsLoadingText}>Updating arrival times…</Text>
|
|
175
|
-
</View>
|
|
176
|
-
{/* Dropdown's own `label` prop, not a hand-rolled Text above it — the component
|
|
177
|
-
already owns the labeled-field pattern (PillRow, which has no label prop, is
|
|
178
|
-
the case where an external label is legitimately the only option). */}
|
|
179
|
-
<Dropdown label="Sort by" value={sortBy} onChange={setSortBy} options={SAVED_TRIPS_SORT_OPTIONS} />
|
|
180
|
-
<SectionHeader title="Nearby stations" />
|
|
181
|
-
{trips.length > 0 ? (
|
|
182
|
-
<List>
|
|
183
|
-
{trips.map((trip) => (
|
|
184
|
-
<ListItem
|
|
185
|
-
key={trip.id}
|
|
186
|
-
title={trip.title}
|
|
187
|
-
subtitle={trip.subtitle}
|
|
188
|
-
leading={<Avatar iconName={trip.iconName} size={40} />}
|
|
189
|
-
trailing={
|
|
190
|
-
<View style={styles.savedTripsInlineRow}>
|
|
191
|
-
<Badge variant={trip.badgeVariant} label={trip.badgeLabel} />
|
|
192
|
-
<Button
|
|
193
|
-
variant="ghost"
|
|
194
|
-
size="small"
|
|
195
|
-
showIcon
|
|
196
|
-
showLabel={false}
|
|
197
|
-
iconName="clear"
|
|
198
|
-
accessibilityLabel={`Remove ${trip.title}`}
|
|
199
|
-
onPress={() => removeTrip(trip.id)}
|
|
200
|
-
/>
|
|
201
|
-
</View>
|
|
202
|
-
}
|
|
203
|
-
/>
|
|
204
|
-
))}
|
|
205
|
-
</List>
|
|
206
|
-
) : (
|
|
207
|
-
<EmptyState
|
|
208
|
-
iconName="waypoints"
|
|
209
|
-
title="No nearby trips"
|
|
210
|
-
description="Removed trips you save will show up here again."
|
|
211
|
-
/>
|
|
212
|
-
)}
|
|
213
|
-
</>
|
|
214
227
|
)}
|
|
215
228
|
</>
|
|
216
229
|
)}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
toast floats over content, it doesn't push the Dock down) — same placement Toast's own
|
|
221
|
-
catalog demo uses. One Toast per pending removal, stacked newest-on-top; each entry
|
|
222
|
-
stays in the array (with `visible: false`) through its own exit animation instead of
|
|
223
|
-
being yanked out of the tree mid-motion. */}
|
|
224
|
-
<View style={styles.savedTripsToastOverlay} pointerEvents="box-none">
|
|
225
|
-
{toasts.map((t) => (
|
|
226
|
-
<Toast
|
|
227
|
-
key={t.key}
|
|
228
|
-
visible={t.visible}
|
|
229
|
-
message={`${t.trip.title} removed`}
|
|
230
|
-
action={{ label: 'Undo', onPress: () => undoRemove(t.key, t.trip) }}
|
|
231
|
-
/>
|
|
232
|
-
))}
|
|
233
|
-
</View>
|
|
234
|
-
<Dock>
|
|
235
|
-
<Button label="Plan a new trip" onPress={() => {}} />
|
|
236
|
-
</Dock>
|
|
237
|
-
</View>
|
|
230
|
+
</>
|
|
231
|
+
)}
|
|
232
|
+
</PhoneScreen>
|
|
238
233
|
</PhoneFrame>
|
|
239
234
|
);
|
|
240
235
|
}
|