@lotics/ui 47.4.0 → 47.5.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 +2 -2
- package/docs/catalog.md +9 -0
- package/docs/composition.md +9 -0
- package/package.json +2 -1
- package/src/just_arrived.ts +70 -0
- package/src/table.tsx +74 -2
package/AGENTS.md
CHANGED
|
@@ -14,10 +14,10 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
14
14
|
|
|
15
15
|
| Doc | Read it for |
|
|
16
16
|
|---|---|
|
|
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. |
|
|
17
|
+
| [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — incl. `just_arrived` (`useJustArrived` + `TableRow`'s `justArrived`: the one-shot wash a row wears when it LANDS, so a realtime push is visible rather than one row of two hundred silently changing) — 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, choosing a CHOICE control by option count, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW, the `FilesEditor` COMPOUND for a whole section, plus the three-way file INTAKE), stage gates, and the commit-on-blur vs action-press ordering law. |
|
|
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; findings, provenance, confidence; **after the run** — a stored record that fills up from several writers; the whole run in a dialog, **stopping**; **review surfaces compose from atoms** — `DiffValue`, `DiffMark`, `useChangeSet`. |
|
|
20
|
-
| [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS before reaching for parts), canvas + content column, heading altitude, register vs inset rows, the button ladder, master-detail `Drawer`, the register's rhythm, craft and TRIAGE bands, where the accent goes, theming as an app-level ESCAPE HATCH, color discipline, typography, **one fact one surface** (a chooser over the thing it chooses is a SELECTOR, not a second card grid; a control that only swaps one number for another is replaced by showing both), **a width floor answers can this be READ and never can this be operated** (the operating question passes a row whose every string is already cut), **a status column earns its slot by carrying a REASON rather than a state** (the floor asks for the fact to be visible, not for a badge), and where a commit sits. |
|
|
20
|
+
| [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS before reaching for parts), canvas + content column, heading altitude, register vs inset rows, the button ladder, master-detail `Drawer`, the register's rhythm, craft and TRIAGE bands, where the accent goes, theming as an app-level ESCAPE HATCH, color discipline, typography, **one fact one surface** (a chooser over the thing it chooses is a SELECTOR, not a second card grid; a control that only swaps one number for another is replaced by showing both), **a width floor answers can this be READ and never can this be operated** (the operating question passes a row whose every string is already cut), **a status column earns its slot by carrying a REASON rather than a state** (the floor asks for the fact to be visible, not for a badge), **a row subject's supporting line takes a REFERENCE and never a sentence** (an explanation truncates to the clause the reader already knew), and where a commit sits. |
|
|
21
21
|
| [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built — and the answer when one "looks bad"** — the other docs say what good looks like; this one says how to find out whether you achieved it. Run it BEFORE the deploy: a typecheck, a lint, a test and `app check` are all blind to how a screen reads. GATES that outrank every treatment question, then the probes, each naming what to COLLECT and the SIGNATURE in the numbers — including the truncation sweep (a row whose halves both measure fine and whose text is cut) the summary-vs-facet check (a strip that keeps describing the unfiltered set) and the derivable-column test (a status badge its neighbour already answers). Plus the extraction snippets, what measurement cannot see, and how to act on a finding. |
|
|
22
22
|
| [docs/testing.md](./docs/testing.md) | Driving the kit in a browser — the three anatomies where the a11y tree says one thing and a driver must do another: a `PressDoor` row whose named button always intercepts pointer events (by design), portalled overlays that render at the top of the DOM, and custom pointer drag that `dragTo` cannot move. |
|
|
23
23
|
| [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) and the ACTIVITY shape — a communications feed where the row's label is the GIST and the body varies by medium, one anatomy rather than a row type per kind. |
|
package/docs/catalog.md
CHANGED
|
@@ -298,6 +298,15 @@ by size.)
|
|
|
298
298
|
ordinary case, not an exotic one; compose a hand-designed
|
|
299
299
|
card pile (`PressableRow` + your own hierarchy) over `useScreenSize` only when a screen
|
|
300
300
|
deserves a better mobile shape than the automatic stack.
|
|
301
|
+
**A row that LANDS while someone is looking takes `justArrived`**, paired with
|
|
302
|
+
`useJustArrived(ids, scope)` from `@lotics/ui/just_arrived` — the push that delivers it is
|
|
303
|
+
otherwise invisible, because the list is one row longer and nothing says which one. Pass the
|
|
304
|
+
hook's set, never a hand-rolled diff: it owns the two rules that make the naive version
|
|
305
|
+
unusable — a FIRST render is not an arrival (or a register opening on two hundred rows flashes
|
|
306
|
+
all of them), and a `scope` change reseeds silently (or picking a status flashes every row the
|
|
307
|
+
filter swapped in). WRONG PLACE: it is not a way to mark a row you want the reader to notice —
|
|
308
|
+
that is `marked`, or a badge in a cell. The question that decides it is *did this row exist a
|
|
309
|
+
moment ago*; if the answer does not change on its own, this is not the prop.
|
|
301
310
|
- **Inline-managed grouped table** (MODERATE — hundreds, low-thousands — you MANAGE in view)
|
|
302
311
|
— the `DataGrid` primitive: a grouped, sortable grid whose cells are LIVE inline editors
|
|
303
312
|
(ANY field — a column is `{ key, label, width?, sortable?, cell: (item) => ReactNode }`,
|
package/docs/composition.md
CHANGED
|
@@ -1623,6 +1623,15 @@ Deciding from the CONTENT instead — prose gets `sm`, reference data gets `xs`
|
|
|
1623
1623
|
that holds both. An account name beside a contract number is one line, and it takes ONE rung: the
|
|
1624
1624
|
pair's, not each half's.
|
|
1625
1625
|
|
|
1626
|
+
**That line takes a REFERENCE, never a sentence.** It is what tells two rows with the same subject
|
|
1627
|
+
apart — a code, a counterparty, a date — so it is short by nature and survives every width. A
|
|
1628
|
+
sentence put there truncates to its opening clause in any column narrow enough to be a column, and
|
|
1629
|
+
the opening clause of an explanation is the half the reader already knew: *"Reordered after the
|
|
1630
|
+
downtown branch's…"*. It measures as an ordinary truncation and reads as a row that says nothing.
|
|
1631
|
+
The sentence is not homeless — a reason, a note, a hand-typed instruction belongs in what the row
|
|
1632
|
+
OPENS, beside the values it explains, where a full line is available. Where a row has no detail to
|
|
1633
|
+
open, the sentence is telling you the register wanted one.
|
|
1634
|
+
|
|
1626
1635
|
**A STACKED PAIR takes `leading="tight"` — it is not prose.** The ramp's body rungs carry prose
|
|
1627
1636
|
leading (14/24, ratio 1.71), which is right for a paragraph and wrong for two lines that are one
|
|
1628
1637
|
object: it leaves ~10px of empty box between them, so the pair reads as two separate lines. Beside
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "47.
|
|
3
|
+
"version": "47.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./accordion": "./src/accordion.tsx",
|
|
@@ -159,6 +159,7 @@
|
|
|
159
159
|
"./inset": "./src/inset.tsx",
|
|
160
160
|
"./interaction_modality": "./src/interaction_modality.ts",
|
|
161
161
|
"./json_panel": "./src/json_panel.tsx",
|
|
162
|
+
"./just_arrived": "./src/just_arrived.ts",
|
|
162
163
|
"./keyboard": "./src/keyboard.ts",
|
|
163
164
|
"./kpi_card": "./src/kpi_card.tsx",
|
|
164
165
|
"./kpi_strip": "./src/kpi_strip.tsx",
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/** Shared so an unchanged result keeps its identity and re-renders nothing. */
|
|
4
|
+
const EMPTY: ReadonlySet<string> = new Set<string>();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which ids are new, given what was on screen before — the rule, without React.
|
|
8
|
+
*
|
|
9
|
+
* `before` is `null` when nothing has been seen yet, and that case is the whole
|
|
10
|
+
* reason this is a named function: **a first render is never an arrival.** A
|
|
11
|
+
* register opening on two hundred rows has two hundred ids it has not seen, and
|
|
12
|
+
* the naive diff flashes all of them — both wrong (nothing arrived, the page
|
|
13
|
+
* loaded) and the moment a highlight is least wanted.
|
|
14
|
+
*
|
|
15
|
+
* A removal reports nothing. A row leaving is not a row arriving, and the two
|
|
16
|
+
* share a diff only if you write it carelessly.
|
|
17
|
+
*/
|
|
18
|
+
export function arrivalsSince(
|
|
19
|
+
before: ReadonlySet<string> | null,
|
|
20
|
+
ids: readonly string[],
|
|
21
|
+
): string[] {
|
|
22
|
+
if (before === null) return [];
|
|
23
|
+
return ids.filter((id) => !before.has(id));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Which of these ids are NEW since the last render — the set a register passes
|
|
28
|
+
* to `TableRow`'s `justArrived`.
|
|
29
|
+
*
|
|
30
|
+
* A row landing while someone is looking at the screen is the case the realtime
|
|
31
|
+
* push exists for, and until something marks it the push is invisible: the list
|
|
32
|
+
* is one row longer and nothing says which one. Every register wants the same
|
|
33
|
+
* diff, so it lives here rather than in each app.
|
|
34
|
+
*
|
|
35
|
+
* **`scope` is what the ids are a list OF** — the filter, the search, the page.
|
|
36
|
+
* Without it a register cannot tell twelve rows ARRIVING from a filter change
|
|
37
|
+
* that replaced the list, because both are a large diff, and it would flash the
|
|
38
|
+
* whole page every time someone picked a status. Change the scope and the
|
|
39
|
+
* baseline reseeds silently: nothing arrived, the reader asked a different
|
|
40
|
+
* question. Pass a string that changes exactly when the QUESTION does.
|
|
41
|
+
*
|
|
42
|
+
* An EMPTY render never seeds the baseline either: a list rendering `[]` while
|
|
43
|
+
* its query is in flight would otherwise make its real first page look like an
|
|
44
|
+
* arrival.
|
|
45
|
+
*
|
|
46
|
+
* Ids leave the set on the next render that carries them, so the highlight is a
|
|
47
|
+
* one-shot — re-rendering never restarts an animation that already played.
|
|
48
|
+
*/
|
|
49
|
+
export function useJustArrived(ids: readonly string[], scope = ""): ReadonlySet<string> {
|
|
50
|
+
const seen = useRef<Set<string> | null>(null);
|
|
51
|
+
const seenScope = useRef(scope);
|
|
52
|
+
const [fresh, setFresh] = useState<ReadonlySet<string>>(EMPTY);
|
|
53
|
+
|
|
54
|
+
// Keyed on the ids' CONTENT: an array literal is a new reference every render,
|
|
55
|
+
// so depending on the array itself would re-run this forever.
|
|
56
|
+
const key = ids.join(",");
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (ids.length === 0) return;
|
|
60
|
+
const sameQuestion = seenScope.current === scope;
|
|
61
|
+
seenScope.current = scope;
|
|
62
|
+
const added = arrivalsSince(sameQuestion ? seen.current : null, ids);
|
|
63
|
+
seen.current = new Set(ids);
|
|
64
|
+
setFresh(added.length > 0 ? new Set(added) : EMPTY);
|
|
65
|
+
// `ids` is covered by `key`, which is its content rather than its identity.
|
|
66
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
67
|
+
}, [key, scope]);
|
|
68
|
+
|
|
69
|
+
return fresh;
|
|
70
|
+
}
|
package/src/table.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createContext,
|
|
3
3
|
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
4
6
|
useState,
|
|
5
7
|
Children,
|
|
6
8
|
cloneElement,
|
|
@@ -8,7 +10,7 @@ import {
|
|
|
8
10
|
type ReactNode,
|
|
9
11
|
type ReactElement,
|
|
10
12
|
} from "react";
|
|
11
|
-
import { StyleSheet, View, type ViewStyle } from "react-native";
|
|
13
|
+
import { Animated, StyleSheet, View, type ViewStyle } from "react-native";
|
|
12
14
|
import { Text } from "./text";
|
|
13
15
|
import { colors, solid, type ColorName } from "./colors";
|
|
14
16
|
import { PressableRow } from "./pressable_row";
|
|
@@ -16,6 +18,13 @@ import { PressDoor } from "./press_door";
|
|
|
16
18
|
import { DetailRow } from "./detail_row";
|
|
17
19
|
import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
|
|
18
20
|
import { COLUMN_GAP, ROW_GUTTER, ROW_HEIGHT, computeTableFit, type TableFit, type TableFitColumn } from "./table_fit";
|
|
21
|
+
|
|
22
|
+
/** How long an arrival wash sits at full strength before fading, and how long
|
|
23
|
+
* the fade takes. Together they are the window a reader has to notice the row —
|
|
24
|
+
* short enough that a busy register does not shimmer, long enough to survive a
|
|
25
|
+
* glance away. */
|
|
26
|
+
const ARRIVAL_HOLD_MS = 900;
|
|
27
|
+
const ARRIVAL_FADE_MS = 700;
|
|
19
28
|
import { ROW_WASH_BLEED } from "./control_surface";
|
|
20
29
|
|
|
21
30
|
/**
|
|
@@ -359,6 +368,21 @@ export type TableRowProps = {
|
|
|
359
368
|
selected?: boolean;
|
|
360
369
|
/** Part of a multi-select set — a resting blue tint (the open/hover wash overrides it). */
|
|
361
370
|
marked?: boolean;
|
|
371
|
+
/**
|
|
372
|
+
* This row LANDED — it was not here a moment ago. Plays a wash that fades out
|
|
373
|
+
* once, so a reader looking at the register when the push arrives can see
|
|
374
|
+
* which row is the new one.
|
|
375
|
+
*
|
|
376
|
+
* Pair it with `useJustArrived`, which owns the diff and the rule that a first
|
|
377
|
+
* render is never an arrival. Passing it per-row from a hand-rolled diff is
|
|
378
|
+
* how a register ends up flashing all two hundred rows on load.
|
|
379
|
+
*
|
|
380
|
+
* It is an OVERLAY, not a fifth interaction state: `backgroundColor` on this
|
|
381
|
+
* row already has one owner (hover / selected / marked / pressed), and a
|
|
382
|
+
* second writer would make the two agree only by luck. So an arrival composes
|
|
383
|
+
* with all of them — a new row you are hovering shows both.
|
|
384
|
+
*/
|
|
385
|
+
justArrived?: boolean;
|
|
362
386
|
/** Outside the door, BEFORE the cells (a selection checkbox) — width = Table `leading`. */
|
|
363
387
|
leading?: ReactNode;
|
|
364
388
|
/**
|
|
@@ -452,8 +476,46 @@ export type TableRowProps = {
|
|
|
452
476
|
* the remaining cells as label-over-value blocks — same door, same wash, same
|
|
453
477
|
* slots, different geometry.
|
|
454
478
|
*/
|
|
479
|
+
/**
|
|
480
|
+
* The one-shot wash a row wears when it has just landed.
|
|
481
|
+
*
|
|
482
|
+
* OPACITY, never `backgroundColor` — that property belongs to the row's four
|
|
483
|
+
* interaction states, and a second writer would make them agree only by accident
|
|
484
|
+
* (see `pressable_row`). Fading an overlay composes with all four instead, and
|
|
485
|
+
* opacity is what `useNativeDriver` can animate off the JS thread, which matters
|
|
486
|
+
* on the surface most likely to be rendering two hundred rows.
|
|
487
|
+
*
|
|
488
|
+
* `accent_wash` is the token the kit reserves for ATTENTION — a drop target
|
|
489
|
+
* lighting up — as distinct from the neutral grounds that say which record is
|
|
490
|
+
* open. A row that arrived while you were looking at it is exactly that.
|
|
491
|
+
*
|
|
492
|
+
* It renders nothing until it has something to say, so an ordinary register pays
|
|
493
|
+
* no extra view per row, and it never takes a press.
|
|
494
|
+
*/
|
|
495
|
+
function ArrivalWash({ on }: { on: boolean }) {
|
|
496
|
+
const fade = useRef(new Animated.Value(0)).current;
|
|
497
|
+
const [live, setLive] = useState(false);
|
|
498
|
+
|
|
499
|
+
useEffect(() => {
|
|
500
|
+
if (!on) return;
|
|
501
|
+
setLive(true);
|
|
502
|
+
fade.setValue(1);
|
|
503
|
+
// Held, THEN faded: a wash that begins mid-fade reads as a rendering
|
|
504
|
+
// artefact rather than as something arriving.
|
|
505
|
+
Animated.sequence([
|
|
506
|
+
Animated.delay(ARRIVAL_HOLD_MS),
|
|
507
|
+
Animated.timing(fade, { toValue: 0, duration: ARRIVAL_FADE_MS, useNativeDriver: true }),
|
|
508
|
+
]).start(({ finished }) => {
|
|
509
|
+
if (finished) setLive(false);
|
|
510
|
+
});
|
|
511
|
+
}, [on, fade]);
|
|
512
|
+
|
|
513
|
+
if (!live) return null;
|
|
514
|
+
return <Animated.View pointerEvents="none" style={[styles.arrival, { opacity: fade }]} />;
|
|
515
|
+
}
|
|
516
|
+
|
|
455
517
|
export function TableRow(props: TableRowProps) {
|
|
456
|
-
const { onPress, selected, marked, accessibilityLabel, leading, ordinal, trailing, action, minHeight, detail, expanded, children } = props;
|
|
518
|
+
const { onPress, selected, marked, justArrived, accessibilityLabel, leading, ordinal, trailing, action, minHeight, detail, expanded, children } = props;
|
|
457
519
|
const ctx = useContext(TableContext);
|
|
458
520
|
if (!ctx) throw new Error("TableRow must be used within a Table");
|
|
459
521
|
// Read AFTER the context check so an airy register does not need every row to
|
|
@@ -522,6 +584,7 @@ export function TableRow(props: TableRowProps) {
|
|
|
522
584
|
|
|
523
585
|
const rowSurface = (
|
|
524
586
|
<PressableRow onPress={onPress} selected={selected || showDetail} marked={marked} style={styles.row}>
|
|
587
|
+
<ArrivalWash on={justArrived === true} />
|
|
525
588
|
{!ctx.stacked ? <LeadGutter ordinal={ordinal}>{leading}</LeadGutter> : null}
|
|
526
589
|
{/* The door hit-tests above in-flow content; the cells/slots lift back above it
|
|
527
590
|
via zIndex 1 (see `PressDoor`), so the door gets only the keyboard. Radius
|
|
@@ -597,6 +660,15 @@ export function TableCell(props: TableCellProps) {
|
|
|
597
660
|
}
|
|
598
661
|
|
|
599
662
|
const styles = StyleSheet.create({
|
|
663
|
+
arrival: {
|
|
664
|
+
position: "absolute",
|
|
665
|
+
top: 0,
|
|
666
|
+
left: 0,
|
|
667
|
+
right: 0,
|
|
668
|
+
bottom: 0,
|
|
669
|
+
borderRadius: 12,
|
|
670
|
+
backgroundColor: colors.accent_wash,
|
|
671
|
+
},
|
|
600
672
|
// The expanded detail. Indented to the identity column's text edge so it reads
|
|
601
673
|
// as belonging to the row above rather than as a new row, and given the row's
|
|
602
674
|
// own gutter so its content lines up with the cells it explains.
|