ink-framework 0.1.0-alpha.10 → 0.1.0-alpha.13

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/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
  });
@@ -30,16 +32,42 @@ function RowComponent<const D extends Destination>({ image, title, titleMaxLines
30
32
 
31
33
  export const Row = /* @__PURE__ */ memo(RowComponent) as typeof RowComponent;
32
34
 
35
+ export type ImageCrop = { zoom: number; x: number; y: number };
36
+ export type ImageOverlay = { src: string; x: number; y: number; width: number; height: number };
37
+
33
38
  export function Image(props: {
34
39
  src: string;
35
40
  width: number;
36
41
  height: number;
37
42
  fit?: "contain" | "cover";
43
+ /** Clockwise quarter turns. Reuses one decoded texture across rotation and fit changes. */
44
+ rotation?: 0 | 90 | 180 | 270;
45
+ background?: string;
46
+ /** Normalised centre in the rotated source, with zoom relative to cover (1–4). */
47
+ crop?: ImageCrop;
48
+ /** Native gesture updates, delivered before subsequent press callbacks. */
49
+ onCropChange?: (crop: ImageCrop) => void;
50
+ /** Dashed horizontal guides at normalised frame positions (0–1), independent of crop. */
51
+ horizontalGuides?: readonly number[];
52
+ /** Transparent image in normalised frame coordinates, independent of crop and rotation. */
53
+ overlay?: ImageOverlay;
38
54
  bleed?: boolean;
39
55
  /** Fill the available content width, using width and height as the aspect ratio. */
40
56
  fillWidth?: boolean;
41
57
  zoomable?: boolean;
42
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;
43
71
  }) {
44
72
  return createElement("Image", props);
45
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;
@@ -21,15 +33,29 @@ export function Screen({ header, children, leftAction, rightAction, ...props }:
21
33
  centered?: boolean;
22
34
  wide?: boolean;
23
35
  bottomInset?: boolean;
24
- waitForImages?: boolean;
25
36
  leftAction?: { icon: IconAsset; onPress: () => void };
26
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;
27
52
  }) {
28
53
  const [scrollToEnd, setScrollToEnd] = useState(0);
29
54
  const requestEnd = useMemo(() => () => setScrollToEnd(value => value + 1), []);
30
- 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 },
31
56
  header != null && createElement(Stack, { gap: 47 }, header),
32
57
  children,
58
+ overlay != null && createElement("ScreenOverlay", null, overlay),
33
59
  ));
34
60
  }
35
61
 
@@ -91,10 +117,11 @@ export function Button<const D extends Destination>(props: PressProps<D> & {
91
117
  }
92
118
 
93
119
  export { Row, Image, Avatar } from "./image";
94
- export { Canvas, Rectangle, CanvasText, CanvasIcon } from "./canvas";
120
+ export { Canvas, Rectangle, CanvasPath, CanvasRange, CanvasText, CanvasIcon } from "./canvas";
95
121
 
96
122
  export function Field<const D extends Destination>(props: PressProps<D> & {
97
123
  label: string;
124
+ disabled?: boolean;
98
125
  children?: ReactNode;
99
126
  }) {
100
127
  const { href, url, onPress, ...rest } = props;
@@ -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 };
package/src/list.ts CHANGED
@@ -5,9 +5,7 @@ import { Button, Stack, Text } from "./index";
5
5
  import { CompiledList, nativeListPlan } from "./native-list";
6
6
  import { ScrollToEndContext } from "./scroll";
7
7
 
8
- const OLDER_PAGE_AHEAD_ITEMS = 8;
9
-
10
- export function ListLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
8
+ function ListLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
11
9
  return createElement(Stack, { gap: 24 },
12
10
  createElement(Text, { size: 18 }, message),
13
11
  createElement(Button, { onPress: onRetry }, "Try again"),
@@ -28,7 +26,65 @@ export type ListProps<T> = {
28
26
  initialPosition?: "start" | "end";
29
27
  };
30
28
 
29
+ type PaginationProps = Pick<ListProps<unknown>, "onLoadMore" | "hasMore" | "onLoadOlder" | "hasOlder">;
30
+ export type ListPagination = ReturnType<typeof useListPagination>;
31
+
32
+ function useListPage(load: (() => Promise<void>) | undefined, available: boolean) {
33
+ const accepted = useRef<string | undefined>(undefined);
34
+ const action = useAction((edge?: string) => {
35
+ // useAction may decline a pending request; record acceptance only when it starts.
36
+ if (edge !== undefined) accepted.current = edge;
37
+ return load?.();
38
+ });
39
+ return {
40
+ available: available && action.status !== "error",
41
+ enabled: !!load,
42
+ pending: action.status === "pending",
43
+ error: action.status === "error" ? action.error : undefined,
44
+ retry: () => action.run(),
45
+ request(edge: string) {
46
+ if (available && load && action.status !== "pending" && action.status !== "error" && accepted.current !== edge) action.run(edge);
47
+ },
48
+ };
49
+ }
50
+
51
+ function useListPagination({ onLoadMore, hasMore = false, onLoadOlder, hasOlder = false }: PaginationProps) {
52
+ const more = useListPage(onLoadMore, hasMore);
53
+ const older = useListPage(onLoadOlder, hasOlder);
54
+ return {
55
+ more,
56
+ older,
57
+ wrap(list: ReactNode) {
58
+ return createElement(Fragment, null,
59
+ older.error && createElement(ListLoadError, { message: older.error.message, onRetry: older.retry }),
60
+ list,
61
+ more.error && createElement(ListLoadError, { message: more.error.message, onRetry: more.retry }),
62
+ );
63
+ },
64
+ };
65
+ }
66
+
67
+ export function useListPaginationEvents(pagination: ListPagination, paused: boolean, count: number, first?: string, last?: string) {
68
+ const [startRequest, setStartRequest] = useState<string>();
69
+ const [endRequest, setEndRequest] = useState<string>();
70
+ const start = JSON.stringify([count, first ?? null]);
71
+ const end = JSON.stringify([count, last ?? null]);
72
+ useEffect(() => {
73
+ if (!paused && startRequest === start) pagination.older.request(startRequest);
74
+ if (!paused && endRequest === end) pagination.more.request(endRequest);
75
+ }, [pagination, paused, startRequest, endRequest, start, end]);
76
+ return {
77
+ hasMore: !paused && pagination.more.available && !pagination.more.pending,
78
+ hasOlder: !paused && pagination.older.available && !pagination.older.pending,
79
+ moreAvailable: pagination.more.available && pagination.more.enabled,
80
+ // Retain notifications even when the current commit cannot start loading yet.
81
+ onEndReached: setEndRequest,
82
+ onStartReached: setStartRequest,
83
+ };
84
+ }
85
+
31
86
  export function List<T>(props: ListProps<T>) {
87
+ const pagination = useListPagination(props);
32
88
  const requestEnd = useContext(ScrollToEndContext);
33
89
  const initialised = useRef(false);
34
90
  useLayoutEffect(() => {
@@ -37,10 +93,12 @@ export function List<T>(props: ListProps<T>) {
37
93
  if (props.initialPosition === "end") requestEnd?.();
38
94
  }, [props.initialPosition, props.items.length, requestEnd]);
39
95
  const plan = nativeListPlan(props.renderItem);
40
- return plan ? createElement(CompiledList<T>, { ...props, plan, key: plan.template }) : createElement(ReactList<T>, props);
96
+ return pagination.wrap(plan
97
+ ? createElement(CompiledList<T>, { ...props, pagination, plan, key: plan.template })
98
+ : createElement(ReactList<T>, { ...props, pagination }));
41
99
  }
42
100
 
43
- export function ReactList<T>({ items, keyExtractor, renderItem, gap = 47, followEnd = false, measurementKey, onLoadMore, hasMore = false, onLoadOlder, hasOlder = false, initialPosition = "start" }: ListProps<T>) {
101
+ export function ReactList<T>({ items, keyExtractor, renderItem, gap = 47, followEnd = false, measurementKey, initialPosition = "start", pagination }: ListProps<T> & { pagination: ListPagination }) {
44
102
  if (!Number.isFinite(gap) || gap < 0) {
45
103
  throw new Error("List gap must be finite and non-negative");
46
104
  }
@@ -51,11 +109,7 @@ export function ReactList<T>({ items, keyExtractor, renderItem, gap = 47, follow
51
109
  items: readonly T[]; keys: string[]; contentVersions: number[]; revision: number;
52
110
  records: Map<string, { item: T; version: number }>;
53
111
  } | null>(null);
54
- const [window, setWindow] = useState({ start: initialPosition === "end" ? Math.max(0, items.length - 32) : 0, end: initialPosition === "end" ? items.length : 32, revision: 0, nearEnd: false });
55
- const requested = useRef<string | null>(null);
56
- const load = useAction(() => onLoadMore?.());
57
- const olderRequested = useRef<string | null>(null);
58
- const older = useAction(() => onLoadOlder?.());
112
+ const [window, setWindow] = useState({ start: initialPosition === "end" ? Math.max(0, items.length - 32) : 0, end: initialPosition === "end" ? items.length : 32, revision: 0 });
59
113
  let start = Math.min(window.start, items.length);
60
114
  let end = Math.min(window.end, items.length);
61
115
  if (initialWindow.current && items.length) {
@@ -80,12 +134,12 @@ export function ReactList<T>({ items, keyExtractor, renderItem, gap = 47, follow
80
134
  throw new Error("List metadata exceeds 128 KiB; use shorter keys or a bounded data window");
81
135
  }
82
136
  if (initialWindow.current && items.length) {
83
- if (start !== window.start || end !== window.end) setWindow({ start, end, revision, nearEnd: false });
137
+ if (start !== window.start || end !== window.end) setWindow({ start, end, revision });
84
138
  } else if (old && window.revision !== revision && old.keys[window.start] !== undefined) {
85
139
  const mapped = keys.indexOf(old.keys[window.start]);
86
140
  start = mapped < 0 ? Math.min(window.start, Math.max(0, items.length - 1)) : mapped;
87
141
  end = Math.min(start + window.end - window.start, items.length);
88
- if (start !== window.start || end !== window.end) setWindow({ start, end, revision, nearEnd: false });
142
+ if (start !== window.start || end !== window.end) setWindow({ start, end, revision });
89
143
  }
90
144
  data = { items, keys, keyExtractor, contentVersions, revision, records, measurementKey };
91
145
  }
@@ -95,40 +149,23 @@ export function ReactList<T>({ items, keyExtractor, renderItem, gap = 47, follow
95
149
  if (items.length) initialWindow.current = false;
96
150
  });
97
151
  const { revision, contentVersions } = data;
98
- const boundary = JSON.stringify([keys.length, keys.at(-1)]);
99
- useEffect(() => {
100
- if (!onLoadMore || !hasMore || load.status === "pending" || load.status === "error"
101
- || window.revision !== revision || !window.nearEnd || requested.current === boundary) return;
102
- requested.current = boundary;
103
- load.run();
104
- }, [onLoadMore, hasMore, load.status, load.run, window, revision, boundary]);
105
- const firstBoundary = JSON.stringify([keys.length, keys[0]]);
106
- useEffect(() => {
107
- if (!onLoadOlder || !hasOlder || older.status === "pending" || older.status === "error"
108
- || window.revision !== revision || window.start > OLDER_PAGE_AHEAD_ITEMS || olderRequested.current === firstBoundary) return;
109
- olderRequested.current = firstBoundary;
110
- older.run();
111
- }, [onLoadOlder, hasOlder, older.status, older.run, window, revision, firstBoundary]);
152
+ const paginationProps = useListPaginationEvents(pagination, window.revision !== revision, keys.length, keys[0], keys.at(-1));
112
153
  const children: ReactNode[] = [];
113
154
  for (let index = start; index < end; index++) {
114
155
  children.push(createElement("Stack", { key: keys[index], axis: "vertical", align: "stretch" }, renderItem(items[index], index)));
115
156
  }
116
157
  const props = {
158
+ ...paginationProps,
117
159
  keys: data.keys,
118
160
  contentVersions,
119
161
  revision,
120
162
  gap,
121
163
  followEnd,
122
164
  start,
123
- onWindow(start: number, end: number, eventRevision: number, nearEnd: boolean) {
165
+ onWindow(start: number, end: number, eventRevision: number) {
124
166
  if (eventRevision !== revision) return;
125
- setWindow(previous => previous.start === start && previous.end === end && previous.revision === eventRevision && previous.nearEnd === nearEnd ? previous : { start, end, revision: eventRevision, nearEnd });
167
+ setWindow(previous => previous.start === start && previous.end === end && previous.revision === eventRevision ? previous : { start, end, revision: eventRevision });
126
168
  },
127
169
  };
128
- const list = createElement("List", props, children);
129
- return createElement(Fragment, null,
130
- older.status === "error" && createElement(ListLoadError, { message: older.error.message, onRetry: older.run }),
131
- list,
132
- load.status === "error" && createElement(ListLoadError, { message: load.error.message, onRetry: load.run }),
133
- );
170
+ return createElement("List", props, children);
134
171
  }