@lotics/ui 45.5.0 → 45.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/AGENTS.md CHANGED
@@ -68,11 +68,15 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
68
68
  `HeadingAltitudeContext`. Do not look for a per-surface heading variant and do not hand-pick a
69
69
  size: a `##` inside a drawer is 24px over the drawer's own 20px title.
70
70
  → [composition.md §The heading ramp](./docs/composition.md).
71
- - **`EmptyState` carries NO verb, and a FAILED read is not an empty one.** Four region states,
72
- picked by what the region can ASSERT: `Skeleton`/`Loading` in flight → **`ErrorState`**
73
- (`message`/`detail`/`onRetry`) on failure → `EmptyState` (succeeded, found nothing) →
74
- `CompletionState` on done. An alert glyph inside an empty state claims the read succeeded when
75
- nothing is known. [catalog.md §Status / feedback](./docs/catalog.md).
71
+ - **`EmptyState` carries NO verb, a FAILED read is not an empty one, and a BAND is not a region.**
72
+ Four region states, picked by what the region can ASSERT: `Skeleton`/`Loading` in flight →
73
+ **`ErrorState`** (`message`/`detail`/`onRetry`) on failure → `EmptyState` (succeeded, found
74
+ nothing) → `CompletionState` on done. An alert glyph inside an empty state claims the read
75
+ succeeded when nothing is known. How much quiet it reserves is a density axis — `compact` for
76
+ one band of a record (left-aligned, and glyph-less because an inline mark beside `sm` text
77
+ cannot be both legible and cap-aligned in this icon set), the default for a whole region.
78
+ → [catalog.md §Status / feedback](./docs/catalog.md),
79
+ [composition.md §Empty states](./docs/composition.md).
76
80
  - **Progress: ONE compound, TWO axes — and both are about meaning.** A run of markable rows is
77
81
  `checklist` (`Checklist` + `ChecklistGroup` + `ChecklistItem`, over `stepper`). **`connected`**
78
82
  asks *is this a sequence* — on for one record walking positions, off for N items handled in any
package/MIGRATION.md CHANGED
@@ -20,11 +20,14 @@ chips from carrying their own remove ✕.
20
20
  now means the removable RESTING chip and takes `(option, { remove })`, so borrowing it for the
21
21
  menu put a ✕ on every option in the list.
22
22
 
23
- **Every option list's rows sit 4px apart.** `OptionList` declared that spacing all along, but
23
+ **Every option list's rows sit 2px apart.** `OptionList` declared that spacing all along, but
24
24
  put it on the ScrollView's viewport rather than its content container, where it fell between
25
25
  nothing — so rows rendered flush and two adjacent SELECTED rows merged into one tinted block. Every
26
26
  selector that opens a list (`Select`, `Picker`, `InlineSelect`, the member pickers, menus) is now
27
- slightly taller for the same number of rows, and a list capped at its 320px max shows one fewer.
27
+ slightly taller for the same number of rows. A field's chips take 4px on BOTH axes the row gap was
28
+ half the column gap, pinching a wrapped field against its own first line. A list row is read top to
29
+ bottom and needs only enough space not to merge with its neighbour; a chip sits beside other
30
+ objects and wants the wider setting.
28
31
 
29
32
  **A multi `InlineSelect`'s chips follow the working set while its list is open** — the draft you
30
33
  are building, not the stored value. Ticking a row or pressing a chip's ✕ now shows immediately
package/docs/catalog.md CHANGED
@@ -927,11 +927,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
927
927
  - **`callout`** — `Callout`, `CalloutTitle`, `CalloutText`, `CalloutActions` (`tone`
928
928
  info|success|warning|error|neutral): inline status band — a MESSAGE (`warning`/`error`
929
929
  are ARIA `alert`s). For a form / nested content surface use **`Inset`**, never a Callout.
930
- - **`empty_state`** — `EmptyState`: centered placeholder for a read that SUCCEEDED and found
930
+ - **`empty_state`** — `EmptyState`: placeholder for a read that SUCCEEDED and found
931
931
  nothing — `message` + `hint` + an optional `icon` anchor, and **no verb at all**. It cannot hold
932
932
  the section's add (that lives on the heading row, where it does not move — § The add-placement
933
933
  law in composition.md), a no-results empty is HINT-only since the filters that emptied it carry
934
- their own clear, and a FAILED read is `ErrorState`, not this.
934
+ their own clear, and a FAILED read is `ErrorState`, not this. **`compact`** for one BAND of a
935
+ record — left-aligned, glyph-less, 56px against the default's 178 — because the
936
+ heading above it has already proved the surface rendered; the centred default speaks for a
937
+ whole region, where nothing else is in view to say so. → composition.md § Empty states.
935
938
  - **`error_state`** — `ErrorState`: the region-scale FAILED read — `message` + optional `detail`
936
939
  + `onRetry` (the kit renders the button and words it from the locale pack, so "try again" reads
937
940
  the same everywhere). The fourth of the region states: `Skeleton`/`Loading` in flight → this on
@@ -2011,6 +2011,33 @@ wearing an alert glyph. The three empties get DIFFERENT copy:
2011
2011
  - **No search/filter results** → help recover: restate the scope ("Không có lô nào khớp
2012
2012
  'ABC'"), hint the fix ("Thử từ khóa khác / xóa bộ lọc"). Never a blank panel or bare "No data".
2013
2013
 
2014
+ **How much QUIET it reserves depends on what it speaks for — `compact` inside a record.** The
2015
+ default holds a screenful because nothing else is in view: collapsing to two grey lines would
2016
+ read as a rendering bug rather than a state, which is the first of the three jobs. Inside a
2017
+ record that premise is false. The band's heading above it, the sections below it and the add
2018
+ verb beside it have already proved the surface rendered; the only open question is what this one
2019
+ band holds, and a screenful is a long way to say "nothing yet". Measured on a four-section
2020
+ record where three bands can be empty at once, the default spent **178px per band** and pushed
2021
+ the section the reader opened the record FOR under the fold on a 768px screen — every probe
2022
+ passes on that page, because nothing is wrong with any element on it.
2023
+
2024
+ So: `compact` on a band, the default on a region. It is a DENSITY axis, not a second component —
2025
+ same three jobs, same two strings, same no-verb rule. Compact also sets its text to the LEFT,
2026
+ because the heading directly above it establishes that edge and a centred block beneath a
2027
+ left-aligned heading reads as a fragment of a different page (§ Canvas & content column).
2028
+
2029
+ **An inline mark beside `sm` text cannot be both legible and aligned, so compact takes no glyph.**
2030
+ Worth knowing as a general fact about this icon set, not just about this component: it is lucide
2031
+ at a fixed stroke on a 24 viewBox, so the artwork fills 20/24 of whatever box you give it. Beside
2032
+ `sm` text the CAP HEIGHT is 11px, so a mark has to be ~13px to sit ON the line instead of
2033
+ straddling it — and at 13–16px a glyph with interior detail (`package`, `receipt`, `file-text`)
2034
+ closes up into a smudge. At 20px it reads, and its 16.7px of artwork then stands half again as
2035
+ tall as the capitals beside it, rising above them and dropping past the baseline. Three sizes,
2036
+ three different complaints, no size that answers both. Reach for a mark beside text only where
2037
+ the glyph is SIMPLE (`check`, a chevron, a dot) or the text is `md` or larger; a detailed glyph
2038
+ wants the block form, where it gets 28px and a line of its own. Here the anchoring is already
2039
+ done by the band's heading, which is the premise the whole variant rests on.
2040
+
2014
2041
  ### Buttons & action labels — the outcome, verb-first
2015
2042
 
2016
2043
  A label names the OUTCOME of pressing — verb + object, 2–4 words (GOV.UK: "Save and continue",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "45.5.0",
3
+ "version": "45.6.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -3,20 +3,64 @@ import { Text } from "./text";
3
3
  import { Icon, type IconName } from "./icon";
4
4
  import { colors } from "./colors";
5
5
 
6
- interface EmptyStateProps {
6
+ interface EmptyStateBase {
7
7
  /** What's empty, in plain language ("Không có nhân viên nào khớp"). */
8
8
  message: string;
9
9
  /** What the user can do about it ("Thử từ khóa khác"). */
10
10
  hint?: string;
11
- /** Optional Lucide glyph centered above the message — a visual anchor so the
12
- * empty region reads as a deliberate state, not two lone lines of muted text. */
11
+ }
12
+
13
+ /** The whole region: centred, generous, with an optional glyph anchoring it. */
14
+ interface EmptyRegionProps extends EmptyStateBase {
15
+ compact?: false;
16
+ /** Lucide glyph centred above the message — a visual anchor so the empty
17
+ * region reads as a deliberate state, not two lone lines of muted text. */
13
18
  icon?: IconName;
14
19
  }
15
20
 
16
21
  /**
17
- * Centered placeholder for an empty list/filter result. Generous vertical
18
- * space on purpose — an empty region that collapses to nothing reads as a
19
- * rendering bug, not a state.
22
+ * ONE BAND of a record, rather than the whole region.
23
+ *
24
+ * The default reserves a screenful of quiet because it speaks for everything in
25
+ * view: nothing else is on the surface, so collapsing to two grey lines would
26
+ * read as a rendering bug. Inside a record that premise is false — the heading
27
+ * above it, the sections below it and the add verb beside it all say the surface
28
+ * rendered fine, and the only open question is what this one band holds.
29
+ *
30
+ * Measured on a four-section record where three bands can be empty at once, the
31
+ * default cost 178px per band and pushed the section a reader had opened the
32
+ * record FOR below the fold on a 768px screen. Compact says the same thing in
33
+ * 56, and sets to the LEFT — a band's heading already establishes that edge, and
34
+ * a centred block under a left-aligned heading reads as a fragment of another
35
+ * page.
36
+ *
37
+ * IT TAKES NO GLYPH, and that is a property of this icon set rather than a
38
+ * preference. The set is lucide at a fixed stroke on a 24 viewBox, so its
39
+ * artwork fills 20/24 of whatever box it is given:
40
+ *
41
+ * - beside `sm` text the CAP HEIGHT is 11px, so an inline mark has to be
42
+ * ~13px to sit ON the line rather than straddle it;
43
+ * - at 13-16px a glyph with interior detail (`package`, `receipt`,
44
+ * `file-text`) closes up and reads as a smudge;
45
+ * - at 20px it reads, but its artwork is 16.7px against that 11px cap — half
46
+ * again as tall as the capitals, rising above them and dropping past the
47
+ * baseline.
48
+ *
49
+ * There is no size that is both legible and aligned, which is why tuning it
50
+ * three times produced three different complaints. The anchor's job — "this
51
+ * region is deliberate, not stray text" — is already done here by the heading
52
+ * and its add button, which is the very premise this variant rests on. So the
53
+ * type refuses the prop rather than the component silently dropping it.
54
+ */
55
+ interface EmptyBandProps extends EmptyStateBase {
56
+ compact: true;
57
+ icon?: never;
58
+ }
59
+
60
+ type EmptyStateProps = EmptyRegionProps | EmptyBandProps;
61
+
62
+ /**
63
+ * Placeholder for an empty list, filter result, or record band.
20
64
  *
21
65
  * IT CARRIES NO VERB — the prop is gone, not narrowed. This took an
22
66
  * `action?: ReactNode` and two different things went into it, both wrong here:
@@ -35,6 +79,17 @@ interface EmptyStateProps {
35
79
  * filters that emptied it carry their own clear.
36
80
  */
37
81
  export function EmptyState(props: EmptyStateProps) {
82
+ if (props.compact) {
83
+ return (
84
+ // `tight` on BOTH lines — the two are one object, and prose leading puts
85
+ // enough empty box between them to read as two (§ Typography). The pair
86
+ // measured 44px for 26px of type before this.
87
+ <View style={styles.compactContainer}>
88
+ <Text size="sm" color="muted" leading="tight">{props.message}</Text>
89
+ {props.hint ? <Text size="xs" color="muted" leading="tight">{props.hint}</Text> : null}
90
+ </View>
91
+ );
92
+ }
38
93
  return (
39
94
  <View style={styles.container}>
40
95
  {props.icon ? (
@@ -70,4 +125,8 @@ const styles = StyleSheet.create({
70
125
  line: {
71
126
  textAlign: "center",
72
127
  },
128
+ compactContainer: {
129
+ paddingVertical: 8,
130
+ gap: 2,
131
+ },
73
132
  });
@@ -306,6 +306,9 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
306
306
  }
307
307
 
308
308
  const styles = StyleSheet.create({
309
- tags: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 4, rowGap: 2 },
309
+ // ONE gap on both axes: a wrapped chip is the same object as the one beside it,
310
+ // so the second line should breathe like the first. The row gap used to be half
311
+ // the column gap, which pinched a wrapped field against its own first line.
312
+ tags: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 4 },
310
313
  error: { marginTop: 4 },
311
314
  });
@@ -244,7 +244,11 @@ const styles = StyleSheet.create({
244
244
  maxHeight: 320,
245
245
  },
246
246
  optionsContent: {
247
- gap: 4,
247
+ // 2, where a field's chips sit at 4. A row here is a 40px LIST line read top to
248
+ // bottom, and needs only enough space to stop two tinted rows reading as one
249
+ // block; a chip is an object sitting beside other objects and wants the wider
250
+ // setting. Two numbers because they answer two questions, not one token split.
251
+ gap: 2,
248
252
  },
249
253
  selectAllContainer: {
250
254
  flexDirection: "row",