@lotics/ui 47.5.1 → 47.6.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.
package/docs/catalog.md CHANGED
@@ -389,7 +389,10 @@ Worked example: [`tpl_board`](../examples/tpl_board.tsx).
389
389
  `TrendChip` (delta), `Sparkline`, `BarChart` / `LineChart` / `PieChart` (the canonical SVG
390
390
  set — no recharts), `ProgressRing`, `ProgressBar` (its `compact` prop = ONE row, track + a
391
391
  plain sm tabular count beside it — the cell/heading/peek-trigger meter. **The track clamps at
392
- 100%, the caption does not** — over its max it reads `2,100 / 2,000 (105%)`. Numbers format in the
392
+ 100%, the caption does not** — over its max it reads `2,100 / 2,000 (105%)`. **`max: null` is an
393
+ unbounded meter** — the caption states the value and the caller's `unboundedLabel`
394
+ (`1,250 (no limit)`) and the track stays empty, so a capped and an uncapped allowance side by
395
+ side stay one widget rather than a bar beside a hand-rolled lookalike. Numbers format in the
393
396
  reader's locale, so
394
397
  never hand-format the value you pass in — when display precision differs from the true value
395
398
  (whole credits off a fractional balance), `formatValue` reshapes the caption text and leaves
@@ -1028,7 +1031,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1028
1031
  - **`switcher`** — `Switcher`: current-item trigger opening a popover of `MenuButton` items
1029
1032
  (`SwitcherItem { id, label }`, `currentId`, `onSelect`) — the compact entity/workspace
1030
1033
  switcher.
1031
- - **`count`** — `Count`: a small circular count bubble (`color` highlight|muted|red).
1034
+ - **`count`** — `Count`: a 20px count disc that grows into a pill past one digit (`color` highlight|muted|red).
1032
1035
  - **`shortcut_badge`** — `ShortcutBadge`: the keycap hint pill — a zinc-50 badge rendering a
1033
1036
  shortcut from a raw string or `ShortcutDescriptor` (⌘B on Mac, Ctrl+B elsewhere); null on
1034
1037
  small screens. `TextInputField shortcut` renders it built-in, while the field is EMPTY —
@@ -2011,7 +2014,7 @@ component rather than showing it at zero.
2011
2014
  sizes the fixed label column (default 80, which fits a date and not a company name), and each
2012
2015
  item's **`leading`** slot carries that entity's own mark ahead of its name.
2013
2016
  - **`progress_bar`** — `ProgressBar`: the determinate meter; `compact` = ONE row, track + a
2014
- plain sm tabular count beside it.
2017
+ plain sm tabular count beside it; `max: null` + `unboundedLabel` = the same meter with no bound.
2015
2018
  - **`progress_ring`** — `ProgressRing`: the same meter in a circle, and the ONLY circular one. Same API shape as the bar — a real `value`/`max` rather than a
2016
2019
  pre-computed percentage, optional `label`/`caption`, and a `format` that turns the centre
2017
2020
  figure off. `format="none"` with no label leaves the arc bare at any size: that is the
@@ -136,6 +136,19 @@ the subject into rows.
136
136
  | Register / work list / calendar | 1040 |
137
137
  | Wide wallboard / report | 1100 |
138
138
 
139
+ **A row that navigates PAINTS.** A link is not a row: it paints nothing, so a row tall enough to
140
+ hold an identity mark becomes a large silent target — the pointer crosses it and the only thing
141
+ saying it is pressable is the cursor. Reach for the row surface (`PressableRow`), never a
142
+ hand-rolled hover on the anchor: the wash has to span the WHOLE row *including its trailing
143
+ controls*, and a parent's Pressability hover is released to the innermost nested pressable, which
144
+ stops the wash short of the `⋯` and leaves a gap exactly where the eye is going. Keep the anchor
145
+ INSIDE as the accessible door — a row of places to go owes middle-click, cmd-click and copy-link,
146
+ which only a real anchor gives.
147
+
148
+ Without that wash the trailing controls are what look wrong: nothing ties a `⋯` to its row, so the
149
+ right edge reads as a floating column competing with the names, and the instinct is to hide the
150
+ control. Paint the row first — the clutter usually WAS the missing wash.
151
+
139
152
  **ONE LEFT EDGE — the whole page, and then the whole APP.** Headings, detail labels, prose, a
140
153
  table's first column, a footer's totals, a dialog's title. Then check the screen this one OPENS: a
141
154
  list and the record it opens are one reading column seen twice, and both must derive from ONE
package/docs/reviewing.md CHANGED
@@ -137,6 +137,11 @@ rule behind it lives in the area doc named beside it; this file never restates o
137
137
  - **Same shape → same treatment.** Group every element by its visual SHAPE (label-over-value,
138
138
  figure+caption, icon+row, chip) and check each group has ONE treatment. A reader parses shape
139
139
  before meaning, so an unexplained difference reads as an accident however principled the reason.
140
+ **The source-side signature is a branch that renders a kit component on one arm and a
141
+ hand-built lookalike on the other** — a meter with no bound, a row with no value — written to
142
+ match the primitive on the day it was built. The primitive moves and the copy does not, and the
143
+ two arms never render side by side on the fixture that was screenshotted, so measure a screen
144
+ where BOTH arms show. The fix is the missing state on the primitive, never a closer copy.
140
145
  - **Singletons.** A rung/weight/ink combination appearing ONCE either wants siblings or wants to
141
146
  join an existing class. Pick the sibling set by STRUCTURAL ROLE, not visual resemblance.
142
147
  - **Count DISTINCT rungs on the surface, then name the role each one carries.** More rungs than
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "47.5.1",
3
+ "version": "47.6.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./accordion": "./src/accordion.tsx",
package/src/count.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Text } from "./text";
2
- import { View } from "react-native";
2
+ import { View, StyleSheet } from "react-native";
3
3
  import { colors } from "./colors";
4
4
 
5
5
  interface CountProps {
@@ -7,28 +7,33 @@ interface CountProps {
7
7
  color?: "highlight" | "muted" | "red";
8
8
  }
9
9
 
10
+ /**
11
+ * A count in a 20px disc that grows into a pill past one digit. `xs` because its line box (18)
12
+ * is the only body rung that fits inside the disc — `sm` is 24 tall, taller than the disc, so
13
+ * the digit was clipped and sat off-centre.
14
+ */
10
15
  export function Count(props: CountProps) {
11
16
  const { count, color = "muted" } = props;
12
17
 
13
18
  return (
14
19
  <View
15
- style={{
16
- width: 20,
17
- height: 20,
18
- justifyContent: "center",
19
- alignItems: "center",
20
- borderRadius: 999,
21
- backgroundColor:
22
- color === "highlight"
23
- ? colors.zinc["700"]
24
- : color === "red"
25
- ? colors.red["700"]
26
- : undefined,
27
- }}
20
+ style={[
21
+ styles.pill,
22
+ {
23
+ backgroundColor:
24
+ color === "highlight"
25
+ ? colors.zinc["700"]
26
+ : color === "red"
27
+ ? colors.red["700"]
28
+ : undefined,
29
+ },
30
+ ]}
28
31
  >
29
32
  <Text
30
33
  numberOfLines={1}
31
- size="sm"
34
+ size="xs"
35
+ weight="medium"
36
+ tabular
32
37
  color={color === "muted" ? "zinc-500" : "inverted"}
33
38
  userSelect="none"
34
39
  >
@@ -37,3 +42,14 @@ export function Count(props: CountProps) {
37
42
  </View>
38
43
  );
39
44
  }
45
+
46
+ const styles = StyleSheet.create({
47
+ pill: {
48
+ minWidth: 20,
49
+ height: 20,
50
+ paddingHorizontal: 6,
51
+ justifyContent: "center",
52
+ alignItems: "center",
53
+ borderRadius: 999,
54
+ },
55
+ });
package/src/popover.tsx CHANGED
@@ -433,6 +433,34 @@ export function PopoverContent(props: PopoverContentProps) {
433
433
  return () => document.removeEventListener("click", onOutsideClick, true);
434
434
  }, [open, small, onOpenChange, triggerRef]);
435
435
 
436
+ // The one press the click listener above CANNOT see: one that lands inside a
437
+ // cross-origin iframe. Those events are dispatched in the frame's own document
438
+ // and never cross into this one, so a popover anchored over an embedded app —
439
+ // the app page's own chrome is exactly this shape — stayed open while the user
440
+ // clicked around underneath it, with no way to dismiss but pressing the trigger
441
+ // again or hitting Escape.
442
+ //
443
+ // Focus is the signal that survives the boundary: clicking into a frame moves
444
+ // this document's `activeElement` to the IFRAME element and blurs the window.
445
+ //
446
+ // Checking `activeElement` is what keeps this from over-firing. A window blur
447
+ // alone also means "switched tab" or "moved to another app", and dismissing
448
+ // then is a menu that quietly disappears while the user was reading something
449
+ // else. Only a blur that HANDED FOCUS TO A FRAME is a press we missed.
450
+ useEffect(() => {
451
+ if (!open || small) return;
452
+ const onWindowBlur = () => {
453
+ // Deferred: at blur time the browser has not always moved `activeElement`
454
+ // to the frame yet, so reading it synchronously can still report the
455
+ // trigger and miss the dismissal entirely.
456
+ setTimeout(() => {
457
+ if (document.activeElement instanceof HTMLIFrameElement) onOpenChange(false);
458
+ }, 0);
459
+ };
460
+ window.addEventListener("blur", onWindowBlur);
461
+ return () => window.removeEventListener("blur", onWindowBlur);
462
+ }, [open, small, onOpenChange]);
463
+
436
464
  const calculatePosition = useCallback(() => {
437
465
  if (!popoverRef.current) return;
438
466
  if (small) return;
@@ -8,15 +8,20 @@ export type ProgressBarFormat = "percentage" | "fraction" | "none";
8
8
 
9
9
  export interface ProgressBarProps {
10
10
  value: number;
11
- max: number;
12
- /** What this bar measures ("Đã nhận", "Tiến độ giao") the xs muted
13
- * uppercase eyebrow above the bar. Omit inside a band that already names
14
- * it. */
11
+ /** `null` = no bound. The caption states the value and `unboundedLabel` in place of a
12
+ * fraction and the track stays empty, so a capped and an uncapped allowance side by side
13
+ * read as one widget. */
14
+ max: number | null;
15
+ /** What this bar measures ("Đã nhận", "Tiến độ giao") — the `Eyebrow` above the
16
+ * bar. Omit inside a band that already names it. */
15
17
  title?: string;
16
18
  /** Caption above-right of the bar: `percentage` → "50%", `fraction` →
17
19
  * "1,250 / 2,500 (50%)" (separators follow the reader's locale). Reports the
18
20
  * TRUE ratio — over `max` it reads "105%" while the track stays clamped. */
19
21
  format?: ProgressBarFormat;
22
+ /** The qualifier beside the value when `max` is null — "1,250 (no limit)". The kit carries
23
+ * no i18n, so the caller supplies the words; without them the caption is the bare value. */
24
+ unboundedLabel?: string;
20
25
  /**
21
26
  * How `value` and `max` render inside the caption. Defaults to the reader's locale grouping.
22
27
  *
@@ -61,6 +66,7 @@ export function ProgressBar(props: ProgressBarProps) {
61
66
  format = "percentage",
62
67
  formatValue,
63
68
  formatPercent,
69
+ unboundedLabel,
64
70
  color = colors.blue["500"],
65
71
  completeColor = colors.green["500"],
66
72
  compact = false,
@@ -76,17 +82,18 @@ export function ProgressBar(props: ProgressBarProps) {
76
82
  * never exceeds its max, so this only diverges for the over-allowance case, where the true
77
83
  * figure is the whole point.
78
84
  */
79
- const ratio = max > 0 ? Math.max(0, (value / max) * 100) : 0;
85
+ const ratio = max !== null && max > 0 ? Math.max(0, (value / max) * 100) : 0;
80
86
  const percentage = Math.min(100, ratio);
81
87
  const isComplete = ratio >= 100;
82
88
  const num = formatValue ?? ((n: number) => n.toLocaleString(localeTag));
83
89
  const pct = formatPercent
84
90
  ? (r: number) => formatPercent(r / 100)
85
91
  : (r: number) => `${Math.round(r)}%`;
92
+ const unbounded = unboundedLabel ? `${num(value)} (${unboundedLabel})` : num(value);
86
93
 
87
94
  if (compact) {
88
95
  const label =
89
- format === "percentage" ? pct(ratio) : `${num(value)}/${num(max)}`;
96
+ max === null ? unbounded : format === "percentage" ? pct(ratio) : `${num(value)}/${num(max)}`;
90
97
  return (
91
98
  <View style={styles.compactRow}>
92
99
  <View style={[styles.track, styles.compactTrack]}>
@@ -109,11 +116,13 @@ export function ProgressBar(props: ProgressBarProps) {
109
116
  }
110
117
 
111
118
  const caption =
112
- format === "fraction"
113
- ? `${num(value)} / ${num(max)} (${pct(ratio)})`
114
- : format === "percentage"
115
- ? pct(ratio)
116
- : null;
119
+ format === "none"
120
+ ? null
121
+ : max === null
122
+ ? unbounded
123
+ : format === "fraction"
124
+ ? `${num(value)} / ${num(max)} (${pct(ratio)})`
125
+ : pct(ratio);
117
126
 
118
127
  return (
119
128
  <View style={styles.container}>