@lotics/ui 34.0.0 → 35.0.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
@@ -17,7 +17,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
17
17
  | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
18
18
  | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (the `FilesEditor` COMPOUND — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
19
19
  | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md)., the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
20
- | [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other), banded cards, register vs inset rows, master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), color discipline, typography, whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
20
+ | [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), color discipline, typography, whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
21
21
  | [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget). |
22
22
 
23
23
  ## Iron rules
package/MIGRATION.md CHANGED
@@ -4,6 +4,35 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 35.0.0 — `FilterChip` has no Clear footer; the × is the clear
8
+
9
+ `FilterChip` no longer renders a Clear button in its popover footer. **Nothing to
10
+ change at a call site** — `onClear` and `clearLabel` are unchanged, and the × on
11
+ the pill still clears, still carries `clearLabel` as its tooltip, and still
12
+ replaces the chevron whenever the chip is active.
13
+
14
+ **Why.** The pill's × and the footer button did the same thing, and the footer
15
+ cost a whole bordered band to say it twice. The editors this pill exists to wrap
16
+ bring their own bottom actions — an `OptionList` with `enableSelectAll` renders
17
+ "Select all / Deselect all" in a bordered band of its own — so a multi-select
18
+ filter ended with TWO stacked rules, the lower one holding a single button that
19
+ "Deselect all" directly above it already performed. Measured on a live filter:
20
+ bands of 49px and 47px, each with its own 1px top border.
21
+
22
+ The × is also the more reachable of the two: it is on the pill, which is on
23
+ screen whether or not the popover is open, while the footer button needed the
24
+ panel opened first.
25
+
26
+ **One behaviour change worth knowing:** passing a custom `footer` no longer
27
+ suppresses the ×. `showClear` used to gate both, so a Save-footer pill silently
28
+ lost its clear — if you were relying on that to hide the ×, pass no `onClear`
29
+ instead, which is what "not clearable" has always meant.
30
+
31
+ **If your filter had no other way to empty itself**, it still does: the ×. And if
32
+ your editor genuinely needs a clear INSIDE the panel (a range slider, a counter —
33
+ controls with no per-option deselect), put one in the `footer` slot, which is
34
+ still there and is now the only thing that draws a footer band.
35
+
7
36
  ## 34.0.0 — one avatar rung fits a control band
8
37
 
9
38
  **`AVATAR_PX.md` is 28, not 36.** Nothing to change at a call site — `md` is still
package/docs/catalog.md CHANGED
@@ -1117,7 +1117,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1117
1117
  `isColumnFilterActive` + `columnFilterSummary`: the typed per-column filter pill; for a
1118
1118
  register filtering on several columns.
1119
1119
  - **`filter_chip`** — `FilterChip` + `selectSummary`: the toolbar filter pill hosting a
1120
- facet (options, a `Slider range`, a `Counter`).
1120
+ facet (options, a `Slider range`, a `Counter`). It draws NO bottom band of its own — the ×
1121
+ on the pill is the clear, and the editor inside brings whatever actions it has, so a
1122
+ multi-select's "select all / deselect all" is the only rule in the panel. `footer` is for an
1123
+ editor that must COMMIT (Cancel / Save); reach for it only then, since it adds a second band
1124
+ under an editor that already has one.
1121
1125
  - **`summary_line`** — `SummaryLine`: the light inline summary of a register/list's FILTERED
1122
1126
  view, sits below the toolbar; NOT the boxed dashboard `kpi_strip` band. Every item is an
1123
1127
  AGGREGATE over the rows in view (a count, a sum, a fill), and the strip goes with the set it
@@ -397,6 +397,58 @@ so `Table` also serves read-only tabular data (a fee breakdown, a spec sheet), n
397
397
  registers. A pressable `TableRow` REQUIRES `accessibilityLabel` ("Open …") — its keyboard door is
398
398
  an empty overlay with no content to derive a name from.
399
399
 
400
+ **A CELL THAT DOES NOT SET THE ROW'S HEIGHT MUST NOT VARY WITH THE DATA.** A row centres its
401
+ cells, so such a cell's first line lands at `(rowH − cellH) / 2`: one line on some rows and two
402
+ on others puts that first line at two different offsets, and the column stops having a baseline.
403
+ Uniform ROW heights hide it completely — the rows measure identical, the content inside them does
404
+ not — and the drift is against the row's own centred chrome (ordinal, checkbox) as much as
405
+ against the neighbouring columns.
406
+
407
+ **It takes a height FLOOR to bite**, which is the part worth understanding rather than
408
+ memorising. A `TableRow` with `minHeight` above its tallest cell holds slack that centring then
409
+ distributes; without the floor the tallest cell sets the row, every cell starts at the top of its
410
+ own row, and nothing drifts. So the cell that IS tallest may vary freely — it just makes the rows
411
+ ragged instead, which is a different and far more visible problem. It is the cells living inside
412
+ another cell's slack that have to hold still.
413
+
414
+ **"Fixed" means the same every row, NOT one line.** A two-line cell is fine if it is two lines on
415
+ every row. And the cost is real: bounding a cell usually means `numberOfLines={1}` and
416
+ truncation, so you are buying a baseline with clipped text. Worth it for an identifier beside a
417
+ contact; think harder for a cell whose job is prose, where the honest answer may be to move that
418
+ content off the register entirely.
419
+
420
+ Two corollaries, both counter-intuitive enough to state:
421
+
422
+ - **Shrinking a NEIGHBOURING cell does not fix it.** The spread is a function of the varying
423
+ cell's own range, not of the row's height: a 20/42 cell spreads 11px in a 52px row and 11px in
424
+ a 68px one. Only bounding the cell that varies removes it.
425
+ - **The fix is never a cross-axis knob.** `Table` has no `align` (see MIGRATION 32.0.0): topping
426
+ the cells leaves the chrome centred, so the row reads as scatter instead.
427
+
428
+ Bound a cell by giving every row the same content, not by hoping the data is uniform. A line that
429
+ appears only when its field is set is the defect; RESERVING the line is the fix — **but only when
430
+ the field is nearly always present.** A box reserved for a value that half the rows lack reads as
431
+ a column that forgot something, which is its own defect: prefer a field the rows actually carry
432
+ (an identifier, a contact) over one that is merely interesting. A cell may hold more than one line
433
+ freely when something FIXED occupies its top — a meter, a thumbnail — because the primary text
434
+ then still lands near the row's centre, where the chrome already is. `tpl_item_list`'s identity
435
+ cell is the worked example: name on top, the supporting values UNCONDITIONALLY beneath.
436
+
437
+ **A pressable CELL wears `ROW_CONTROL_HOVER` / `ROW_CONTROL_PRESS`, never a hand-picked grey.**
438
+ Reaching a cell means crossing its row, so both wash at once — a cell painting the row's own
439
+ `zinc-100` vanishes under the pointer, and anything LIGHTER reads as a hole punched in the row.
440
+ The tokens are one step darker for exactly that reason, and they are what `IconButton`,
441
+ `CopyButton` and `CommentsButton` already paint, so a register whose cells use them speaks one
442
+ hover language. Bleed the wash outward (`marginHorizontal: -8` against its own padding) so the
443
+ cell's content stays on the column's edge — see `ROW_WASH_BLEED`.
444
+
445
+ **Size a flexible column by what it CARRIES, measured.** A `flex` share is a claim about content;
446
+ when the content shrinks — a value moves to the record, a stacked pair becomes a count — the
447
+ share left behind is white space, and the columns beside it pay for it. Measure the widest row's
448
+ content per column and set `flex` from that. A column whose cell holds a PROPORTION (a meter, a
449
+ bar) is the exception in one direction only: cap it, because a gauge stops reading as an
450
+ instrument once its track is long enough to be a rule across the row.
451
+
400
452
  Make a register SELECTABLE with the `Table` `leading` gutter + `selectAll` slot — a
401
453
  `CheckboxInput` per `TableRow` (its `leading` slot) + a select-all in the header band, the ticked
402
454
  rows `marked`, paired with a `FloatingActionBar` (its "Clear" escape is locale-resolved — pass
@@ -237,7 +237,16 @@ function HoSoRow({ hs, ordinal, daThu, selected, marked, selectable, onToggle, o
237
237
  because it is the one value copied often enough to earn one (a desk
238
238
  pastes it into a message, a rep dials it). The verb rides the value
239
239
  inside its own cell: the trailing gutter belongs to the ⋯, and with
240
- two values in this cell it would have nothing to name. */}
240
+ two values in this cell it would have nothing to name.
241
+
242
+ UNCONDITIONAL, and that is the load-bearing part of this shape. A
243
+ row centres its cells, so a supporting line that appears only when
244
+ its field is set makes this cell two heights, and the NAME above it
245
+ then sits at two different offsets down the column while every row
246
+ still measures the same. That is why the values here are ones every
247
+ record carries — a key, a contact — rather than whichever field is
248
+ most interesting: the line has to be honest on every row to be
249
+ reservable at all. */}
241
250
  <View style={{ flexDirection: "row", alignItems: "center", gap: 2 }}>
242
251
  {/* The KEY, muted, on the supporting line — at most this, never a
243
252
  column and never the row's name.
@@ -250,8 +259,13 @@ function HoSoRow({ hs, ordinal, daThu, selected, marked, selectable, onToggle, o
250
259
  gives way, which is also the right order of loss: the phone has a
251
260
  Copy control beside it that hands over the full value regardless. */}
252
261
  <Text size="xs" color="muted" tabular numberOfLines={1} style={{ flexShrink: 0 }}>{hs.ma}</Text>
253
- <Text size="xs" color="muted" style={{ flexShrink: 0 }}> · </Text>
254
- <Text size="xs" color="muted" tabular numberOfLines={1} style={{ flexShrink: 1 }}>{hs.dienThoai}</Text>
262
+ {/* A GAP, not a middot. Two facts of different kinds sat either side
263
+ of a ` · ` here, which is the separator this kit bans outright —
264
+ punctuation claiming a relation it refuses to name, and dropped
265
+ entirely by a screen reader. Spacing separates them without
266
+ asserting anything, and the two are already told apart by shape:
267
+ one is a key, one is a number with a Copy control on it. */}
268
+ <Text size="xs" color="muted" tabular numberOfLines={1} style={{ flexShrink: 1, marginLeft: 8 }}>{hs.dienThoai}</Text>
255
269
  <CopyButton value={hs.dienThoai} label="Copy phone number" />
256
270
  </View>
257
271
  </View>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "34.0.0",
3
+ "version": "35.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -7,7 +7,6 @@ import { Chip } from "./chip";
7
7
  import { Popover, PopoverTrigger, PopoverContent, PopoverFooter } from "./popover";
8
8
  import type { PopoverSide, PopoverAlign } from "./popover";
9
9
  import { useLoticsLocale } from "./locale";
10
- import { Button } from "./button";
11
10
 
12
11
  export interface FilterChipProps {
13
12
  /** The dimension name — shown alone when inactive ("Owner"), prefixed when
@@ -18,10 +17,12 @@ export interface FilterChipProps {
18
17
  * selection RICHLY in the trigger (member avatars, colour dots). Empty /
19
18
  * undefined renders the inactive pill (label + chevron, no clear). */
20
19
  summary?: ReactNode;
21
- /** Clears the dimension — renders the × on the pill AND a Clear in the popover
22
- * footer whenever a summary is present. */
20
+ /** Clears the dimension — renders the × on the pill whenever a summary is
21
+ * present. The × is the ONLY clear: the editor inside brings its own bottom
22
+ * actions, and a second clear in the popover footer stacked a redundant band
23
+ * under them. */
23
24
  onClear?: () => void;
24
- /** Label for the clear affordances (pass a translated string). */
25
+ /** Tooltip on the pill's × (pass a translated string). */
25
26
  clearLabel?: string;
26
27
  /** The editor revealed on press — a premium primitive (`Slider range`,
27
28
  * `Counter`, `OptionList` multi) or any composed control. `FilterChip` owns
@@ -35,8 +36,11 @@ export interface FilterChipProps {
35
36
  /** Optional controlled popover state — for an editor that closes on Save. */
36
37
  open?: boolean;
37
38
  onOpenChange?: (open: boolean) => void;
38
- /** A custom popover footer (e.g. Cancel / Save) — replaces the baked Clear.
39
- * Lets a non-filter VALUE pill (a setting gate) reuse the same shell. */
39
+ /** A custom popover footer (e.g. Cancel / Save) — for an editor that commits
40
+ * rather than applying live. Lets a non-filter VALUE pill (a setting gate)
41
+ * reuse the same shell. Reach for it only when the editor NEEDS a commit: an
42
+ * editor that already carries bottom actions of its own gets a second
43
+ * bordered band from this. */
40
44
  footer?: ReactNode;
41
45
  }
42
46
 
@@ -61,9 +65,11 @@ export function selectSummary(
61
65
  * filter dimension, the sibling of `ChipGroup` (which lays ONE hot dimension's
62
66
  * options out inline). Many dimensions stay scannable because each collapses to
63
67
  * a single pill: inactive reads "Label ⌄", active reads "Label: summary" with an
64
- * × to clear. `FilterChip` bakes the consistent chrome — the preview pill, a
65
- * padded popover body for the composed editor, and a Clear footer when active —
66
- * so every filter looks and behaves the same. Drop a premium primitive inside
68
+ * × to clear. `FilterChip` bakes the consistent chrome — the preview pill and a
69
+ * padded popover body for the composed editor so every filter looks and
70
+ * behaves the same. It adds NO bottom band of its own: the editor inside brings
71
+ * whatever actions it has, and a shell that also contributed one gave every
72
+ * multi-select filter two stacked rules. Drop a premium primitive inside
67
73
  * (`Slider range`, `Counter`, `OptionList` multi). With `footer` (+ controlled
68
74
  * `open`/`onOpenChange`) the same shell wraps a non-filter VALUE pill — a
69
75
  * setting gate with Cancel/Save — keeping it on the one pill surface. The table
@@ -73,9 +79,23 @@ export function FilterChip(props: FilterChipProps) {
73
79
  const { label, summary, onClear, children, side = "bottom", align = "start", open, onOpenChange, footer } = props;
74
80
  const clearLabel = props.clearLabel ?? useLoticsLocale().filterChip.clear;
75
81
  const active = summary != null && (typeof summary !== "string" || summary.length > 0);
76
- // The clear × / Clear footer only when there's a clearable selection AND no
77
- // custom footer — a valued, non-clearable pill ("Target: 20") keeps its chevron.
78
- const showClear = active && !!onClear && !footer;
82
+ // THE × IS THE CLEAR, and it is the only one. A valued, non-clearable pill
83
+ // ("Target: 20") keeps its chevron.
84
+ //
85
+ // There used to be a baked Clear in the popover footer as well, and it was a
86
+ // duplicate that cost a whole extra band. The editors this pill is built for
87
+ // bring their own bottom actions — an `OptionList` with `enableSelectAll`
88
+ // renders "Select all / Deselect all" in a bordered band of its own — so the
89
+ // popover ended with TWO stacked rules whose lower one held a single button
90
+ // that "Deselect all" directly above it already performed. Measured on a live
91
+ // filter: bands at 49px and 47px, each with its own 1px top border.
92
+ //
93
+ // The × is also the more discoverable of the two: it sits on the pill, which
94
+ // is on screen whether or not the popover is open, and it carries `clearLabel`
95
+ // as its tooltip. The footer button was reachable only by opening the panel
96
+ // first. A custom `footer` (Cancel / Save) no longer suppresses it either —
97
+ // that coupling meant a Save-footer pill silently lost its clear.
98
+ const showClear = active && !!onClear;
79
99
 
80
100
  // Own the open state so the editor can close itself via the render-prop
81
101
  // `close`, while still honoring a controlled `open`/`onOpenChange` from the
@@ -112,13 +132,7 @@ export function FilterChip(props: FilterChipProps) {
112
132
  </PopoverTrigger>
113
133
  <PopoverContent style={styles.body} disableBodyScroll>
114
134
  {typeof children === "function" ? children({ close: () => setOpen(false) }) : children}
115
- {footer ? (
116
- <PopoverFooter>{footer}</PopoverFooter>
117
- ) : showClear ? (
118
- <PopoverFooter align="start">
119
- <Button title={clearLabel} color="muted" onPress={onClear} />
120
- </PopoverFooter>
121
- ) : null}
135
+ {footer ? <PopoverFooter>{footer}</PopoverFooter> : null}
122
136
  </PopoverContent>
123
137
  </Popover>
124
138
  );
@@ -126,9 +140,9 @@ export function FilterChip(props: FilterChipProps) {
126
140
 
127
141
  const styles = StyleSheet.create({
128
142
  // The popover hugs its content — a wide control (e.g. Slider) sets its
129
- // OWN fixed width; the shell never forces one. No extra padding: the editor and
130
- // the Clear footer then share the popover's own 8px inset, so a multi-select's
131
- // options, its select-all, and the Clear all line up on one left edge.
143
+ // OWN fixed width; the shell never forces one. No extra padding: the editor
144
+ // then sits on the popover's own 8px inset, so a multi-select's options and
145
+ // its select-all band line up on one left edge.
132
146
  body: {
133
147
  gap: 8,
134
148
  },