@lotics/ui 47.15.1 → 47.16.1

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/docs/catalog.md CHANGED
@@ -648,7 +648,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
648
648
  - **`markdown.css`** — import once for the web markdown styling.
649
649
  - **`format_date`** — `formatDate` / `parseDate` / `toISODate` + `DateFormatStyle` (incl.
650
650
  `monthYear` and `quarterYear`, the two period labels).
651
- - **`format_money`** — `formatMoney` / `formatCompactNumber`.
651
+ - **`format_money`** — `formatMoney` / `formatCompactNumber`. **`compact` abbreviates in the
652
+ LOCALE's own words**: `vi` gets "486 tr ₫" / "1,28 tỷ ₫", every other locale gets Intl's
653
+ compact currency ("$1.2M"). `tỷ` and `tr` are Vietnamese words, so a figure under `en-US`
654
+ never wears one — it reads as a typo, not as a number.
652
655
  - **`text_utils`** — text/typography plumbing: `getTextColor` (the TextColor→hex map incl.
653
656
  the AA-cleared valence set), the Inter `fontFamily*` stacks, and `getInputTextStyle` /
654
657
  `getInputLineHeight` — the 16px-mobile/14px-desktop input contract that stops Safari iOS
@@ -1147,7 +1150,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1147
1150
  nothing — `message` + `hint` + an optional `icon` anchor, and **no verb at all**. It cannot hold
1148
1151
  the section's add (that lives on the heading row, where it does not move — § The add-placement
1149
1152
  law in composition.md), a no-results empty is HINT-only since the filters that emptied it carry
1150
- their own clear, and a FAILED read is `ErrorState`, not this. **`compact`** for one BAND of a
1153
+ their own clear, and a FAILED read is `ErrorState`, not this. **`link` ({ label, onPress }) is a
1154
+ DESTINATION, not that verb** — reach for it only where what fills the region is made somewhere
1155
+ else, so the hint has an address to end at; it does not move when the first row lands and it
1156
+ asserts nothing about the read. **`compact`** for one BAND of a
1151
1157
  record — left-aligned, glyph-less, 56px against the default's 178; the centred default speaks
1152
1158
  for a whole region. → composition.md § Empty states.
1153
1159
  - **`error_state`** — `ErrorState`: the region-scale FAILED read — `message` + optional `detail`
@@ -1661,7 +1667,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1661
1667
  **And before reaching for a verb at all, ask whether the value is simply the field's DEFAULT** —
1662
1668
  a tariff, a list price, the figure a line is created with. Then there is no verb: the field
1663
1669
  already holds it, and clearing the field restores it. A button that re-applies what the field
1664
- would hold anyway is a control for a state the reader cannot reach. `extra` carries a second control belonging
1670
+ would hold anyway is a control for a state the reader cannot reach. **A flat line's `amount: null` is a cost NOBODY HAS STATED** — an excluded service, a
1671
+ figure the supplier still owes — and prints a dash, never the band's zero, because a service
1672
+ shown at zero reads as free; `0` stays a price somebody set. `extra` carries a second control belonging
1665
1673
  to the charge (how it was paid); `warning` names a problem on the line that has it; `locked`
1666
1674
  turns the editors into values for a settled band and draws no verb of any kind; `empty` speaks for a record nobody has priced
1667
1675
  yet. `formatMoney` is the BAND's, so two lines cannot disagree about a currency, and the `total`
@@ -367,7 +367,7 @@ the add returns to the right edge where the law puts it, and drag/paste keep wor
367
367
  with row count is the fault, not the cure.
368
368
  - **It renders unconditionally**, empty or not. A heading whose CTA appears only once there is
369
369
  data cannot be used to create the first row.
370
- - **The `EmptyState` then takes NO `action`** — see § Empty states.
370
+ - **The `EmptyState` then carries no add of its own** — see § Empty states.
371
371
  - **Only the ADD.** A verb that acts on a SELECTION (delete, export, run) belongs to the
372
372
  selection — `FloatingActionBar` — and a verb about ONE row stays on the row.
373
373
 
@@ -1457,8 +1457,8 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
1457
1457
 
1458
1458
  The ADD is the one verb this law does NOT place next to its effect, and the add-placement law
1459
1459
  says why. The heading row therefore carries identity + meta + view controls + the section's ADD,
1460
- and no other content verb. **An add is `primary` wherever it renders** — on that heading row,
1461
- or inside an `EmptyState` in the one case § Empty states allows, where there is no heading to carry it. It
1460
+ and no other content verb. **An add is `primary` wherever it renders** — and the heading row is
1461
+ where it renders, since `EmptyState` holds no verb (§ Empty states). It
1462
1462
  is the act its section offers, so it carries that section's weight; a lone `secondary` button
1463
1463
  reads as though the real action were somewhere else. The rule that matters is that ONE VERB
1464
1464
  KEEPS ONE WEIGHT — the same add must not be primary on an empty list and secondary on a full one.
@@ -2601,11 +2601,21 @@ what's empty, `hint` = what to do about it).
2601
2601
  duplicating it here would put two buttons for one act in view and make the add jump the moment
2602
2602
  the first row lands. A read that FAILED is not an empty one — it does not know whether there is
2603
2603
  anything — so it gets `ErrorState` (`message` + `detail` + `onRetry`), never an empty state
2604
- wearing an alert glyph. The three empties get DIFFERENT copy:
2604
+ wearing an alert glyph.
2605
2605
 
2606
- - **First use** teach + invite: message names what will live here, and the CTA — the
2607
- heading's, or `action` where there is no heading creates the first one ("Chưa khoản phí"
2608
- + "Thêm khoản thu/chi cho lô").
2606
+ **A DESTINATION is not that verb, and `link` is where it goes.** Where what fills the region is
2607
+ made somewhere else entirely another surface, a terminal, a page the reader has to open the
2608
+ hint needs an address to end at, and neither of the two refusals above applies to one: a
2609
+ destination was never this section's add, so it cannot jump when the first row lands, and it
2610
+ asserts nothing about the read, so it cannot dress a failure as an empty. It sits under the hint,
2611
+ as `Link` (underlined, blue, `role="link"`), and stays the last line: the region still says what
2612
+ is empty first. Anything the reader could press to fill the region HERE is the add, and belongs
2613
+ on the heading row.
2614
+
2615
+ The three empties get DIFFERENT copy:
2616
+
2617
+ - **First use** → teach + invite: message names what will live here, and the heading's CTA
2618
+ creates the first one ("Chưa có khoản phí" + "Thêm khoản thu/chi cho lô").
2609
2619
  - **User cleared it** → stay quiet: the user knows why it's empty; confirmation only, no tutorial.
2610
2620
  - **No search/filter results** → help recover: restate the scope ("Không có lô nào khớp
2611
2621
  'ABC'"), hint the fix ("Thử từ khóa khác / xóa bộ lọc"). Never a blank panel or bare "No data".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "47.15.1",
3
+ "version": "47.16.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./accordion": "./src/accordion.tsx",
@@ -73,7 +73,11 @@ export interface ChargeLineProps {
73
73
  quantity?: number | null;
74
74
  unitPrice?: number | null;
75
75
  /** Flat line: the amount itself. Ignored when `quantity`/`unitPrice` are set,
76
- * because a derived figure and a typed one cannot both be the truth. */
76
+ * because a derived figure and a typed one cannot both be the truth.
77
+ * `null` is a cost NOBODY HAS STATED — a supplier's exclusion, a line still
78
+ * awaiting a figure — and renders as a dash, never as the band's zero: a
79
+ * service printed at zero reads as free. Pass `0` for a price somebody
80
+ * actually set to nothing. */
77
81
  amount?: number | null;
78
82
  onAmountChange?: (next: number | null) => void | Promise<void>;
79
83
  /** The same, for a FLAT line's amount. Unconditional and `disabled` when idle. */
@@ -204,6 +208,14 @@ export function ChargeLine(props: ChargeLineProps) {
204
208
  : undefined;
205
209
  const priced = quantity !== undefined || unitPrice !== undefined;
206
210
  const amount = priced ? (quantity ?? 0) * (unitPrice ?? 0) : (props.amount ?? 0);
211
+ /**
212
+ * A flat line whose amount is NULL has no figure — a cost nobody has stated
213
+ * yet, a service the supplier excluded. Read-only it used to print the
214
+ * band's zero, which says the line is free; the editable spelling of the
215
+ * same value already showed the placeholder dash. One value cannot render
216
+ * as two different claims, and of the two only the dash is true.
217
+ */
218
+ const unstated = !priced && props.amount == null;
207
219
  /** The row's money verb — the unit price's on a priced line, the amount's on a
208
220
  * flat one. A locked band has nothing to act on, so it draws none. */
209
221
  const warning = props.warning ? (
@@ -276,8 +288,8 @@ export function ChargeLine(props: ChargeLineProps) {
276
288
  answer — and it lands in no column, so the total below closes nothing. */}
277
289
  <View style={styles.amountSlot}>
278
290
  {priced || locked || !props.onAmountChange ? (
279
- <Text size="sm" tabular style={[styles.right, styles.inset]}>
280
- {money(amount)}
291
+ <Text size="sm" tabular color={unstated ? "muted" : undefined} style={[styles.right, styles.inset]}>
292
+ {unstated ? "—" : money(amount)}
281
293
  </Text>
282
294
  ) : (
283
295
  <InlineNumberInput
@@ -1,6 +1,7 @@
1
1
  import { View, StyleSheet } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { Icon, type IconName } from "./icon";
4
+ import { Link } from "./link";
4
5
  import { colors } from "./colors";
5
6
 
6
7
  interface EmptyStateBase {
@@ -8,6 +9,16 @@ interface EmptyStateBase {
8
9
  message: string;
9
10
  /** What the user can do about it ("Thử từ khóa khác"). */
10
11
  hint?: string;
12
+ /**
13
+ * Where to go — an address, under the hint. NOT the `action` this refuses
14
+ * below: a destination is a different thing from a verb. It does not move
15
+ * when the first row lands (it was never this section's add), and it asserts
16
+ * nothing about the read (it is not a retry).
17
+ *
18
+ * Reach for it only where what fills the region lives SOMEWHERE ELSE, so the
19
+ * hint has an address to end at.
20
+ */
21
+ link?: { label: string; onPress: () => void };
11
22
  }
12
23
 
13
24
  /** The whole region: centred, generous, with an optional glyph anchoring it. */
@@ -74,9 +85,10 @@ type EmptyStateProps = EmptyRegionProps | EmptyBandProps;
74
85
  * is that nothing is known — use `ErrorState`. Retrying a true empty just
75
86
  * returns the same nothing.
76
87
  *
77
- * What is left says one thing and offers nothing to press: this region is empty,
78
- * and here is what would live in it. A no-results empty is hint-only too the
79
- * filters that emptied it carry their own clear.
88
+ * What is left says one thing and points, at most, somewhere else: this region
89
+ * is empty, here is what would live in it, and `link` the address where it
90
+ * gets made. A no-results empty is hint-only too — the filters that emptied it
91
+ * carry their own clear.
80
92
  */
81
93
  export function EmptyState(props: EmptyStateProps) {
82
94
  if (props.compact) {
@@ -87,6 +99,11 @@ export function EmptyState(props: EmptyStateProps) {
87
99
  <View style={styles.compactContainer}>
88
100
  <Text size="sm" color="muted" leading="tight">{props.message}</Text>
89
101
  {props.hint ? <Text size="xs" color="muted" leading="tight">{props.hint}</Text> : null}
102
+ {props.link ? (
103
+ <Link size="xs" onPress={props.link.onPress}>
104
+ {props.link.label}
105
+ </Link>
106
+ ) : null}
90
107
  </View>
91
108
  );
92
109
  }
@@ -105,6 +122,11 @@ export function EmptyState(props: EmptyStateProps) {
105
122
  {props.hint}
106
123
  </Text>
107
124
  ) : null}
125
+ {props.link ? (
126
+ <Link size="xs" onPress={props.link.onPress}>
127
+ {props.link.label}
128
+ </Link>
129
+ ) : null}
108
130
  </View>
109
131
  );
110
132
  }
@@ -1,9 +1,10 @@
1
1
  export interface FormatMoneyOptions {
2
2
  locale?: string;
3
3
  currency?: string;
4
- /** Abbreviate for display density: ≥1 tỷ "1,28 tỷ ₫", ≥1 triệu →
5
- * "486 tr ₫". For stat strips/cards where the full figure lives in the
6
- * table below never for the table itself. */
4
+ /** Abbreviate for display density in the LOCALE's own words: `vi` gets
5
+ * "1,28 tỷ ₫" / "486 tr ₫", everywhere else gets Intl's compact currency
6
+ * ("$1.2M"). For stat strips/cards where the full figure lives in the table
7
+ * below — never for the table itself. */
7
8
  compact?: boolean;
8
9
  /** Significant digits below the currency's minor unit. `Intl`'s currency
9
10
  * style floors at that unit — 2 for USD — so a genuine per-unit price like
@@ -14,18 +15,25 @@ export interface FormatMoneyOptions {
14
15
  }
15
16
 
16
17
  /**
17
- * Compact vi-style magnitude suffixes: ≥1 tỷ "1,28 tỷ", ≥1 triệu
18
- * "486 tr"; smaller values render in full. Bare numbers only — for money,
19
- * use `formatMoney` with `compact`.
18
+ * A magnitude abbreviated IN THE READER'S OWN LANGUAGE: `vi` gets its own
19
+ * words (≥1 tỷ → "1,28 tỷ", ≥1 triệu "486 tr"), every other locale gets
20
+ * Intl's compact notation ("1.2M", "486K"). Smaller values render in full.
21
+ * Bare numbers only — for money, use `formatMoney` with `compact`.
22
+ *
23
+ * `tỷ` and `tr` are Vietnamese WORDS, so they belong to a Vietnamese locale
24
+ * and nowhere else: appending them under `en-US` printed "1 tr USD" on a US
25
+ * register, which reads as a typo rather than as a number. A locale Intl has
26
+ * no compact form for falls back to grouped digits — long, and never foreign.
20
27
  */
21
28
  export function formatCompactNumber(value: number, locale = "vi-VN"): string {
29
+ if (Math.abs(value) < 1_000_000) return value.toLocaleString(locale);
30
+ if (!locale.startsWith("vi")) {
31
+ return value.toLocaleString(locale, { notation: "compact", maximumFractionDigits: 1 });
32
+ }
22
33
  if (Math.abs(value) >= 1_000_000_000) {
23
34
  return `${(value / 1_000_000_000).toLocaleString(locale, { maximumFractionDigits: 2 })} tỷ`;
24
35
  }
25
- if (Math.abs(value) >= 1_000_000) {
26
- return `${Math.round(value / 1_000_000).toLocaleString(locale)} tr`;
27
- }
28
- return value.toLocaleString(locale);
36
+ return `${Math.round(value / 1_000_000).toLocaleString(locale)} tr`;
29
37
  }
30
38
 
31
39
  /**
@@ -37,6 +45,11 @@ export function formatCompactNumber(value: number, locale = "vi-VN"): string {
37
45
  export function formatMoney(value: number, options: FormatMoneyOptions = {}): string {
38
46
  const { locale = "vi-VN", currency = "VND", compact = false, maxFractionDigits } = options;
39
47
  if (compact && Math.abs(value) >= 1_000_000) {
48
+ // Outside Vietnamese, Intl abbreviates the CURRENCY itself — "$1.2M" — so
49
+ // the symbol keeps its place instead of trailing a foreign magnitude word.
50
+ if (!locale.startsWith("vi")) {
51
+ return value.toLocaleString(locale, { style: "currency", currency, notation: "compact", maximumFractionDigits: 1 });
52
+ }
40
53
  const suffixed = formatCompactNumber(value, locale);
41
54
  return currency === "VND" ? `${suffixed} ₫` : `${suffixed} ${currency}`;
42
55
  }