@astratra/native-ui 0.1.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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * @astratra/native-ui/logic — the pure rules of the kit, without react-native.
3
+ */
4
+ import type { GlassMode } from '@astratra/native';
5
+
6
+ export type { GlassMode };
7
+ export type ColorScheme = 'light' | 'dark';
8
+
9
+ /* ─────────────────────────────── Colour ─────────────────────────────── */
10
+
11
+ export interface Rgba {
12
+ r: number;
13
+ g: number;
14
+ b: number;
15
+ a: number;
16
+ }
17
+
18
+ /** `#rgb`, `#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()`; null for anything else. */
19
+ export function parseColor(value: unknown): Rgba | null;
20
+ /** `tint` over `background` at `proportion`, returned as an OPAQUE `#rrggbb`. */
21
+ export function mixColors(background: string, tint: string, proportion: number): string;
22
+ /** The same colour at another alpha, as `rgba()`; unreadable colours come back unchanged. */
23
+ export function withAlpha(color: string, alpha: number): string;
24
+
25
+ /* ──────────────────────────────── Glass ─────────────────────────────── */
26
+
27
+ /** The share of a container tint that Apple's glass really shows (0.25). */
28
+ export const TINT_WEIGHT: number;
29
+ /** The frost of Apple's interactive glass (0.4). */
30
+ export const INTERACTIVE_FROST: number;
31
+ /** Style keys that draw a shadow on either platform. */
32
+ export const SHADOW_KEYS: readonly string[];
33
+
34
+ /** A translucent tint at Apple's real weight; opaque colours are left alone. */
35
+ export function tintAtAppleWeight(color: string, interactive: boolean): string;
36
+ /** A flattened style with iOS's shadow rule applied: containers lose their shadow. */
37
+ export function surfaceStyleOffApple<T extends object>(flatStyle: T | null | undefined, interactive: boolean): Partial<T>;
38
+ /** The default tint of an untinted glass button. */
39
+ export function glassButtonTint(input?: { mode?: GlassMode; selected?: boolean }): string;
40
+
41
+ export interface GlassButtonMaterial {
42
+ glassStyle: 'regular' | 'clear';
43
+ /** Top highlight, clear middle, denser foot. */
44
+ highlight: [string, string, string];
45
+ /** Content glow — Apple's glass only. */
46
+ glow: {
47
+ shadowColor: string;
48
+ shadowOpacity: number;
49
+ shadowRadius: number;
50
+ shadowOffset: { width: number; height: number };
51
+ } | null;
52
+ }
53
+
54
+ export function glassButtonMaterial(input?: { mode?: GlassMode; scheme?: ColorScheme; tinted?: boolean }): GlassButtonMaterial;
55
+
56
+ /* ────────────────────────────── Pale card ───────────────────────────── */
57
+
58
+ export const PALE_CARD_RADIUS: number;
59
+
60
+ export interface PaleCardDose {
61
+ readonly top: number;
62
+ readonly fill: number;
63
+ readonly bottom: number;
64
+ }
65
+
66
+ export const PALE_CARD_DOSAGE: {
67
+ readonly light: { readonly tinted: PaleCardDose; readonly neutral: PaleCardDose };
68
+ readonly dark: { readonly tinted: PaleCardDose; readonly neutral: PaleCardDose };
69
+ };
70
+
71
+ export interface PaleCardColors {
72
+ fill: string;
73
+ top: string;
74
+ bottom: string;
75
+ }
76
+
77
+ export function paleCardColors(input: {
78
+ scheme?: ColorScheme;
79
+ pageBackground: string;
80
+ ink: string;
81
+ tint?: string | null;
82
+ }): PaleCardColors;
83
+
84
+ /* ────────────────────────── Collapse on scroll ──────────────────────── */
85
+
86
+ export interface CollapseState {
87
+ collapsed: boolean;
88
+ anchor: number;
89
+ }
90
+
91
+ export const COLLAPSE_THRESHOLD: number;
92
+ export const TOP_ZONE: number;
93
+ export const INITIAL_COLLAPSE: Readonly<CollapseState>;
94
+ /** One step of the fold decision; returns the same object when nothing changes. */
95
+ export function followScroll(state: CollapseState, y: number): CollapseState;
96
+
97
+ export const REFERENCE_WIDTH: number;
98
+ export const MAX_BAR_SCALE: number;
99
+ /** 1 up to a 390-point screen, growing with the width, capped at MAX_BAR_SCALE. */
100
+ export function barScale(screenWidth: number | undefined): number;
101
+ export const COLLAPSED_SCALE: number;
102
+ export const COLLAPSED_DROP: number;
103
+ /** Distance of a floating bottom element from the bottom edge. */
104
+ export function floatingBottomOffset(bottomInset: number | undefined): number;
105
+
106
+ /* ─────────────────────────────── Tab bar ────────────────────────────── */
107
+
108
+ export const TAB_BAR_HEIGHT: number;
109
+ export const TAB_BAR_SIDE_MARGIN: number;
110
+ export const TAB_BAR_MAX_WIDTH: number;
111
+ export const TAB_ROW_MARGIN: number;
112
+ export const TAB_PILL_HEIGHT: number;
113
+ export const TAB_CENTER_SIZE: number;
114
+ export const TAB_OPEN_DELAY: number;
115
+
116
+ export function arrangeTabs<T extends { key: string }>(
117
+ tabs: readonly T[] | null | undefined,
118
+ centerKey?: string | null
119
+ ): { ordered: T[]; centerIndex: number };
120
+ export function tabCellWidth(rowWidth: number, count: number, rowMargin?: number): number;
121
+ export function pillOffset(orderedKeys: readonly string[], key: string, cellWidth: number, rowMargin?: number): number;
122
+ export function tabBarWidth(screenWidth: number, scale?: number, options?: { sideMargin?: number; maxWidth?: number }): number;
123
+ export function badgeText(count: number): string;
124
+ export function tabAccessibilityLabel(tab: { label: string; badge?: number }): string;
125
+
126
+ export const PAGINATION_HEIGHT: number;
127
+ /** Room to keep under a list so its last row scrolls above the pagination pill. */
128
+ export function paginationReserve(bottomInset: number | undefined): number;
129
+
130
+ /* ────────────────────────── Collapsible header ──────────────────────── */
131
+
132
+ export interface FadeRange {
133
+ input: [number, number];
134
+ output: [number, number];
135
+ }
136
+
137
+ export const HEADER_BAR_HEIGHT: number;
138
+ export const HEADER_FADE_RUN: number;
139
+ export const HEADER_BOTTOM_FADE: { readonly ios: number; readonly android: number };
140
+ export const HEADER_DEFAULT_THRESHOLD: number;
141
+ export function headerThreshold(titleBottom: number, topInset?: number): number;
142
+ export function headerBarFade(threshold: number): FadeRange;
143
+ export function headerTitleFade(threshold: number): { opacity: FadeRange; translateY: FadeRange };
144
+ export function headerBottomFade(platform: string): number;
145
+ export function headerGradientStops(barHeight: number, fadeLength: number): [number, number, number];
146
+ export function androidHeaderColors(pageBackground: string | undefined): [string, string, string];
147
+
148
+ /* ────────────────────────────── Markdown ────────────────────────────── */
149
+
150
+ export type MarkdownInline =
151
+ | { kind: 'text'; text: string }
152
+ | { kind: 'bold'; text: string }
153
+ | { kind: 'italic'; text: string }
154
+ | { kind: 'code'; text: string }
155
+ | { kind: 'link'; text: string; href: string };
156
+
157
+ export interface MarkdownListItem {
158
+ depth: number;
159
+ inlines: MarkdownInline[];
160
+ }
161
+
162
+ export type MarkdownBlock =
163
+ | { kind: 'paragraph'; inlines: MarkdownInline[] }
164
+ | { kind: 'heading'; level: number; inlines: MarkdownInline[] }
165
+ | { kind: 'bullets'; items: MarkdownListItem[] }
166
+ | { kind: 'numbers'; items: MarkdownListItem[]; start: number }
167
+ | { kind: 'code'; language: string | null; text: string }
168
+ | { kind: 'table'; header: string[]; rows: string[][] }
169
+ | { kind: 'quote'; inlines: MarkdownInline[] };
170
+
171
+ export function parseMarkdown(source: string | null | undefined): MarkdownBlock[];
172
+ export function parseInline(source: string | null | undefined): MarkdownInline[];
173
+ export function inlinesToText(inlines: readonly MarkdownInline[] | null | undefined): string;
174
+ export function splitTableRow(line: string): string[];
175
+
176
+ /* ──────────────────────────── Table columns ─────────────────────────── */
177
+
178
+ export interface MeasureColumnsOptions {
179
+ charWidth?: number;
180
+ headerCharWidth?: number;
181
+ padding?: number;
182
+ minWidth?: number;
183
+ maxWidth?: number;
184
+ targetWidth?: number;
185
+ /** The phone's text size setting (1 = normal); below 1 is treated as 1. */
186
+ fontScale?: number;
187
+ /** Rounding onto the screen's pixel grid, e.g. PixelRatio.roundToNearestPixel. */
188
+ round?: (value: number) => number;
189
+ }
190
+
191
+ export const TABLE_COLUMN_DEFAULTS: Readonly<Required<Omit<MeasureColumnsOptions, 'fontScale' | 'round'>>>;
192
+ export function isNumeric(value: unknown): boolean;
193
+ export function measureColumns(
194
+ header: readonly string[],
195
+ rows: readonly (readonly string[])[],
196
+ options?: MeasureColumnsOptions
197
+ ): { widths: number[]; numeric: boolean[]; totalWidth: number };
198
+
199
+ /* ─────────────────────────── Question anchoring ─────────────────────── */
200
+
201
+ export const ANCHOR_MARGIN: number;
202
+ export function anchorOffset(anchorY: number): number;
203
+ export function reserveBelowQuestion(input: { viewportHeight: number; contentHeight: number; anchorY: number }): number;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The pure half of the kit: every rule the components follow, without
3
+ * react-native. Importable from Node (`@astratra/native-ui/logic`) — a server
4
+ * rendering the same Markdown, a test, a script.
5
+ */
6
+ module.exports = {
7
+ ...require('./color'),
8
+ ...require('./glass'),
9
+ ...require('./paleCard'),
10
+ ...require('./collapse'),
11
+ ...require('./tabBar'),
12
+ ...require('./collapsibleHeader'),
13
+ ...require('./markdown'),
14
+ ...require('./tableColumns'),
15
+ ...require('./anchorQuestion')
16
+ };
@@ -0,0 +1,229 @@
1
+ /**
2
+ * A Markdown parser for AI answers.
3
+ *
4
+ * A model writes Markdown; the screen used to render only its tables, and
5
+ * everything else came out raw — "**important**" displayed with its stars.
6
+ * No library is added for all that: Markdown renderers promise nothing on
7
+ * React 19 / recent React Native, and above all they do not test dry. Here
8
+ * parsing is a pure function, checked without mounting React, and rendering
9
+ * (`MarkdownView`) is only a walk over the structure it returns.
10
+ *
11
+ * Block kinds: paragraph, heading, bullets, numbers, code, table, quote.
12
+ * Inline kinds: text, bold, italic, code, link.
13
+ */
14
+
15
+ const HEADING = /^ {0,3}(#{1,6})\s+(.*)$/;
16
+ const BULLET = /^(\s*)[-*+][ \t]+(.*)$/;
17
+ const NUMBER = /^(\s*)(\d{1,9})[.)][ \t]+(.*)$/;
18
+ const QUOTE = /^ {0,3}>[ \t]?(.*)$/;
19
+ const FENCE = /^\s*(`{3,}|~{3,})\s*([^\s`~]*)\s*$/;
20
+ /* A table's marker: the row of dashes under the header. */
21
+ const TABLE_RULE = /^\s*\|?\s*:?-{3,}/;
22
+ const HORIZONTAL_RULE = /^\s*([-*_])(\s*\1){2,}\s*$/;
23
+
24
+ /** Two spaces of indentation = one level; a tab counts the same. */
25
+ const depthOf = (indent) => Math.floor(indent.replace(/\t/g, ' ').length / 2);
26
+
27
+ function splitTableRow(line) {
28
+ return line
29
+ .trim()
30
+ .replace(/^\|/, '')
31
+ .replace(/\|$/, '')
32
+ .split('|')
33
+ .map((cell) => cell.trim());
34
+ }
35
+
36
+ function parseMarkdown(source) {
37
+ const lines = String(source ?? '').split(/\r?\n/);
38
+ const blocks = [];
39
+ let paragraph = [];
40
+
41
+ const flushParagraph = () => {
42
+ const text = paragraph.join('\n').trim();
43
+ paragraph = [];
44
+ if (text) blocks.push({ kind: 'paragraph', inlines: parseInline(text) });
45
+ };
46
+
47
+ for (let index = 0; index < lines.length; index += 1) {
48
+ const line = lines[index];
49
+ const fence = FENCE.exec(line);
50
+
51
+ if (fence) {
52
+ flushParagraph();
53
+ /* A code block is opaque: no indentation trimmed, no star interpreted.
54
+ A missing closing fence must not silently swallow what follows
55
+ either — take everything to the end and stop there. */
56
+ const closing = fence[1][0];
57
+ const body = [];
58
+ let cursor = index + 1;
59
+ while (cursor < lines.length) {
60
+ const candidate = FENCE.exec(lines[cursor]);
61
+ if (candidate && candidate[1][0] === closing && !candidate[2]) break;
62
+ body.push(lines[cursor]);
63
+ cursor += 1;
64
+ }
65
+ blocks.push({ kind: 'code', language: fence[2] || null, text: body.join('\n') });
66
+ index = cursor;
67
+ continue;
68
+ }
69
+
70
+ if (!line.trim()) {
71
+ flushParagraph();
72
+ continue;
73
+ }
74
+
75
+ /* NO RULE BETWEEN PARAGRAPHS. A `---`, `***` or `___` line (Markdown's
76
+ horizontal rule) was displayed as is, three dashes in the middle of the
77
+ text. It separates nothing more than the space between two paragraphs
78
+ already does: it is dropped. */
79
+ if (HORIZONTAL_RULE.test(line)) {
80
+ flushParagraph();
81
+ continue;
82
+ }
83
+
84
+ const heading = HEADING.exec(line);
85
+ if (heading) {
86
+ flushParagraph();
87
+ blocks.push({ kind: 'heading', level: heading[1].length, inlines: parseInline(heading[2].trim()) });
88
+ continue;
89
+ }
90
+
91
+ const quote = QUOTE.exec(line);
92
+ if (quote) {
93
+ flushParagraph();
94
+ const body = [quote[1]];
95
+ let cursor = index + 1;
96
+ while (cursor < lines.length) {
97
+ const next = QUOTE.exec(lines[cursor]);
98
+ if (!next) break;
99
+ body.push(next[1]);
100
+ cursor += 1;
101
+ }
102
+ blocks.push({ kind: 'quote', inlines: parseInline(body.join('\n').trim()) });
103
+ index = cursor - 1;
104
+ continue;
105
+ }
106
+
107
+ /* A table only announces itself on its SECOND line: the current line is
108
+ a header only if the next one is made of dashes. */
109
+ if (line.includes('|') && index + 1 < lines.length && TABLE_RULE.test(lines[index + 1])) {
110
+ flushParagraph();
111
+ const header = splitTableRow(line);
112
+ const rows = [];
113
+ let cursor = index + 2;
114
+ while (cursor < lines.length && lines[cursor].includes('|')) {
115
+ rows.push(splitTableRow(lines[cursor]));
116
+ cursor += 1;
117
+ }
118
+ blocks.push({ kind: 'table', header, rows });
119
+ index = cursor - 1;
120
+ continue;
121
+ }
122
+
123
+ const bullet = BULLET.exec(line);
124
+ const numbered = bullet ? null : NUMBER.exec(line);
125
+ if (bullet || numbered) {
126
+ flushParagraph();
127
+ const kind = bullet ? 'bullets' : 'numbers';
128
+ const items = [];
129
+ let cursor = index;
130
+ while (cursor < lines.length) {
131
+ const current = kind === 'bullets' ? BULLET.exec(lines[cursor]) : NUMBER.exec(lines[cursor]);
132
+ if (!current) break;
133
+ const text = kind === 'bullets' ? current[2] : current[3];
134
+ items.push({ depth: depthOf(current[1]), inlines: parseInline(text.trim()) });
135
+ cursor += 1;
136
+ }
137
+ if (numbered) {
138
+ /* ONE STEP PER BLOCK, ALL "1.". Bullets under each step cut a numbered
139
+ list into several blocks, and each restarted at 1 (seen on an
140
+ Android capture: "1. 1. 1."). The written number is authoritative;
141
+ a "1." that follows a numbered list, separated from it only by
142
+ bullets, CONTINUES it. A paragraph in between still starts a new
143
+ list. */
144
+ const written = Number(numbered[2]);
145
+ const previous = [...blocks].reverse().find((block) => block.kind !== 'bullets');
146
+ const next = previous && previous.kind === 'numbers' ? previous.start + previous.items.length : 1;
147
+ blocks.push({ kind: 'numbers', items, start: written === 1 ? next : written });
148
+ } else {
149
+ blocks.push({ kind: 'bullets', items });
150
+ }
151
+ index = cursor - 1;
152
+ continue;
153
+ }
154
+
155
+ paragraph.push(line);
156
+ }
157
+
158
+ flushParagraph();
159
+ return blocks;
160
+ }
161
+
162
+ /**
163
+ * Cuts a paragraph into fragments. A delimiter that never closes stays text:
164
+ * without that rule, one stray star in the middle of an answer put the whole
165
+ * rest of the message in bold.
166
+ */
167
+ function parseInline(source) {
168
+ const text = String(source ?? '');
169
+ const inlines = [];
170
+ let plain = '';
171
+
172
+ const flush = () => {
173
+ if (plain) inlines.push({ kind: 'text', text: plain });
174
+ plain = '';
175
+ };
176
+
177
+ let index = 0;
178
+ while (index < text.length) {
179
+ const rest = text.slice(index);
180
+
181
+ const code = /^`([^`\n]+)`/.exec(rest);
182
+ if (code) {
183
+ flush();
184
+ inlines.push({ kind: 'code', text: code[1] });
185
+ index += code[0].length;
186
+ continue;
187
+ }
188
+
189
+ const bold = /^(\*\*|__)(?=\S)([\s\S]*?\S)\1/.exec(rest);
190
+ if (bold) {
191
+ flush();
192
+ inlines.push({ kind: 'bold', text: bold[2] });
193
+ index += bold[0].length;
194
+ continue;
195
+ }
196
+
197
+ /* Italic is recognised by the star only: a lone underscore appears in
198
+ identifiers (`final_grade`) far more often than as a style intent, and
199
+ taking it for a delimiter ate the word. */
200
+ const italic = /^\*(?=[^\s*])([^*\n]*[^\s*])\*/.exec(rest);
201
+ if (italic) {
202
+ flush();
203
+ inlines.push({ kind: 'italic', text: italic[1] });
204
+ index += italic[0].length;
205
+ continue;
206
+ }
207
+
208
+ const link = /^\[([^\]\n]*)\]\(([^)\s]+)\)/.exec(rest);
209
+ if (link) {
210
+ flush();
211
+ inlines.push({ kind: 'link', text: link[1] || link[2], href: link[2] });
212
+ index += link[0].length;
213
+ continue;
214
+ }
215
+
216
+ plain += text[index];
217
+ index += 1;
218
+ }
219
+
220
+ flush();
221
+ return inlines;
222
+ }
223
+
224
+ /** The plain text of a paragraph, delimiters removed — to copy, and to measure. */
225
+ function inlinesToText(inlines) {
226
+ return (inlines || []).map((inline) => inline.text).join('');
227
+ }
228
+
229
+ module.exports = { parseMarkdown, parseInline, inlinesToText, splitTableRow };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The colours of a pale card — its fill and its gradient — computed dry.
3
+ *
4
+ * WHY A PALE CARD AND NOT GLASS. Apple's glass on the CONTENT cards of a
5
+ * screen was judged, on a real phone, plain ugly: reflections, blur, a
6
+ * floating shadow. The rule that came out of it: glass stays on what floats
7
+ * and what is tapped (buttons, bars); content gets flat pale cards, glued to
8
+ * the page, in their own colour.
9
+ *
10
+ * WHY NO OUTLINE. At 7 % of tint the cards "had lost their colour" and it was
11
+ * their hairline outline that delimited them — read as "lines that show it's
12
+ * ice". No outline any more: the colour is INSIDE, in a light gradient
13
+ * (lighter at the top, denser at the bottom, like the light inside an iPhone
14
+ * icon), and that alone separates the card from the page.
15
+ *
16
+ * WHY OPAQUE. The fill is an opaque MIX of the tint over the page colour, not
17
+ * an rgba: a page that paints a pattern under its content would show it
18
+ * through a translucent card. The mix is done once, here, identical on iOS
19
+ * and Android — no extra layer a platform could composite differently.
20
+ */
21
+ const { parseColor, mixColors } = require('./color');
22
+
23
+ /** The card's corner radius. */
24
+ const PALE_CARD_RADIUS = 20;
25
+
26
+ /**
27
+ * Proportion of tint mixed into the page, per scheme and variant.
28
+ *
29
+ * Each dose was chosen so the card shows on its page, yellow included. The
30
+ * neutral light dose went from 4.5 % to 8 % after a settings screen became
31
+ * "invisible" on an iPhone. On black the same proportion vanishes, so dark
32
+ * doses go up; the dark neutral at 15 % comes out slightly lighter than
33
+ * iOS's own card grey (#1c1c1e) — at 11 % it drowned in the black.
34
+ */
35
+ const PALE_CARD_DOSAGE = Object.freeze({
36
+ light: Object.freeze({
37
+ tinted: Object.freeze({ top: 0.1, fill: 0.14, bottom: 0.2 }),
38
+ neutral: Object.freeze({ top: 0.08, fill: 0.08, bottom: 0.08 })
39
+ }),
40
+ dark: Object.freeze({
41
+ tinted: Object.freeze({ top: 0.16, fill: 0.22, bottom: 0.28 }),
42
+ neutral: Object.freeze({ top: 0.15, fill: 0.15, bottom: 0.15 })
43
+ })
44
+ });
45
+
46
+ /**
47
+ * The colours of a pale card: its flat `fill` (for a pale background that is
48
+ * not a card) and the two ends of its gradient (`top`, `bottom`).
49
+ *
50
+ * `tint` must already be the colour for the current scheme. A missing or
51
+ * unreadable tint gives the NEUTRAL variant: `ink` — the theme's text colour
52
+ * — heavily diluted. Page and ink are read from the caller's theme rather
53
+ * than written here: when the dark theme moves, the card follows untouched.
54
+ */
55
+ function paleCardColors({ scheme = 'light', pageBackground, ink, tint } = {}) {
56
+ const readableTint = tint && parseColor(tint) ? tint : null;
57
+ const dosage = PALE_CARD_DOSAGE[scheme === 'dark' ? 'dark' : 'light'][readableTint ? 'tinted' : 'neutral'];
58
+ const colour = readableTint || ink;
59
+ return {
60
+ fill: mixColors(pageBackground, colour, dosage.fill),
61
+ top: mixColors(pageBackground, colour, dosage.top),
62
+ bottom: mixColors(pageBackground, colour, dosage.bottom)
63
+ };
64
+ }
65
+
66
+ module.exports = { PALE_CARD_RADIUS, PALE_CARD_DOSAGE, paleCardColors };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The geometry of the floating tab bar, and the pagination pill that shares
3
+ * its place — computed dry.
4
+ *
5
+ * The rendered bar (`TabBar`) is a thin shell over these numbers: which tab
6
+ * sits in the middle, how wide a cell is, where the sliding pill must land.
7
+ * Each of them was once wrong on a real phone; each is pinned by a test.
8
+ */
9
+ const { floatingBottomOffset } = require('./collapse');
10
+
11
+ /** The bar's own height at scale 1, and the side margin to the screen edge. */
12
+ const TAB_BAR_HEIGHT = 62;
13
+ const TAB_BAR_SIDE_MARGIN = 26;
14
+ /* On a tablet a one-metre bar scatters five destinations to the four corners;
15
+ it keeps a generous phone width and centres under the thumb. */
16
+ const TAB_BAR_MAX_WIDTH = 560;
17
+ /** Inner padding of the row: the pill never touches the bar's rounded ends. */
18
+ const TAB_ROW_MARGIN = 4;
19
+ /** Height of the active-tab pill at scale 1. */
20
+ const TAB_PILL_HEIGHT = 44;
21
+ /** Size of the raised centre button at scale 1. */
22
+ const TAB_CENTER_SIZE = 50;
23
+ /**
24
+ * How long the destination waits after a tap, in ms.
25
+ *
26
+ * The page used to open in the same frame and cover the bar: the pill's slide
27
+ * was never seen. It now waits until the pill has almost arrived.
28
+ */
29
+ const TAB_OPEN_DELAY = 180;
30
+
31
+ /**
32
+ * Puts the centre tab in the middle; the others keep their order around it.
33
+ *
34
+ * Returns `centerIndex: -1` when there is no centre tab — the bar is then a
35
+ * plain row, same code, no raised button.
36
+ */
37
+ function arrangeTabs(tabs, centerKey) {
38
+ const list = Array.isArray(tabs) ? tabs : [];
39
+ const center = centerKey == null ? undefined : list.find((tab) => tab.key === centerKey);
40
+ if (!center) return { ordered: list, centerIndex: -1 };
41
+ const rest = list.filter((tab) => tab !== center);
42
+ const centerIndex = Math.floor(rest.length / 2);
43
+ return { ordered: [...rest.slice(0, centerIndex), center, ...rest.slice(centerIndex)], centerIndex };
44
+ }
45
+
46
+ /** The width of one tab cell, once the row has been measured (0 before). */
47
+ function tabCellWidth(rowWidth, count, rowMargin = TAB_ROW_MARGIN) {
48
+ if (!(rowWidth > 0) || !(count > 0)) return 0;
49
+ return (rowWidth - rowMargin * 2) / count;
50
+ }
51
+
52
+ /**
53
+ * Where the pill must land for `key`.
54
+ *
55
+ * An unknown key lands on the first cell rather than off the bar: a pill
56
+ * sliding out of sight reads as a broken bar.
57
+ */
58
+ function pillOffset(orderedKeys, key, cellWidth, rowMargin = TAB_ROW_MARGIN) {
59
+ return rowMargin + Math.max(0, orderedKeys.indexOf(key)) * cellWidth;
60
+ }
61
+
62
+ /** The bar's width: the screen minus its margins, capped for tablets. */
63
+ function tabBarWidth(screenWidth, scale = 1, { sideMargin = TAB_BAR_SIDE_MARGIN, maxWidth = TAB_BAR_MAX_WIDTH } = {}) {
64
+ return Math.min(screenWidth - sideMargin * 2, maxWidth * scale);
65
+ }
66
+
67
+ /** A badge never grows past three characters. */
68
+ function badgeText(count) {
69
+ return count > 99 ? '99+' : String(count);
70
+ }
71
+
72
+ /** A screen reader hears the count, not just a red dot. */
73
+ function tabAccessibilityLabel(tab) {
74
+ return tab.badge ? `${tab.label}, ${tab.badge}` : tab.label;
75
+ }
76
+
77
+ /** The pagination pill's height. */
78
+ const PAGINATION_HEIGHT = 52;
79
+
80
+ /**
81
+ * The room to keep under a list so its last row can scroll ABOVE the pill,
82
+ * instead of ending hidden behind it.
83
+ */
84
+ function paginationReserve(bottomInset) {
85
+ return floatingBottomOffset(bottomInset) + PAGINATION_HEIGHT + 16;
86
+ }
87
+
88
+ module.exports = {
89
+ TAB_BAR_HEIGHT,
90
+ TAB_BAR_SIDE_MARGIN,
91
+ TAB_BAR_MAX_WIDTH,
92
+ TAB_ROW_MARGIN,
93
+ TAB_PILL_HEIGHT,
94
+ TAB_CENTER_SIZE,
95
+ TAB_OPEN_DELAY,
96
+ arrangeTabs,
97
+ tabCellWidth,
98
+ pillOffset,
99
+ tabBarWidth,
100
+ badgeText,
101
+ tabAccessibilityLabel,
102
+ PAGINATION_HEIGHT,
103
+ paginationReserve
104
+ };