@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,149 @@
1
+ /**
2
+ * Column widths for a table in an AI answer — the rule, dry.
3
+ *
4
+ * THE DEFECT. The table was rendered in a horizontal scroll, but each cell was
5
+ * free text in a row, WITHOUT a column width. React Native then shares the
6
+ * room according to the content of EACH ROW: the header and its rows do not
7
+ * line up, "Subscription" breaks, values float. The table is no longer a
8
+ * table — it is three lists side by side that don't talk to each other.
9
+ *
10
+ * THE RULE. A column has ONE width, computed once for the whole table from its
11
+ * longest content — header included, since the header is what was breaking.
12
+ * The measure is in characters: with no access to the text engine, a
13
+ * character's width is approximated by the font's average width at the size
14
+ * used. An assumed approximation, because the error it makes (a column a bit
15
+ * wide) is invisible, while the error before (misaligned columns) jumped out.
16
+ *
17
+ * WHY A FLOOR ON THE LONGEST WORD. A column narrower than its longest word
18
+ * cuts that word in the middle — "Subscrip- / tion". The floor prevents it: a
19
+ * whole word always fits, and wrapping only happens between two words.
20
+ *
21
+ * WHY A CEILING. Without one, a whole sentence in a cell pushes the column to
22
+ * 600 points and the horizontal scroll never ends. Above the ceiling the text
23
+ * wraps in a taller cell — the row grows, the alignment holds.
24
+ *
25
+ * WHY THE ROUNDING IS INJECTED. A width must land on the screen's pixel grid,
26
+ * or the separating hairline renders as two blurry greys. `PixelRatio` only
27
+ * exists in the app; here the rounding function is received, and a test
28
+ * swaps in the one of a chosen density.
29
+ *
30
+ * MEASURE THE VISIBLE TEXT. Cells go through the inline Markdown renderer
31
+ * ("**Starter**" is shown bold, without its stars), so the caller measures
32
+ * `inlinesToText(parseInline(cell))`, not the raw source.
33
+ */
34
+
35
+ const DEFAULTS = Object.freeze({
36
+ /* MEASURED TOO NARROW. These were 6.4 and 6.9: at the real size of the cells
37
+ (13.5) and headers (11, uppercase, letter-spaced), a digit takes more.
38
+ "17/09/2026" fell into a column too short and broke into "17/09/202"
39
+ then "6". Measure wide: a column one point too wide doesn't show, a word
40
+ cut in two does. */
41
+ charWidth: 7.4,
42
+ /* THE HEADER IS SHOWN IN SPACED CAPITALS. Measured like the body, "STATUS"
43
+ fell into a column too short and broke into "STATU / S": a capital is
44
+ wider than an average lowercase letter, and letter-spacing adds to it. */
45
+ headerCharWidth: 8.6,
46
+ /* THE REAL PADDING, NOT AN OLD ONE. This margin was 20 — EXACTLY the cell
47
+ padding of the time (10 left, 10 right). Not one pixel was left for
48
+ rounding, and "Number" broke into "Numbe / r". A floor exact to the pixel
49
+ is not a floor. It follows the current padding (12 + 12). */
50
+ padding: 24,
51
+ minWidth: 58,
52
+ maxWidth: 176,
53
+ targetWidth: 0
54
+ });
55
+
56
+ /* A number, an amount, a percentage, a mark out of twenty: what reads
57
+ right-aligned, because the eye then compares units with units. Thousands
58
+ separators and decimal commas are allowed, and any currency symbol (\p{Sc})
59
+ before or after. */
60
+ const NUMERIC_VALUE = /^[\p{Sc}+\-−]?\s?\d[\d\s.,/]*\s?(?:%|\p{Sc}|\/\s?\d+)?$/u;
61
+
62
+ function isNumeric(value) {
63
+ const text = String(value ?? '').trim();
64
+ if (!text) return false;
65
+ return NUMERIC_VALUE.test(text);
66
+ }
67
+
68
+ /** The longest word of a cell, in characters (code points, not UTF-16 units). */
69
+ function longestWord(text) {
70
+ return text
71
+ .trim()
72
+ .split(/\s+/)
73
+ .reduce((longest, word) => Math.max(longest, [...word].length), 0);
74
+ }
75
+
76
+ function lengthOf(text) {
77
+ return [...text.trim()].length;
78
+ }
79
+
80
+ /**
81
+ * @param {string[]} header
82
+ * @param {string[][]} rows
83
+ * @param {object} [options] charWidth, headerCharWidth, padding, minWidth,
84
+ * maxWidth, targetWidth, fontScale, round.
85
+ * @returns {{widths: number[], numeric: boolean[], totalWidth: number}}
86
+ */
87
+ function measureColumns(header, rows, options = {}) {
88
+ const o = { ...DEFAULTS, ...options };
89
+ const round = typeof options.round === 'function' ? options.round : (value) => value;
90
+ /* THE PHONE'S TEXT SIZE. Text grows with the size chosen in the phone's
91
+ settings; the columns must grow too, or a phone set to large text cuts
92
+ every header. Character widths follow it, the padding does not. A
93
+ smaller setting never shrinks below the normal measure. */
94
+ const scale = Math.max(1, options.fontScale ?? 1);
95
+ const charWidth = o.charWidth * scale;
96
+ const headerCharWidth = o.headerCharWidth * scale;
97
+
98
+ /* The column count comes from the fullest row, not just the header: a
99
+ model sometimes writes a row longer than its header, and a cell with no
100
+ column disappeared silently. */
101
+ const columnCount = Math.max(header.length, ...rows.map((row) => row.length), 0);
102
+
103
+ const widths = [];
104
+ const numeric = [];
105
+
106
+ for (let column = 0; column < columnCount; column += 1) {
107
+ const title = header[column] ?? '';
108
+ const cells = rows.map((row) => row[column] ?? '');
109
+
110
+ /* Wanted width: the longest content, header included. */
111
+ const wantedBody = Math.max(0, ...cells.map(lengthOf)) * charWidth;
112
+ const wantedHeader = lengthOf(title) * headerCharWidth;
113
+ const wanted = Math.max(wantedBody, wantedHeader) + o.padding;
114
+
115
+ /* Floor: the longest word must fit whole, even past the ceiling. */
116
+ const wordFloor =
117
+ Math.max(longestWord(title) * headerCharWidth, ...cells.map((cell) => longestWord(cell) * charWidth), 0) +
118
+ o.padding;
119
+
120
+ const bounded = Math.min(Math.max(wanted, o.minWidth), o.maxWidth);
121
+ widths.push(round(Math.max(bounded, wordFloor)));
122
+
123
+ /* Numeric only if it has values and ALL are numbers. One text value —
124
+ "unpaid" among amounts — and the column is text again: mixed alignment
125
+ reads worse than a uniform left alignment. */
126
+ const filled = cells.filter((cell) => cell.trim().length > 0);
127
+ numeric.push(filled.length > 0 && filled.every(isNumeric));
128
+ }
129
+
130
+ /* Stretch to the target width, proportionally: every column grows by the
131
+ same percentage, so the width ratios — and the reading — don't change.
132
+ The old `minWidth: 300` on the frame widened the border but not the
133
+ columns, leaving a gap on the right. The rounding remainder goes to the
134
+ widest column, the only one where one more point doesn't show. */
135
+ const total = widths.reduce((sum, width) => sum + width, 0);
136
+ if (o.targetWidth > total && total > 0) {
137
+ const factor = o.targetWidth / total;
138
+ for (let i = 0; i < widths.length; i += 1) widths[i] = round(widths[i] * factor);
139
+ const missing = o.targetWidth - widths.reduce((sum, width) => sum + width, 0);
140
+ if (missing > 0) {
141
+ const widest = widths.indexOf(Math.max(...widths));
142
+ widths[widest] = round(widths[widest] + missing);
143
+ }
144
+ }
145
+
146
+ return { widths, numeric, totalWidth: widths.reduce((sum, width) => sum + width, 0) };
147
+ }
148
+
149
+ module.exports = { TABLE_COLUMN_DEFAULTS: DEFAULTS, isNumeric, measureColumns };