ink-framework 0.1.0-alpha.12 → 0.1.0-alpha.14

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/DESIGN.md ADDED
@@ -0,0 +1,168 @@
1
+ # Design apps with Ink
2
+
3
+ Build Light Phone III apps from Ink's screens and components. Ink already supplies typography, spacing, padding, colours, navigation, scrolling and keyboard behaviour. Your job is to choose the right page for each task and the right component for each piece of content.
4
+
5
+ Follow the steps in order. Do not write screen code until you have a page map.
6
+
7
+ ## 1. Write a page map
8
+
9
+ List every page before you build. Give each one a route, a single page type from [Page types](#2-choose-a-page-type) and the page it is reached from.
10
+
11
+ ```text
12
+ / Main task Tuner reading. Header: settings icon → /settings
13
+ /settings Settings Toggles, Reference pitch field → /reference
14
+ /reference Choice Reference pitch options. Saves and goes back
15
+ ```
16
+
17
+ Shape the map with these rules:
18
+
19
+ - `/` opens on the app's main task. Do not make it a menu of buttons that lead to the real content. Add setup or welcome pages only when the app cannot work without them, and leave them once setup is complete.
20
+ - Use tabs only for two to four peer sections that users switch between often, such as Today, Locations and Settings. Put tab pages in `app/(tabs)/`.
21
+ - Reach Settings from a header icon or a tab. Reach per-item actions from a `moreHoriz` header icon or a long press, which opens an options page.
22
+ - Keep the tree shallow: section → item → options or choice. If a page needs a fourth level, group the settings differently.
23
+ - When a page has two jobs, it is two pages. Split it.
24
+
25
+ ## 2. Choose a page type
26
+
27
+ Every page is exactly one of these types. Use the listed components and nothing else at the top level of the page.
28
+
29
+ | Page type | Use it for | Build with |
30
+ | --- | --- | --- |
31
+ | Main task | The app's primary reading or tool | `Screen` with the one thing users came for. Use `centered` for short content. Header icons for Settings and secondary pages |
32
+ | Collection | Items users browse or open | `List` of `Row`. `EmptyState` when there are no items. `add` header icon to create |
33
+ | Detail | One item | `Screen` titled with the item name. Content first, `moreHoriz` header icon for actions |
34
+ | Settings | Preferences | `Toggle`, `Field` and `Button` as direct children of `Screen` |
35
+ | Choice | Pick one value for a setting | `SettingsChoices` only. Save, then `back()` |
36
+ | Input | Enter or edit one value | One `TextInput` with `autoFocus`. Its keyboard action submits. Hints or errors underneath only |
37
+ | Options | Actions for one item | `Button`s only, one per action |
38
+ | Confirmation | Destructive or irreversible action | `Confirmation` as the whole page |
39
+ | Media or chat | Playback or messages | `PlayingScreen` or `ConversationScreen` |
40
+ | Custom visual | Diagrams, boards, gauges | `Canvas` primitives inside `Screen`, with Ink components for any controls |
41
+
42
+ Loading, empty and error states are not separate pages. Place `LoadingState`, `EmptyState` or `ErrorState` directly inside `Screen` in place of its content.
43
+
44
+ ## 3. Choose components
45
+
46
+ Pick the component that matches what the element means, not how you want it to look.
47
+
48
+ | To show | Use | Not |
49
+ | --- | --- | --- |
50
+ | A setting that is on or off | `Toggle label="…"` | A button whose label changes |
51
+ | A setting with a current value | `Field label="…" href="/choice"` showing the value | A button labelled with the value |
52
+ | A group of related settings | `Button href="/settings/group"` labelled with the group name | `Field` with an empty value |
53
+ | An action | `Button` labelled with a verb | `Text` with `onPress` |
54
+ | An item that opens | `Row` with `title`, and `subtitle` or `image` when useful | A `Stack` of `Text` with `onPress` |
55
+ | A scrolling collection | `List` | `.map()` over many items inside `Stack` |
56
+ | A user-ordered collection | `ReorderList` on its own page, reached from a `swapVert` header icon | Up and down buttons |
57
+ | A one-of-many choice | `SettingsChoices` on a choice page | Toggles that turn each other off, or `+` and `−` buttons |
58
+ | Content that stays above scrolling | `Screen header` | A custom pinned view |
59
+ | Up to three persistent bottom actions | `Screen actionBar` | Buttons placed against the screen edge |
60
+ | A secondary navigation action | `leftAction` or `rightAction` with an icon from `ink/icons` | A button in the content |
61
+ | Supporting text | `Text size={18}` below what it explains | A paragraph above the controls |
62
+ | A large primary reading | One `Text` with a large `size` and `tabularNumbers` | Several large sizes on one page |
63
+
64
+ Common header icons: `settings` opens Settings, `add` creates an item, `moreHoriz` opens options, `swapVert` reorders, `sort` changes order, `check` saves an edit, and `graphicEq` opens the playing screen.
65
+
66
+ ## 4. Lay out the page
67
+
68
+ - Put components directly inside `Screen`. `Screen` already spaces its children 47 pixels apart and supplies side padding and scrolling. Do not wrap the whole page in a `Stack`.
69
+ - Use `Stack` only for elements that belong together and need tighter spacing than the page, such as a reading above its unit, or a horizontal row of labels. Use a gap of 16 or less inside a group.
70
+ - Use `Stack justify="space-between"` as the only child of `Screen` to pin a footer to the bottom.
71
+ - Order a page by importance: primary content, then the controls that act on it, then supporting text.
72
+ - Order a Settings page as values first (toggles and fields), then links to groups, then destructive actions.
73
+ - Use default text size for content and labels and `size={18}` for supporting text. Add at most one larger size per page, for its primary reading.
74
+ - Centre short content with `Screen centered`. Align feeds, lists and forms to the top.
75
+
76
+ ## 5. Keep it small
77
+
78
+ Ink apps are deliberately plain. Remove anything that does not help the user complete the task.
79
+
80
+ - Do not create wrapper components that restyle or rearrange Ink components, such as cards, sections, headers, tiles or custom list items. A local component is fine when it composes Ink components for reuse across pages without changing their appearance.
81
+ - Do not add borders, backgrounds, dividers, badges, status pills or decorative icons.
82
+ - Do not set colours outside `Canvas`. Inside `Canvas`, derive colours from `useColourScheme()`.
83
+ - Do not use props that are not in Ink's type definitions. If TypeScript rejects a prop, change the design, not the types.
84
+ - Do not add explanatory paragraphs to a page. One short sentence is acceptable when the purpose or limits of a control are not obvious.
85
+ - Do not add success messages for actions whose result is visible. Navigate back, or show the changed value.
86
+ - Do not add a toggle subtitle that repeats its label. Add one only when the label alone cannot be understood.
87
+ - Do not show the same information twice, such as a status in both a label and a sentence.
88
+
89
+ ## 6. Handle state and input
90
+
91
+ - Inputs live on their own page. Use the right keyboard; its action submits or saves. Do not add a Save or Cancel button. The header Back button cancels.
92
+ - Search submits to a separate results page. Multiline inputs keep Return for new lines and use a `check` header action to save.
93
+ - Keep existing content visible during refreshes and when returning to a page. Show `LoadingState` only when there is no content yet.
94
+ - Keep a control's label and appearance while its action is pending. `useAction` already ignores repeated calls, so do not set `disabled` or change the label to “Loading…”. Reserve `disabled` for controls that are unavailable.
95
+ - Show one error at a time. Use `ErrorState` with a retry action when the page cannot show its content. Otherwise, place one `Text` beside the affected control.
96
+ - Request a permission when the user opens the feature that needs it. Do not add a separate permission button.
97
+ - Preserve scroll position, drafts and selections when navigating.
98
+ - Use `tabularNumbers` for changing numbers. Reserve space for images whose size is known.
99
+
100
+ ## Example: a setup page
101
+
102
+ This keyboard setup page mixes statuses, actions, instructions and several error messages:
103
+
104
+ ```tsx
105
+ <Screen title="Set up keyboard">
106
+ <Stack gap={24}>
107
+ <Button onPress={enable.run}>{ready?.enabled ? "Atlas is enabled" : "Enable Atlas"}</Button>
108
+ <Button onPress={choose.run}>{ready?.selected ? "Atlas is selected" : "Choose Atlas"}</Button>
109
+ <Button onPress={scroll.run}>Open Scroll assistant</Button>
110
+ <Text size={18}>Select Sliding Mode 1 for each app where you want to scroll and swipe type. Atlas reads the keyboard while a text field is active, and scrolling works everywhere else.</Text>
111
+ {(enable.status === "error" || choose.status === "error") && <Text>Could not open keyboard settings.</Text>}
112
+ {scroll.status === "error" && <Text>Scroll assistant is only available on the Titan 2.</Text>}
113
+ {/* Retry button with disabled and pending label, two more error messages */}
114
+ </Stack>
115
+ </Screen>
116
+ ```
117
+
118
+ Problems:
119
+
120
+ - Button labels change between an action and a status.
121
+ - The whole page is wrapped in a `Stack` with its own gap.
122
+ - A two-sentence paragraph sits between controls.
123
+ - Each action adds its own error line at the bottom of the page.
124
+
125
+ The same page as a Settings page type:
126
+
127
+ ```tsx
128
+ <Screen title="Set up keyboard">
129
+ {loaded.status === "error" ? <ErrorState message="Could not read keyboard settings." onRetry={reload} /> : <>
130
+ <Field label="Enabled" onPress={enable.run}>{ready.enabled ? "Yes" : "No"}</Field>
131
+ <Field label="Selected" onPress={choose.run}>{ready.selected ? "Yes" : "No"}</Field>
132
+ <Field label="Scroll assistant" onPress={scroll.run}>Sliding Mode 1</Field>
133
+ {error && <Text size={18}>{error}</Text>}
134
+ </>}
135
+ </Screen>
136
+ ```
137
+
138
+ Each row shows one status and opens the place to change it. Spacing comes from `Screen`, and there is one error line.
139
+
140
+ ## Before you finish
141
+
142
+ Check each page against this list and fix every failure before reporting the work as done:
143
+
144
+ - [ ] The page appears in the page map with one page type.
145
+ - [ ] `/` opens on the main task.
146
+ - [ ] Top-level children of `Screen` are the components listed for its page type.
147
+ - [ ] No wrapper `Stack`, custom gaps between page sections, borders, colours or decorative elements.
148
+ - [ ] No component restyles or reimplements an Ink component.
149
+ - [ ] Labels name actions or settings; values and statuses are in `Field` values or `Toggle` states.
150
+ - [ ] No paragraphs, repeated information or success messages.
151
+ - [ ] One error at a time, beside the control or as `ErrorState`.
152
+ - [ ] `ink check` passes. Its `ink-design` rules catch some of these mistakes; fix the design rather than disabling a rule.
153
+ - [ ] Each page is checked in the emulator with the keyboard open, long content, missing data, light and dark modes, and after navigating back from another page. Use the phone for hardware features.
154
+
155
+ Use plain British English and sentence case. Name buttons with direct verbs, such as “Export words”.
156
+
157
+ ## Reference apps
158
+
159
+ These apps show the intended structure. Read the matching page in one before building a new page type.
160
+
161
+ | App | Shows |
162
+ | --- | --- |
163
+ | [Tuner](https://github.com/No-Scrolling/tuner) | Main task opening on a reading, Settings with a choice page |
164
+ | [Weather](https://github.com/No-Scrolling/weather) | Tabs, grouped Settings, unit choice pages, search input and results |
165
+ | [Podcasts](https://github.com/No-Scrolling/podcasts) | Collection, detail, options page, Player settings, `PlayingScreen` |
166
+ | [Passes](https://github.com/No-Scrolling/passes) | Collection with reorder, options page, rename input, delete confirmation |
167
+
168
+ See the [Ink documentation](https://ink.noscroll.ing/screens) for each component's props and examples. Ink's type definitions in `node_modules/ink/src` list every supported prop.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ink-framework",
3
- "version": "0.1.0-alpha.12",
3
+ "version": "0.1.0-alpha.14",
4
4
  "types": "./src/index.ts",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -8,17 +8,20 @@
8
8
  "./internal/list": "./src/native-list.ts",
9
9
  "./internal/data": "./src/data-views.ts",
10
10
  "./internal/playback": "./src/playback-binding.ts",
11
+ "./internal/controller": "./src/controller-react.ts",
11
12
  "./native": "./src/native.ts",
12
13
  "./native/controller": "./src/controller.ts",
13
14
  "./assets": "./src/assets.ts",
14
15
  "./icons": "./src/icons.ts",
15
16
  "./file-router": "./src/file-router.tsx",
16
17
  "./runtime-types": "./runtime.d.ts",
17
- "./tsconfig": "./tsconfig.app.json"
18
+ "./tsconfig": "./tsconfig.app.json",
19
+ "./internal/react": "./src/react.ts",
20
+ "./resource": "./src/resource.ts"
18
21
  },
19
22
  "dependencies": {
20
23
  "linkify-it": "^6.1.0",
21
- "react-reconciler": "0.33.0"
24
+ "preact": "10.29.3"
22
25
  },
23
26
  "peerDependencies": {
24
27
  "react": "19.2.8"
@@ -35,6 +38,7 @@
35
38
  "files": [
36
39
  "src",
37
40
  "*.json",
38
- "*.ts"
41
+ "*.ts",
42
+ "*.md"
39
43
  ]
40
44
  }
@@ -0,0 +1,6 @@
1
+ import { useMemo } from "preact/hooks";
2
+
3
+ /** React Compiler's per-component memo slots, retained across Preact renders. */
4
+ export function c(size: number): unknown[] {
5
+ return useMemo(() => Array(size).fill(Symbol.for("react.memo_cache_sentinel")), []);
6
+ }
@@ -0,0 +1,33 @@
1
+ import { useMemo } from "./react";
2
+ import { attachNativeController } from "./controller";
3
+
4
+ type Attachment = ReturnType<typeof attachNativeController>;
5
+ type ControllerAttachment = {
6
+ current: Attachment | null;
7
+ attach(recipe: unknown, observe: (value: unknown) => void, fail: (error: Error) => void,
8
+ ready?: (id: number) => void): () => void;
9
+ };
10
+
11
+ /** @internal Attach from an effect and return its cleanup, including for hidden Activities. */
12
+ export function useNativeController(module: string, description = module): ControllerAttachment {
13
+ return useMemo(() => {
14
+ const controller: ControllerAttachment = {
15
+ current: null,
16
+ attach(recipe, observe, fail, ready) {
17
+ const attachment = attachNativeController(module, recipe, observe);
18
+ controller.current = attachment;
19
+ // Disposal removes event observers; promise continuations need their own lifetime check.
20
+ void attachment.ready.then(() => {
21
+ if (controller.current === attachment) ready?.(attachment.id);
22
+ }, error => {
23
+ if (controller.current === attachment) fail(error);
24
+ });
25
+ return () => {
26
+ controller.current = null;
27
+ void attachment.dispose().catch(error => console.error(`Could not release ${description}`, error));
28
+ };
29
+ },
30
+ };
31
+ return controller;
32
+ }, [module, description]);
33
+ }
package/src/controller.ts CHANGED
@@ -16,8 +16,12 @@ export function attachNativeController(
16
16
  if (observers.size >= 256) throw new NativeError("busy", "Too many native controllers");
17
17
  const id = __inkNextId();
18
18
  let disposed = false;
19
+ let activated = false;
19
20
  observers.set(id, observe);
20
- const ready = callNative(module, "activate", recipe, { controller: id }).catch(error => {
21
+ const ready = callNative(module, "activate", recipe, { controller: id }).then(value => {
22
+ activated = true;
23
+ return value;
24
+ }).catch(error => {
21
25
  disposed = true;
22
26
  observers.delete(id);
23
27
  throw error;
@@ -36,7 +40,10 @@ export function attachNativeController(
36
40
  if (disposed) return;
37
41
  disposed = true;
38
42
  observers.delete(id);
39
- try { await ready; } catch { return; }
43
+ // Release an active owner before the next effect attaches its replacement.
44
+ if (!activated) {
45
+ try { await ready; } catch { return; }
46
+ }
40
47
  await callNative(module, "deactivate", {}, { controller: id });
41
48
  },
42
49
  };
@@ -1,4 +1,4 @@
1
- import { Activity, createElement, useMemo, type ComponentType, type ReactNode } from "react";
1
+ import { Activity, createElement, useMemo, type ComponentType, type ReactNode } from "./react";
2
2
  import { back, LayoutContext, NavigationStack, RouteContext, type Destination, type Entry } from "./navigation";
3
3
 
4
4
  export type FileRoute = { name: string; path: string; component: ComponentType };
@@ -0,0 +1,65 @@
1
+ import { createElement, type ComponentProps, type ReactElement } from "./react";
2
+ import { Button, type StackProps, type TextProps } from "./index";
3
+ import type { IconAsset } from "./assets";
4
+ import { listText, nativeListRow, nativeListTemplate } from "./native-list";
5
+
6
+ type SharedProps = { key?: string | number; hidden?: boolean };
7
+ type RowProps = {
8
+ Stack: SharedProps & Omit<StackProps, "children">;
9
+ Text: SharedProps & Omit<TextProps, "children" | "href" | "url"> & { text: string };
10
+ Button: SharedProps & Pick<ComponentProps<typeof Button>, "icon" | "disabled" | "selected" | "onPress" | "onLongPress">;
11
+ MediaGridRow: SharedProps;
12
+ MediaCell: SharedProps & {
13
+ src: string; selected?: boolean; dimmed?: boolean; video?: boolean;
14
+ checkIcon?: IconAsset; videoIcon?: IconAsset;
15
+ onPress?: () => void; onLongPress?: () => void;
16
+ };
17
+ };
18
+ type RowValue = string | number | boolean | undefined | (() => void);
19
+ type RowField<Fields, Value extends RowValue> = { readonly read: (fields: Fields) => Value };
20
+ type BoundProps<Fields, Props> = { [Name in keyof Props]: (Props[Name] & RowValue) | RowField<Fields, Props[Name] & RowValue> };
21
+ type RowNode<Fields> = {
22
+ type: keyof RowProps;
23
+ props: Record<string, RowValue | RowField<Fields, RowValue>>;
24
+ children: RowNode<Fields>[];
25
+ };
26
+
27
+ /** @internal A fixed built-in presentation with React and native list adapters. */
28
+ export function fixedListRow<Fields>(build: (
29
+ node: <Kind extends keyof RowProps>(type: Kind, props: BoundProps<Fields, RowProps[NoInfer<Kind>]>, children?: RowNode<Fields>[]) => RowNode<Fields>,
30
+ field: <Value extends RowValue>(read: (fields: Fields) => Value) => RowField<Fields, Value>,
31
+ ) => RowNode<Fields>) {
32
+ class Field<Value extends RowValue> {
33
+ constructor(readonly read: (fields: Fields) => Value) {}
34
+ }
35
+ const root = build((type, props, children = []) => ({ type, props, children }), read => new Field(read));
36
+ const readers: ((fields: Fields) => RowValue)[] = [];
37
+ const template = nativeListTemplate((node, field) => {
38
+ const visit = (row: RowNode<Fields>): ReturnType<typeof node> => node(row.type,
39
+ Object.fromEntries(Object.entries(row.props).flatMap(([name, value]) => {
40
+ if (name === "key") return [];
41
+ if (value instanceof Field || name.startsWith("on")) {
42
+ const read = value instanceof Field ? value.read : () => value;
43
+ return [[name, field(readers.push(read) - 1)]];
44
+ }
45
+ return [[name, value]];
46
+ })), row.children.map(visit));
47
+ return visit(root);
48
+ });
49
+ function render(row: RowNode<Fields>, fields: Fields): ReactElement | null {
50
+ const props = Object.fromEntries(Object.entries(row.props).map(([name, value]) =>
51
+ [name, value instanceof Field ? value.read(fields) : value]));
52
+ if (props.hidden === true) return null;
53
+ if (row.type === "Text") {
54
+ const { text, ...rest } = props;
55
+ return createElement("Text", rest, listText([text]));
56
+ }
57
+ return createElement(row.type === "Button" ? Button : row.type, props, ...row.children.map(child => render(child, fields)));
58
+ }
59
+ return function<T>(project: (item: T, index: number) => Fields) {
60
+ return nativeListRow((item: T, index: number) => render(root, project(item, index)), (item, index) => {
61
+ const fields = project(item, index);
62
+ return [readers.map(read => read(fields))];
63
+ }, template);
64
+ };
65
+ }
package/src/icons.ts CHANGED
@@ -3259,6 +3259,7 @@ export const forward = "outlined:forward" as IconAsset;
3259
3259
  export const forwardFilled = "filled:forward" as IconAsset;
3260
3260
  export const forward10 = "outlined:forward_10" as IconAsset;
3261
3261
  export const forward10Filled = "filled:forward_10" as IconAsset;
3262
+ export const forward15Filled = "filled:forward_15" as IconAsset;
3262
3263
  export const forward30 = "outlined:forward_30" as IconAsset;
3263
3264
  export const forward30Filled = "filled:forward_30" as IconAsset;
3264
3265
  export const forward5 = "outlined:forward_5" as IconAsset;
@@ -6151,6 +6152,7 @@ export const replay = "outlined:replay" as IconAsset;
6151
6152
  export const replayFilled = "filled:replay" as IconAsset;
6152
6153
  export const replay10 = "outlined:replay_10" as IconAsset;
6153
6154
  export const replay10Filled = "filled:replay_10" as IconAsset;
6155
+ export const replay15Filled = "filled:replay_15" as IconAsset;
6154
6156
  export const replay30 = "outlined:replay_30" as IconAsset;
6155
6157
  export const replay30Filled = "filled:replay_30" as IconAsset;
6156
6158
  export const replay5 = "outlined:replay_5" as IconAsset;
package/src/image.ts CHANGED
@@ -12,17 +12,19 @@ export function Avatar({ src = "", unread = false, onPress, onLongPress }: {
12
12
  return createElement("Avatar", { src, unread, onPress, onLongPress });
13
13
  }
14
14
 
15
- function RowComponent<const D extends Destination>({ image, title, titleMaxLines, titleIcon, subtitle, subtitleIcon, href, url, onPress, onLongPress }: PressProps<D> & {
15
+ function RowComponent<const D extends Destination>({ image, title, titleMaxLines, titleIcon, subtitle, subtitleIcon, selected, href, url, onPress, onLongPress }: PressProps<D> & {
16
16
  image?: string;
17
17
  title: string;
18
18
  titleMaxLines?: number;
19
19
  titleIcon?: IconAsset;
20
20
  subtitle?: string;
21
21
  subtitleIcon?: IconAsset;
22
+ /** Underlines the row's text block, such as for the current choice. */
23
+ selected?: boolean;
22
24
  onLongPress?: () => void;
23
25
  }) {
24
26
  return createElement("RowContent", {
25
- image, title, titleMaxLines, titleIcon, subtitle, subtitleIcon,
27
+ image, title, titleMaxLines, titleIcon, subtitle, subtitleIcon, selected,
26
28
  onPress: pressHandler({ href, url, onPress }) ?? (onLongPress ? () => {} : undefined),
27
29
  onLongPress,
28
30
  });
@@ -54,6 +56,18 @@ export function Image(props: {
54
56
  fillWidth?: boolean;
55
57
  zoomable?: boolean;
56
58
  loop?: boolean;
59
+ /** Up to two sources decoded ahead at this image's size, such as neighbouring photos. */
60
+ preload?: readonly string[];
61
+ onPress?: () => void;
62
+ /** A play triangle centred over the image, such as on a video poster. */
63
+ playIcon?: boolean;
64
+ /** A video from `useVideo` drawn over this image, such as over its poster frame. */
65
+ videoController?: number | null;
66
+ /** A horizontal swipe: `"next"` when swiped left, `"previous"` when swiped right. */
67
+ onSwipe?: (direction: "next" | "previous") => void;
68
+ /** Whether a swipe can continue in each direction. Both default to true. */
69
+ hasNext?: boolean;
70
+ hasPrevious?: boolean;
57
71
  }) {
58
72
  return createElement("Image", props);
59
73
  }
package/src/index.ts CHANGED
@@ -13,7 +13,19 @@ export { useAction, type Action } from "./action";
13
13
  export { NativeError } from "./native";
14
14
  export { Tabs, Slot, navigate, replace, back, useRouteParams, type Destination } from "./navigation";
15
15
 
16
- export function Screen({ header, children, leftAction, rightAction, ...props }: {
16
+ export type ActionBarItem = { icon?: IconAsset; label?: string; onPress: () => void; disabled?: boolean } | null;
17
+
18
+ /** @internal Host props for a bottom action bar of up to three slots. */
19
+ export function actionBarProps(items: readonly ActionBarItem[] | undefined) {
20
+ if (!items) return {};
21
+ const props: Record<string, unknown> = {
22
+ actionBar: items.map(item => item && { icon: item.icon, label: item.label, disabled: item.disabled ?? false }),
23
+ };
24
+ items.forEach((item, index) => { if (item && !item.disabled) props[`onAction${index}`] = item.onPress; });
25
+ return props;
26
+ }
27
+
28
+ export function Screen({ header, children, overlay, leftAction, rightAction, actionBar, ...props }: {
17
29
  children?: ReactNode;
18
30
  header?: ReactNode;
19
31
  title?: string;
@@ -23,12 +35,27 @@ export function Screen({ header, children, leftAction, rightAction, ...props }:
23
35
  bottomInset?: boolean;
24
36
  leftAction?: { icon: IconAsset; onPress: () => void };
25
37
  rightAction?: { icon: IconAsset; onPress: () => void };
38
+ /** Up to three actions in equal slots along the bottom; `null` leaves a slot empty. */
39
+ actionBar?: readonly ActionBarItem[];
40
+ /** `"auto"` hides the scrollbar until the page scrolls. */
41
+ scrollBar?: "auto";
42
+ /** Hides the header and action bar while keeping their space, so content stays in place. */
43
+ immersive?: boolean;
44
+ /** Lets content fill the screen, with the header and action bar drawn over it. */
45
+ overlayBars?: boolean;
46
+ /** Content above the bottom action bar, such as video progress. Hidden in immersive mode. */
47
+ overlay?: ReactNode;
48
+ /** Makes the title pressable, such as to choose what the page shows. */
49
+ onTitlePress?: () => void;
50
+ /** An icon after the title, such as a chevron for `onTitlePress`. */
51
+ titleIcon?: IconAsset;
26
52
  }) {
27
53
  const [scrollToEnd, setScrollToEnd] = useState(0);
28
54
  const requestEnd = useMemo(() => () => setScrollToEnd(value => value + 1), []);
29
- return createElement(ScrollToEndContext.Provider, { value: requestEnd }, createElement("Screen", { ...props, scrollToEnd, pinnedHeader: header != null, leftIcon: leftAction?.icon, onLeftPress: leftAction?.onPress, rightIcon: rightAction?.icon, onRightPress: rightAction?.onPress },
55
+ return createElement(ScrollToEndContext.Provider, { value: requestEnd }, createElement("Screen", { ...props, ...actionBarProps(actionBar), scrollToEnd, pinnedHeader: header != null, leftIcon: leftAction?.icon, onLeftPress: leftAction?.onPress, rightIcon: rightAction?.icon, onRightPress: rightAction?.onPress },
30
56
  header != null && createElement(Stack, { gap: 47 }, header),
31
57
  children,
58
+ overlay != null && createElement("ScreenOverlay", null, overlay),
32
59
  ));
33
60
  }
34
61
 
@@ -51,6 +78,7 @@ export type TextProps<D extends Destination = Destination> = PressProps<D> & {
51
78
  align?: "start" | "center" | "end" | "justify";
52
79
  maxLines?: number;
53
80
  tabularNumbers?: boolean;
81
+ strikethrough?: boolean;
54
82
  }
55
83
 
56
84
  // Keep text as a host element while checking literal route parameters in JSX.
@@ -0,0 +1,21 @@
1
+ import { Fragment, options, type ComponentType, type VNode } from "preact";
2
+ export { Fragment };
3
+ let nextId = 0;
4
+
5
+ /** Preact's vnode contract without unused browser/debug fields on every node. */
6
+ export function jsx(type: string | ComponentType, incoming: Record<string, unknown> | null, key?: string) {
7
+ let props = incoming ?? {};
8
+ let ref: unknown;
9
+ if ("ref" in props) {
10
+ ({ ref, ...props } = props);
11
+ }
12
+ if (typeof type === "function" && type.defaultProps) {
13
+ for (const name in type.defaultProps) {
14
+ if (props[name] === undefined) props[name] = (type.defaultProps as Record<string, unknown>)[name];
15
+ }
16
+ }
17
+ const vnode = { type, props, key, ref, constructor: undefined, __v: --nextId } as unknown as VNode;
18
+ if (typeof type === "function") options.vnode?.(vnode);
19
+ return vnode;
20
+ }
21
+ export { jsx as jsxs, jsx as jsxDEV };