@lotics/ui 46.1.0 → 46.3.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 +24 -0
- package/MIGRATION.md +87 -0
- package/docs/ai_patterns.md +34 -14
- package/docs/catalog.md +109 -22
- package/docs/composition.md +48 -6
- package/docs/templates.md +3 -1
- package/docs/testing.md +6 -0
- package/package.json +3 -2
- package/src/agent_run.tsx +74 -58
- package/src/agent_run_pane.tsx +7 -5
- package/src/alert.css +0 -1
- package/src/alert.tsx +8 -0
- package/src/axis_label_indices.ts +84 -0
- package/src/bar_chart.tsx +137 -16
- package/src/dialog.tsx +46 -24
- package/src/drawer.tsx +21 -2
- package/src/file_gallery_modal.tsx +3 -0
- package/src/line_chart.tsx +2 -2
- package/src/locale.tsx +4 -4
- package/src/modal.tsx +23 -3
- package/src/overlay_layer.ts +65 -0
- package/src/page_content.tsx +8 -22
- package/src/page_header.tsx +60 -11
- package/src/popover.tsx +29 -5
- package/src/skip_link.tsx +2 -1
- package/src/stacked_bar_chart.tsx +31 -1
- package/src/text.tsx +21 -0
- package/src/tooltip.tsx +2 -1
- package/src/use_change_set.ts +66 -17
- package/src/use_scroll_seam.ts +79 -0
- package/src/line_chart_labels.ts +0 -32
package/src/text.tsx
CHANGED
|
@@ -162,6 +162,21 @@ export function Text(props: TextProps) {
|
|
|
162
162
|
decoration && styles[decoration],
|
|
163
163
|
tabular && styles.tabular,
|
|
164
164
|
transform && styles[transform],
|
|
165
|
+
// A CLAMP THAT CANNOT SHRINK CANNOT CLAMP. `numberOfLines` says "cut this
|
|
166
|
+
// to fit", and on a flex row neither platform lets it: on web a clamped
|
|
167
|
+
// Text is `white-space: nowrap`, so its min-content width is the WHOLE
|
|
168
|
+
// string and `min-width: auto` floors it there; on native a `Text` is
|
|
169
|
+
// `flexShrink: 0` (unlike the web, where 1 is the CSS default), so it
|
|
170
|
+
// holds its full width outright. Either way two labelled values on one
|
|
171
|
+
// row lay out at intrinsic width and run off the frame — measured at
|
|
172
|
+
// x=790 in a 390px frame, with no page scroll to reach it.
|
|
173
|
+
//
|
|
174
|
+
// Both halves are needed because each fixes one platform, and both are
|
|
175
|
+
// NO-OPS unless the row actually overflows: flex-shrink only acts on
|
|
176
|
+
// negative free space. It sits before the caller's `style`, so a value
|
|
177
|
+
// that must never give way still says so — `flexShrink: 0` on the site
|
|
178
|
+
// that means it, which is how a figure keeps winning over its label.
|
|
179
|
+
numberOfLines != null && styles.clamped,
|
|
165
180
|
style,
|
|
166
181
|
]}
|
|
167
182
|
numberOfLines={numberOfLines}
|
|
@@ -259,6 +274,12 @@ const styles = StyleSheet.create({
|
|
|
259
274
|
fontVariant: ["tabular-nums"],
|
|
260
275
|
},
|
|
261
276
|
|
|
277
|
+
/** The pair a `numberOfLines` clamp needs to mean anything on a flex row. */
|
|
278
|
+
clamped: {
|
|
279
|
+
minWidth: 0,
|
|
280
|
+
flexShrink: 1,
|
|
281
|
+
},
|
|
282
|
+
|
|
262
283
|
// Text transform styles
|
|
263
284
|
uppercase: {
|
|
264
285
|
textTransform: "uppercase",
|
package/src/tooltip.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
|
+
import { OVERLAY_Z_ABOVE } from "./overlay_layer";
|
|
3
4
|
import { colors } from "./colors";
|
|
4
5
|
import { Text } from "./text";
|
|
5
6
|
|
|
@@ -51,7 +52,7 @@ export function TooltipProvider({ children }: { children: React.ReactNode }) {
|
|
|
51
52
|
container.style.width = "100%";
|
|
52
53
|
container.style.height = "100%";
|
|
53
54
|
container.style.pointerEvents = "none";
|
|
54
|
-
container.style.zIndex =
|
|
55
|
+
container.style.zIndex = String(OVERLAY_Z_ABOVE);
|
|
55
56
|
document.body.appendChild(container);
|
|
56
57
|
|
|
57
58
|
setPortalContainer(container);
|
package/src/use_change_set.ts
CHANGED
|
@@ -39,22 +39,52 @@ export interface ChangeSet<Id extends string = string> {
|
|
|
39
39
|
settled: boolean;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
export interface UseChangeSetOptions {
|
|
42
|
+
export interface UseChangeSetOptions<Id extends string = string> {
|
|
43
43
|
/**
|
|
44
44
|
* What an untouched proposal counts as. Default `accepted`: the operator
|
|
45
45
|
* drops the exceptions rather than approving each of eight identical lines,
|
|
46
46
|
* which is the difference between a review and a second round of data entry.
|
|
47
47
|
* Use `pending` when each change genuinely deserves its own verdict — and
|
|
48
48
|
* gate the commit on `settled`.
|
|
49
|
+
*
|
|
50
|
+
* **A MAP when one set mixes kinds with different safe defaults** — filling a
|
|
51
|
+
* blank arrives `accepted` while overwriting a value a human already set
|
|
52
|
+
* arrives `rejected`, so the destructive half is opt-in. An id the map does
|
|
53
|
+
* not name arrives `accepted`. Deciding those rows by calling `reject()` from
|
|
54
|
+
* the result handler instead writes overrides the operator never made, and
|
|
55
|
+
* `undo` on such a row then returns it to `accepted` rather than to the safe
|
|
56
|
+
* default it was supposed to arrive at.
|
|
57
|
+
*
|
|
58
|
+
* A VALUE and not a predicate, so the default is a dependency like any other:
|
|
59
|
+
* `status` and the group arrays are derived from it together and change
|
|
60
|
+
* identity together when it changes. The caller closes over its own rows to
|
|
61
|
+
* build it (`useMemo` over the same rows it already has), which is what keeps
|
|
62
|
+
* this hook ignorant of what a proposal IS. A predicate written inline is a
|
|
63
|
+
* new function every render, so it can only be honoured by hiding it from the
|
|
64
|
+
* dependency lists — and a `status` that never changes identity is a `status`
|
|
65
|
+
* a memoizing screen reads once and then never again, which is the row
|
|
66
|
+
* rendering as kept while the commit bar counts it as dropped.
|
|
49
67
|
*/
|
|
50
|
-
initial?: ChangeDecision
|
|
68
|
+
initial?: ChangeDecision | ReadonlyMap<Id, ChangeDecision>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The default for one id: the whole set's decision, or the map's entry for it,
|
|
72
|
+
* or `accepted` for an id nobody named. */
|
|
73
|
+
function defaultFor<Id extends string>(
|
|
74
|
+
initial: ChangeDecision | ReadonlyMap<Id, ChangeDecision> | undefined,
|
|
75
|
+
id: Id,
|
|
76
|
+
): ChangeDecision {
|
|
77
|
+
if (initial === undefined) return "accepted";
|
|
78
|
+
if (typeof initial === "string") return initial;
|
|
79
|
+
return initial.get(id) ?? "accepted";
|
|
51
80
|
}
|
|
52
81
|
|
|
53
82
|
export function useChangeSet<Id extends string = string>(
|
|
54
83
|
ids: readonly Id[],
|
|
55
|
-
options?: UseChangeSetOptions
|
|
84
|
+
options?: UseChangeSetOptions<Id>,
|
|
56
85
|
): ChangeSet<Id> {
|
|
57
|
-
const initial = options?.initial
|
|
86
|
+
const initial = options?.initial;
|
|
87
|
+
|
|
58
88
|
const [overrides, setOverrides] = useState<ReadonlyMap<Id, ChangeDecision>>(new Map());
|
|
59
89
|
|
|
60
90
|
const set = useCallback((id: Id, decision: ChangeDecision) => {
|
|
@@ -79,21 +109,40 @@ export function useChangeSet<Id extends string = string>(
|
|
|
79
109
|
[ids],
|
|
80
110
|
);
|
|
81
111
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
112
|
+
// ONE derivation, feeding BOTH readers of the same fact.
|
|
113
|
+
//
|
|
114
|
+
// Deriving the groups and `status` separately is what split them: they read
|
|
115
|
+
// the same three inputs twice, and the moment one of those inputs was kept out
|
|
116
|
+
// of a dependency list the two answered differently — `status(id)` said
|
|
117
|
+
// "rejected" while `accepted`, `keptCount` and the commit still carried the
|
|
118
|
+
// row. One memo over the same deps cannot do that. There is nothing expensive
|
|
119
|
+
// here either way: it is one pass over ids, the same pass `status` used to
|
|
120
|
+
// make per call.
|
|
121
|
+
const derived = useMemo(() => {
|
|
122
|
+
const decisions = new Map<Id, ChangeDecision>();
|
|
85
123
|
const accepted: Id[] = [];
|
|
86
124
|
const rejected: Id[] = [];
|
|
87
125
|
const pending: Id[] = [];
|
|
88
126
|
for (const id of ids) {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
127
|
+
const decision = overrides.get(id) ?? defaultFor(initial, id);
|
|
128
|
+
decisions.set(id, decision);
|
|
129
|
+
if (decision === "accepted") accepted.push(id);
|
|
130
|
+
else if (decision === "rejected") rejected.push(id);
|
|
92
131
|
else pending.push(id);
|
|
93
132
|
}
|
|
94
|
-
return { accepted, rejected, pending };
|
|
133
|
+
return { decisions, accepted, rejected, pending };
|
|
95
134
|
}, [ids, overrides, initial]);
|
|
96
135
|
|
|
136
|
+
// Reads the SAME map the groups were built from, so the two cannot disagree,
|
|
137
|
+
// and changes identity whenever an answer does — a screen that memoizes its
|
|
138
|
+
// rows on `status` re-runs when a decision moves. An id outside `ids` still
|
|
139
|
+
// answers (its own override, else the default): a row can leave the set while
|
|
140
|
+
// a handler still holds its id.
|
|
141
|
+
const status = useCallback(
|
|
142
|
+
(id: Id) => derived.decisions.get(id) ?? overrides.get(id) ?? defaultFor(initial, id),
|
|
143
|
+
[derived, overrides, initial],
|
|
144
|
+
);
|
|
145
|
+
|
|
97
146
|
return useMemo(
|
|
98
147
|
() => ({
|
|
99
148
|
status,
|
|
@@ -103,13 +152,13 @@ export function useChangeSet<Id extends string = string>(
|
|
|
103
152
|
acceptAll: () => all("accepted"),
|
|
104
153
|
rejectAll: () => all("rejected"),
|
|
105
154
|
reset: () => setOverrides(new Map()),
|
|
106
|
-
accepted:
|
|
107
|
-
rejected:
|
|
108
|
-
pending:
|
|
109
|
-
keptCount:
|
|
155
|
+
accepted: derived.accepted,
|
|
156
|
+
rejected: derived.rejected,
|
|
157
|
+
pending: derived.pending,
|
|
158
|
+
keptCount: derived.accepted.length,
|
|
110
159
|
total: ids.length,
|
|
111
|
-
settled:
|
|
160
|
+
settled: derived.pending.length === 0,
|
|
112
161
|
}),
|
|
113
|
-
[status, set, undo, all,
|
|
162
|
+
[status, set, undo, all, derived, ids.length],
|
|
114
163
|
);
|
|
115
164
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef } from "react";
|
|
2
|
+
import type { NativeScrollEvent, NativeSyntheticEvent, ScrollView } from "react-native";
|
|
3
|
+
|
|
4
|
+
/** The three props a seam needs on the `ScrollView` it manages. Spread, so a
|
|
5
|
+
* surface cannot wire half of it. */
|
|
6
|
+
export interface ScrollSeam {
|
|
7
|
+
ref: React.RefObject<ScrollView | null>;
|
|
8
|
+
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
|
9
|
+
scrollEventThrottle: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The identity of the content while nothing is swapped in.
|
|
14
|
+
*
|
|
15
|
+
* `scrollKey` is optional, and the natural call site — `scrollKey={openChild?.id}`,
|
|
16
|
+
* which is what the master-detail recipe prescribes — passes `undefined` for the
|
|
17
|
+
* MASTER leg. Reading that as "this surface opts out" broke exactly half of the
|
|
18
|
+
* seam: the forward leg worked (undefined → "child" is a change, so the child
|
|
19
|
+
* opened at 0) while the return leg restored nothing, because the master's
|
|
20
|
+
* offset had never been recorded under any key. The reader came back to the
|
|
21
|
+
* CHILD's offset inside the parent list — worse than either 0 or the remembered
|
|
22
|
+
* place.
|
|
23
|
+
*
|
|
24
|
+
* So an absent key is a KEY, not an opt-out. Opting out needs no signal of its
|
|
25
|
+
* own: a surface that never changes its key never scrolls, because the seam
|
|
26
|
+
* fires on the CHANGE and there is none.
|
|
27
|
+
*/
|
|
28
|
+
const ROOT_KEY = "\u0000root";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* REMEMBERED SCROLL OFFSETS ACROSS A CONTENT SWAP.
|
|
32
|
+
*
|
|
33
|
+
* A scroll container keeps its offset when its children change, because nothing
|
|
34
|
+
* tells it the content it was holding no longer exists. Swap a drawer's body for
|
|
35
|
+
* a child record and the new record opens part-way down itself, with its own
|
|
36
|
+
* heading off-screen above — invisible until the first list long enough to
|
|
37
|
+
* scroll, which is the same list that makes the swap worth having.
|
|
38
|
+
*
|
|
39
|
+
* The seam is the content's IDENTITY: while `scrollKey` holds, the container is
|
|
40
|
+
* left alone; when it changes, the outgoing key's offset is already recorded and
|
|
41
|
+
* the incoming one is restored — 0 for a key never seen, so a swap FORWARD opens
|
|
42
|
+
* at the top, and the remembered offset on the way BACK, so the reader keeps
|
|
43
|
+
* their place in the list they came from.
|
|
44
|
+
*
|
|
45
|
+
* That return leg is the whole reason this is not a React `key` on the scroll
|
|
46
|
+
* area. A key throws the container away and rebuilds it, which resets the child
|
|
47
|
+
* correctly and resets the PARENT just as thoroughly.
|
|
48
|
+
*
|
|
49
|
+
* No key CHANGE ⇒ no scrolling: a surface that does not swap content behaves
|
|
50
|
+
* exactly as it did before, whether it names its content or not. An ABSENT key
|
|
51
|
+
* is the root content's identity (see {@link ROOT_KEY}), not a request to skip
|
|
52
|
+
* the seam — which is what `scrollKey={openChild?.id}` needs on the leg where
|
|
53
|
+
* no child is open.
|
|
54
|
+
*/
|
|
55
|
+
export function useScrollSeam(scrollKey: string | undefined): ScrollSeam {
|
|
56
|
+
const key = scrollKey ?? ROOT_KEY;
|
|
57
|
+
const ref = useRef<ScrollView | null>(null);
|
|
58
|
+
const offsets = useRef(new Map<string, number>());
|
|
59
|
+
// Which key the live offset belongs to. Read by `onScroll`, which fires long
|
|
60
|
+
// after the render that changed the prop.
|
|
61
|
+
const liveKey = useRef(key);
|
|
62
|
+
|
|
63
|
+
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
64
|
+
offsets.current.set(liveKey.current, event.nativeEvent.contentOffset.y);
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
useLayoutEffect(() => {
|
|
68
|
+
const previous = liveKey.current;
|
|
69
|
+
liveKey.current = key;
|
|
70
|
+
// On mount `previous` IS the key, so nothing is scrolled — a fresh container
|
|
71
|
+
// is already at the top and moving it would be a visible jump on open.
|
|
72
|
+
if (previous === key) return;
|
|
73
|
+
ref.current?.scrollTo({ y: offsets.current.get(key) ?? 0, animated: false });
|
|
74
|
+
}, [key]);
|
|
75
|
+
|
|
76
|
+
// Native emits one scroll event per gesture without this, so the offset the
|
|
77
|
+
// seam remembers would be wherever the finger first landed.
|
|
78
|
+
return { ref, onScroll, scrollEventThrottle: 16 };
|
|
79
|
+
}
|
package/src/line_chart_labels.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Which of a line chart's x positions get a printed label.
|
|
3
|
-
*
|
|
4
|
-
* A label is roughly `minLabelWidth` wide, so only so many fit across the
|
|
5
|
-
* track; the rest are thinned out. The rule that matters is WHERE the thinning
|
|
6
|
-
* is anchored: stepping forwards from the first point and then forcing the last
|
|
7
|
-
* one in collides whenever the series length minus one is not a multiple of the
|
|
8
|
-
* step — the newest label lands a few pixels from the one before it while every
|
|
9
|
-
* other pair is a full step apart. Anchoring on the LAST point makes the spacing
|
|
10
|
-
* uniform by construction, and the last point is the one a reader looks up
|
|
11
|
-
* first. The first point is then kept only when it clears the same distance.
|
|
12
|
-
*/
|
|
13
|
-
export function lineChartLabelIndices(
|
|
14
|
-
count: number,
|
|
15
|
-
chartWidth: number,
|
|
16
|
-
minLabelWidth = 50,
|
|
17
|
-
): number[] {
|
|
18
|
-
if (count <= 0 || chartWidth <= 0) return [];
|
|
19
|
-
if (count === 1) return [0];
|
|
20
|
-
|
|
21
|
-
const last = count - 1;
|
|
22
|
-
const maxLabels = Math.max(2, Math.floor(chartWidth / minLabelWidth));
|
|
23
|
-
const step = Math.max(1, Math.ceil(count / maxLabels));
|
|
24
|
-
|
|
25
|
-
const kept: number[] = [];
|
|
26
|
-
for (let i = last; i >= 0; i -= step) kept.unshift(i);
|
|
27
|
-
|
|
28
|
-
const firstKept = kept[0] ?? 0;
|
|
29
|
-
if (firstKept > 0 && (firstKept / last) * chartWidth >= minLabelWidth) kept.unshift(0);
|
|
30
|
-
|
|
31
|
-
return kept;
|
|
32
|
-
}
|