@lotics/ui 44.2.0 → 44.4.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/MIGRATION.md +23 -0
- package/docs/ai_patterns.md +1 -0
- package/docs/catalog.md +15 -3
- package/package.json +1 -1
- package/src/composer.tsx +104 -2
- package/src/highlight_segments.ts +61 -0
- package/src/member_chip.tsx +13 -23
- package/src/member_peek.tsx +6 -3
- package/src/peek.tsx +37 -5
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,29 @@ 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
|
+
## 44.4.0 — `MemberChip`'s `marker` is gone
|
|
8
|
+
|
|
9
|
+
A minor that broke a type, recorded here under its exact version rather than held
|
|
10
|
+
for a major: `marker` existed for one release, had a single consumer inside the
|
|
11
|
+
kit, and underlined the chip's name to say "this opens something".
|
|
12
|
+
|
|
13
|
+
Delete the prop. A chip that opens something is a `MemberPeek`, which now carries
|
|
14
|
+
its own affordance through the trigger's control-band surface — the underline was
|
|
15
|
+
decorating the text to describe the container.
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
// before
|
|
19
|
+
<MemberChip name={m.name} marker />
|
|
20
|
+
|
|
21
|
+
// after
|
|
22
|
+
<MemberPeek name={m.name} identity={m.email} groups={m.groups} />
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
A chip carrying `secondary` also takes a larger mark now (`lg` rather than the
|
|
26
|
+
requested `md`), because two lines of text stand taller than a 28px avatar. Single
|
|
27
|
+
-line chips are unchanged. Nothing to do — but a layout that hard-coded 28px
|
|
28
|
+
beside a two-line chip will need the number.
|
|
29
|
+
|
|
7
30
|
## 44.0.0 — `Finding` is horizontal, and `FindingComparison` is gone
|
|
8
31
|
|
|
9
32
|
`Finding` stacked six blocks — badge, title, detail, a labelled row per side, a hairline, the
|
package/docs/ai_patterns.md
CHANGED
|
@@ -108,6 +108,7 @@ Key props (full API: `../src/composer.tsx`):
|
|
|
108
108
|
| `footerRight` | Footer-right content before Send (a model picker); forces expanded |
|
|
109
109
|
| `maxLines` | Expanded growth cap before the input scrolls (default 10) |
|
|
110
110
|
| `sendLabel` / `stopLabel` | Accessible/tooltip labels (default English "Send"/"Stop" — pass translations) |
|
|
111
|
+
| `highlightRanges` | Character ranges of `value` to tint. What a range MEANS is yours — the composer paints a ground behind those characters and nothing more. It renders a mirror BEHIND the real field rather than styling the field itself, so the browser stays the editor and IME, undo, paste and selection are untouched; omit the prop and no mirror is rendered at all |
|
|
111
112
|
| `textInputProps` | Extra `TextInput` props; a consumer `onKeyPress` runs before Enter-to-send and can `preventDefault()` to suppress it |
|
|
112
113
|
|
|
113
114
|
For a run that needs a FILE + a prompt together (attach a photo, review/remove it, add a note,
|
package/docs/catalog.md
CHANGED
|
@@ -1295,9 +1295,16 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1295
1295
|
reference is a FIELD, not a section — one pointer to one other row never earns a heading
|
|
1296
1296
|
and a rail entry. Worked example: [`tpl_record`](../examples/tpl_record.tsx) § General
|
|
1297
1297
|
(customer) and its Transport parties.
|
|
1298
|
-
- **`peek`** — `Peek`: drill-down for
|
|
1299
|
-
|
|
1300
|
-
|
|
1298
|
+
- **`peek`** — `Peek`: drill-down for a reference — press a name/id where it appears and get its
|
|
1299
|
+
details in an anchored popover, without leaving the screen; keep the content a summary with ONE
|
|
1300
|
+
action to the full record. **`variant` picks the register the TRIGGER sits in, and getting it
|
|
1301
|
+
wrong is visible:** `text` (default) boxes the child at the 32px chip scale and bleeds the extra
|
|
1302
|
+
height back out, so a reference inside a line of reading never grows that line; `control` takes
|
|
1303
|
+
the full `CONTROL_HEIGHT` band and the control radius, for a token that is a control-band citizen
|
|
1304
|
+
— anything built on `MemberChip`, whose `md` avatar is sized to `CONTROL_CONTENT_HEIGHT` for
|
|
1305
|
+
exactly that. On the wrong one a chip under-fills the band and stands a different height from the
|
|
1306
|
+
same chip un-peeked beside it. The horizontal bleed is common to both, so a peekable token keeps
|
|
1307
|
+
the surface's left edge.
|
|
1301
1308
|
|
|
1302
1309
|
### Lists, tables & registers
|
|
1303
1310
|
|
|
@@ -1599,6 +1606,11 @@ component rather than showing it at zero.
|
|
|
1599
1606
|
- **`matrix_totals`** — the data layer for `Matrix`: `matrixTotals` (the cross-tab
|
|
1600
1607
|
aggregation) + `MatrixAxisItem` / `MatrixCellRef` / `MatrixTotalsResult`; React-free, so a
|
|
1601
1608
|
KPI can be driven off the same numbers the grid shows.
|
|
1609
|
+
- **`highlight_segments`** — `splitHighlightSegments`: cuts a string into plain and
|
|
1610
|
+
highlighted runs for `Composer`'s `highlightRanges`. React-free, and the reason it is
|
|
1611
|
+
separate: it feeds a mirror sitting behind a real text field, so a boundary off by one
|
|
1612
|
+
paints the tint adrift while everything still looks like a working composer. Overlapping,
|
|
1613
|
+
unsorted and out-of-bounds ranges all normalize; no input drops a character.
|
|
1602
1614
|
- **`legend_item`** — `LegendItem`: one swatch + label of a chart legend.
|
|
1603
1615
|
- **`remainder_meter`** — `RemainderMeter`: allocated-vs-remaining meter
|
|
1604
1616
|
(`RemainderMeterLabels` localized via the provider).
|
package/package.json
CHANGED
package/src/composer.tsx
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import React, { type ReactNode, type Ref, useCallback, useEffect, useState } from "react";
|
|
2
|
-
import {
|
|
1
|
+
import React, { type ReactNode, type Ref, useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text as RNText,
|
|
5
|
+
TextInput as RNTextInput,
|
|
6
|
+
ScrollView,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
type NativeSyntheticEvent,
|
|
9
|
+
type TextInputProps,
|
|
10
|
+
type TextInputScrollEventData,
|
|
11
|
+
} from "react-native";
|
|
3
12
|
import { IconButton } from "./icon_button";
|
|
4
13
|
import { colors } from "./colors";
|
|
5
14
|
import { Text } from "./text";
|
|
6
15
|
import { fontFamilyRegular, getInputTextStyle, INPUT_LETTER_SPACING } from "./text_utils";
|
|
16
|
+
import { splitHighlightSegments, type HighlightRange } from "./highlight_segments";
|
|
7
17
|
import { useAutoGrowHeight } from "./use_auto_grow_height";
|
|
8
18
|
import { useLoticsLocale } from "./locale";
|
|
9
19
|
|
|
@@ -43,6 +53,10 @@ export interface ComposerProps {
|
|
|
43
53
|
/** Control text externally; omit to let the composer manage its own state. */
|
|
44
54
|
value?: string;
|
|
45
55
|
onChangeText?: (text: string) => void;
|
|
56
|
+
/** Ranges of `value` to tint. What they MEAN is the caller's business — this
|
|
57
|
+
* package never learns what a skill or a document is. Omit it and nothing is
|
|
58
|
+
* rendered, so a consumer that never tints pays nothing. */
|
|
59
|
+
highlightRanges?: readonly HighlightRange[];
|
|
46
60
|
/** Extra TextInput props (ref, onKeyPress, …). `onKeyPress` runs before the
|
|
47
61
|
* built-in Enter-to-send and can `preventDefault()` to suppress it. */
|
|
48
62
|
textInputProps?: Partial<TextInputProps> & { ref?: Ref<RNTextInput> };
|
|
@@ -79,6 +93,7 @@ export function Composer(props: ComposerProps) {
|
|
|
79
93
|
autoFocus,
|
|
80
94
|
testID,
|
|
81
95
|
textInputProps,
|
|
96
|
+
highlightRanges,
|
|
82
97
|
pills,
|
|
83
98
|
files,
|
|
84
99
|
children,
|
|
@@ -186,12 +201,85 @@ export function Composer(props: ComposerProps) {
|
|
|
186
201
|
// padding; `measureScrollHeight` subtracts that padding, so wrap-detection is
|
|
187
202
|
// unaffected. Growth past one line is the EXPANDED layout's job (up to maxLines).
|
|
188
203
|
const compactPadV = Math.max(2, Math.round((COMPACT_INPUT_HEIGHT - lineHeight) / 2));
|
|
204
|
+
|
|
205
|
+
// Null — not an empty array — when there is nothing to tint, so the mirror is
|
|
206
|
+
// not rendered at all for the ordinary message or for a consumer that never
|
|
207
|
+
// passes ranges.
|
|
208
|
+
const highlightSegments = useMemo(
|
|
209
|
+
() =>
|
|
210
|
+
highlightRanges && highlightRanges.length > 0
|
|
211
|
+
? splitHighlightSegments(text, highlightRanges)
|
|
212
|
+
: null,
|
|
213
|
+
[text, highlightRanges],
|
|
214
|
+
);
|
|
215
|
+
const [mirrorScrollY, setMirrorScrollY] = useState(0);
|
|
216
|
+
const handleInputScroll = useCallback(
|
|
217
|
+
(e: NativeSyntheticEvent<TextInputScrollEventData>) => {
|
|
218
|
+
// The two platforms report the offset in different places and neither has
|
|
219
|
+
// the other's: RN measures the content and sends `contentOffset`, while
|
|
220
|
+
// rn-web forwards the <textarea>'s DOM scroll event verbatim, where the
|
|
221
|
+
// only offset is the element's own `scrollTop`. Reading just the native
|
|
222
|
+
// shape type-checks — RN's types promise `contentOffset` — and then throws
|
|
223
|
+
// on the web, on every scroll tick, while the tint silently stops tracking
|
|
224
|
+
// the words in exactly the long message this handler exists to follow.
|
|
225
|
+
const native: Partial<TextInputScrollEventData> & { target?: { scrollTop?: number } } =
|
|
226
|
+
e.nativeEvent;
|
|
227
|
+
setMirrorScrollY(native.contentOffset?.y ?? native.target?.scrollTop ?? 0);
|
|
228
|
+
},
|
|
229
|
+
[],
|
|
230
|
+
);
|
|
189
231
|
const field = (
|
|
190
232
|
<View
|
|
191
233
|
key="field"
|
|
192
234
|
onLayout={measure}
|
|
193
235
|
style={expanded ? { height: containerHeight } : [styles.field, { height: COMPACT_INPUT_HEIGHT }]}
|
|
194
236
|
>
|
|
237
|
+
{highlightSegments ? (
|
|
238
|
+
// A MIRROR, not a replacement. The real <textarea> stays and keeps every
|
|
239
|
+
// behaviour the browser gives it — IME composition (this workspace types
|
|
240
|
+
// Vietnamese), undo, paste, spellcheck, selection, a11y — none of which
|
|
241
|
+
// survives a contentEditable rewrite. It renders TRANSPARENT text purely
|
|
242
|
+
// to lay out identically, and paints the tint behind the words the field
|
|
243
|
+
// is drawing on top; the field's own text and caret are untouched, so
|
|
244
|
+
// there is nothing here that can swallow a keystroke.
|
|
245
|
+
//
|
|
246
|
+
// Alignment is by construction: same text style object, same padding
|
|
247
|
+
// expressions, same width. It translates with the field's own scroll so
|
|
248
|
+
// the tint cannot drift once the message outgrows `maxLines`.
|
|
249
|
+
<View
|
|
250
|
+
pointerEvents="none"
|
|
251
|
+
style={[
|
|
252
|
+
StyleSheet.absoluteFill,
|
|
253
|
+
{ overflow: "hidden" },
|
|
254
|
+
expanded ? null : { paddingHorizontal: 10, paddingVertical: compactPadV },
|
|
255
|
+
]}
|
|
256
|
+
>
|
|
257
|
+
<RNText
|
|
258
|
+
testID="composer-highlight-mirror"
|
|
259
|
+
// The RAW react-native Text, never the kit's: `Text` bakes tracking
|
|
260
|
+
// per type-ramp rung, which quietly overrode the explicit
|
|
261
|
+
// `letterSpacing` the field uses and drifted the tint a fraction of
|
|
262
|
+
// a pixel per character — invisible on a short line, off the words
|
|
263
|
+
// by the end of a long one. The mirror takes the field's own style
|
|
264
|
+
// array and nothing else.
|
|
265
|
+
style={[
|
|
266
|
+
styles.textInput,
|
|
267
|
+
getInputTextStyle(),
|
|
268
|
+
{ color: "transparent" },
|
|
269
|
+
{ transform: [{ translateY: -mirrorScrollY }] },
|
|
270
|
+
]}
|
|
271
|
+
>
|
|
272
|
+
{highlightSegments.map((segment, i) => (
|
|
273
|
+
<RNText
|
|
274
|
+
key={i}
|
|
275
|
+
style={segment.highlighted ? styles.highlightSegment : undefined}
|
|
276
|
+
>
|
|
277
|
+
{segment.text}
|
|
278
|
+
</RNText>
|
|
279
|
+
))}
|
|
280
|
+
</RNText>
|
|
281
|
+
</View>
|
|
282
|
+
) : null}
|
|
195
283
|
<RNTextInput
|
|
196
284
|
ref={mergedInputRef}
|
|
197
285
|
autoFocus={autoFocus}
|
|
@@ -205,11 +293,19 @@ export function Composer(props: ComposerProps) {
|
|
|
205
293
|
multiline
|
|
206
294
|
scrollEnabled={expanded && scrollEnabled}
|
|
207
295
|
onContentSizeChange={onContentSizeChange}
|
|
296
|
+
onScroll={highlightSegments ? handleInputScroll : undefined}
|
|
208
297
|
{...spreadInputProps}
|
|
209
298
|
onKeyPress={handleKeyPress}
|
|
210
299
|
style={[
|
|
211
300
|
styles.textInput,
|
|
212
301
|
getInputTextStyle(),
|
|
302
|
+
// ABOVE the mirror. The mirror is absolutely positioned, and a
|
|
303
|
+
// positioned box paints over static in-flow content whatever the
|
|
304
|
+
// source order — so without this its tint is an opaque ground drawn
|
|
305
|
+
// ON TOP of the field, and the words inside a mention disappear
|
|
306
|
+
// entirely. Geometry and font metrics can all agree while this is
|
|
307
|
+
// wrong; only rendering the thing shows it.
|
|
308
|
+
{ position: "relative" as const, zIndex: 1 },
|
|
213
309
|
{ outlineStyle: "none" },
|
|
214
310
|
(!expanded || !scrollEnabled) && { overflow: "hidden" as const },
|
|
215
311
|
expanded ? null : { paddingHorizontal: 10, paddingVertical: compactPadV },
|
|
@@ -348,4 +444,10 @@ const styles = StyleSheet.create({
|
|
|
348
444
|
fontFamily: fontFamilyRegular,
|
|
349
445
|
letterSpacing: INPUT_LETTER_SPACING,
|
|
350
446
|
},
|
|
447
|
+
// A GROUND, not coloured text: the words on screen are the field's, drawn on
|
|
448
|
+
// top, so the tint has to sit behind them rather than replace them.
|
|
449
|
+
highlightSegment: {
|
|
450
|
+
backgroundColor: colors.blue[100],
|
|
451
|
+
borderRadius: 4,
|
|
452
|
+
},
|
|
351
453
|
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface HighlightRange {
|
|
2
|
+
/** Index of the first highlighted character. */
|
|
3
|
+
start: number;
|
|
4
|
+
/** Index one past the last highlighted character. */
|
|
5
|
+
end: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface HighlightSegment {
|
|
9
|
+
text: string;
|
|
10
|
+
highlighted: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Cut `value` into alternating plain and highlighted runs.
|
|
15
|
+
*
|
|
16
|
+
* Split out from the composer because it is the part that can be wrong in a way
|
|
17
|
+
* nobody sees: the mirror it feeds sits behind a real text field, so a segment
|
|
18
|
+
* boundary off by one paints the tint half a character adrift and everything
|
|
19
|
+
* still looks like a working composer. Ranges arrive from a caller that is
|
|
20
|
+
* matching text it does not control, so overlapping, unsorted, reversed and
|
|
21
|
+
* out-of-bounds inputs are all normal here — none of them may drop or duplicate
|
|
22
|
+
* a character, because the mirror has to lay out identically to the field it
|
|
23
|
+
* sits behind or the tint drifts from the words.
|
|
24
|
+
*/
|
|
25
|
+
export function splitHighlightSegments(
|
|
26
|
+
value: string,
|
|
27
|
+
ranges: readonly HighlightRange[],
|
|
28
|
+
): HighlightSegment[] {
|
|
29
|
+
if (value.length === 0) return [];
|
|
30
|
+
|
|
31
|
+
const clamped = ranges
|
|
32
|
+
.map((r) => ({
|
|
33
|
+
start: Math.max(0, Math.min(r.start, value.length)),
|
|
34
|
+
end: Math.max(0, Math.min(r.end, value.length)),
|
|
35
|
+
}))
|
|
36
|
+
.filter((r) => r.end > r.start)
|
|
37
|
+
.sort((a, b) => a.start - b.start);
|
|
38
|
+
|
|
39
|
+
// Overlaps merge rather than nest: two tints over one character would render
|
|
40
|
+
// twice as dark, and the caller's ranges are matches over shared text.
|
|
41
|
+
const merged: HighlightRange[] = [];
|
|
42
|
+
for (const range of clamped) {
|
|
43
|
+
const last = merged[merged.length - 1];
|
|
44
|
+
if (last && range.start <= last.end) last.end = Math.max(last.end, range.end);
|
|
45
|
+
else merged.push({ ...range });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const segments: HighlightSegment[] = [];
|
|
49
|
+
let cursor = 0;
|
|
50
|
+
for (const range of merged) {
|
|
51
|
+
if (range.start > cursor) {
|
|
52
|
+
segments.push({ text: value.slice(cursor, range.start), highlighted: false });
|
|
53
|
+
}
|
|
54
|
+
segments.push({ text: value.slice(range.start, range.end), highlighted: true });
|
|
55
|
+
cursor = range.end;
|
|
56
|
+
}
|
|
57
|
+
if (cursor < value.length) {
|
|
58
|
+
segments.push({ text: value.slice(cursor), highlighted: false });
|
|
59
|
+
}
|
|
60
|
+
return segments;
|
|
61
|
+
}
|
package/src/member_chip.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import React from "react";
|
|
|
2
2
|
import { View, StyleSheet, StyleProp, ViewStyle } from "react-native";
|
|
3
3
|
import { Avatar } from "./avatar";
|
|
4
4
|
import { Text } from "./text";
|
|
5
|
-
import type
|
|
5
|
+
import { AVATAR_PX, type AvatarSize } from "./avatar_size";
|
|
6
6
|
import { useLoticsLocale } from "./locale";
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -34,22 +34,6 @@ interface MemberChipProps {
|
|
|
34
34
|
* than as a current assignment. The chip keeps its height, because its usual
|
|
35
35
|
* home is a fixed row: this is a colour change, never an extra line. */
|
|
36
36
|
inactive?: boolean;
|
|
37
|
-
/**
|
|
38
|
-
* This chip is a DOOR — something opens when it is pressed. Underlines the
|
|
39
|
-
* name in neutral ink, the kit's marker for "leads somewhere, and not away"
|
|
40
|
-
* (see `TextLink` with no `href`).
|
|
41
|
-
*
|
|
42
|
-
* It exists because a pressable that paints nothing at rest is text: with no
|
|
43
|
-
* ground, no border and no underline, its only affordance is a hover wash,
|
|
44
|
-
* which does not exist on touch and has not happened yet for anyone who has
|
|
45
|
-
* not already pointed at it. The chip then measures perfectly, carries a real
|
|
46
|
-
* focus ring, and is never pressed.
|
|
47
|
-
*
|
|
48
|
-
* Set it ONLY when the chip itself opens something. A chip inside a row that
|
|
49
|
-
* navigates must not wear it — the row is the door, and marking the chip
|
|
50
|
-
* promises a second destination that is not there.
|
|
51
|
-
*/
|
|
52
|
-
marker?: boolean;
|
|
53
37
|
style?: StyleProp<ViewStyle>;
|
|
54
38
|
}
|
|
55
39
|
|
|
@@ -75,7 +59,6 @@ export function MemberChip({
|
|
|
75
59
|
secondary,
|
|
76
60
|
size = "md",
|
|
77
61
|
inactive,
|
|
78
|
-
marker,
|
|
79
62
|
style,
|
|
80
63
|
}: MemberChipProps) {
|
|
81
64
|
// `Avatar` has always taken this word from the pack; the NAME beside it was a
|
|
@@ -105,9 +88,20 @@ export function MemberChip({
|
|
|
105
88
|
// name is not a pair, and leaving it on prose leading keeps every single-line
|
|
106
89
|
// chip — every picker option, every cell — pixel-identical to before.
|
|
107
90
|
const pairLeading = secondary ? ("tight" as const) : undefined;
|
|
91
|
+
// THE MARK IS NEVER SHORTER THAN WHAT IT MARKS. Two tight lines measure ~38
|
|
92
|
+
// at this rung, so beside a 28px avatar the text overflowed the mark by ten
|
|
93
|
+
// pixels and the person's face read as a decoration hung off their name.
|
|
94
|
+
// Tight leading is already the floor — 38 is as short as the pair gets — so
|
|
95
|
+
// the fix is the MARK, not the type: a chip carrying a supporting line is a
|
|
96
|
+
// taller object, and it takes the rung that covers the stack. That rung is
|
|
97
|
+
// `lg` = `CONTROL_HEIGHT`, which is the band a two-line chip belongs in
|
|
98
|
+
// anyway. A caller already above it (`xl`, a profile header) keeps what it
|
|
99
|
+
// asked for.
|
|
100
|
+
const markSize: AvatarSize =
|
|
101
|
+
secondary && AVATAR_PX[size] < AVATAR_PX.lg ? "lg" : size;
|
|
108
102
|
return (
|
|
109
103
|
<View style={[styles.row, style]}>
|
|
110
|
-
<Avatar size={
|
|
104
|
+
<Avatar size={markSize} name={displayName} source={image ? { uri: image } : undefined} />
|
|
111
105
|
<View style={styles.text}>
|
|
112
106
|
<Text
|
|
113
107
|
userSelect="none"
|
|
@@ -118,10 +112,6 @@ export function MemberChip({
|
|
|
118
112
|
// supporting line except by colour.
|
|
119
113
|
weight="medium"
|
|
120
114
|
leading={pairLeading}
|
|
121
|
-
// NEUTRAL underline, never the navigation blue: pressing this opens
|
|
122
|
-
// the person in place rather than travelling to them, and blue
|
|
123
|
-
// underlined text promises a destination.
|
|
124
|
-
decoration={marker ? "underline" : undefined}
|
|
125
115
|
color={inactive ? "zinc-500" : undefined}
|
|
126
116
|
numberOfLines={1}
|
|
127
117
|
>
|
package/src/member_peek.tsx
CHANGED
|
@@ -72,6 +72,12 @@ export function MemberPeek(props: MemberPeekProps) {
|
|
|
72
72
|
<Peek
|
|
73
73
|
accessibilityLabel={accessibilityLabel ?? words.profile(displayName)}
|
|
74
74
|
content={<MemberProfileCard {...card} />}
|
|
75
|
+
// A chip is a CONTROL-BAND citizen — `md` is `CONTROL_CONTENT_HEIGHT`
|
|
76
|
+
// precisely so it seats in one. On the default `text` register the
|
|
77
|
+
// trigger boxed it at 32 and bled 4 off each side, so a peeking chip
|
|
78
|
+
// under-filled the band and stood shorter than the same chip un-peeked
|
|
79
|
+
// beside it.
|
|
80
|
+
variant="control"
|
|
75
81
|
side={side}
|
|
76
82
|
align={align}
|
|
77
83
|
>
|
|
@@ -81,9 +87,6 @@ export function MemberPeek(props: MemberPeekProps) {
|
|
|
81
87
|
secondary={secondary}
|
|
82
88
|
size={size}
|
|
83
89
|
inactive={card.inactive}
|
|
84
|
-
// This chip IS the door — without the marker it paints nothing at rest
|
|
85
|
-
// and only a pointer already on it would ever learn that.
|
|
86
|
-
marker
|
|
87
90
|
style={style}
|
|
88
91
|
/>
|
|
89
92
|
</Peek>
|
package/src/peek.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { StyleSheet } from "react-native";
|
|
|
3
3
|
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
4
4
|
import type { PopoverSide, PopoverAlign } from "./popover";
|
|
5
5
|
import { PressableHighlight } from "./pressable_highlight";
|
|
6
|
+
import { CONTROL_HEIGHT, CONTROL_RADIUS } from "./control_surface";
|
|
6
7
|
|
|
7
8
|
export interface PeekProps {
|
|
8
9
|
/** The inline reference that becomes the trigger — a customer name, a
|
|
@@ -15,6 +16,27 @@ export interface PeekProps {
|
|
|
15
16
|
content: ReactNode;
|
|
16
17
|
/** Announced name for the trigger ("Hồ sơ KOMASPEC VIỆT NAM"). */
|
|
17
18
|
accessibilityLabel: string;
|
|
19
|
+
/**
|
|
20
|
+
* WHICH REGISTER the trigger sits in — the two surfaces a peek lives on need
|
|
21
|
+
* different boxes, and picking by taste puts the wrong one on both.
|
|
22
|
+
*
|
|
23
|
+
* `text` (default) is a reference INSIDE a line of reading — a customer name
|
|
24
|
+
* in a sentence, an id in a table cell. It takes the 32px chip register and
|
|
25
|
+
* bleeds the extra height back out with negative margins, so wrapping a
|
|
26
|
+
* `Text` never grows the line it sits in.
|
|
27
|
+
*
|
|
28
|
+
* `control` is a reference that IS a control-band citizen — anything built on
|
|
29
|
+
* `MemberChip`, whose `md` avatar is sized to `CONTROL_CONTENT_HEIGHT` for
|
|
30
|
+
* exactly this. It takes the full `CONTROL_HEIGHT` band and no vertical
|
|
31
|
+
* bleed, so the token seats where every other control on the surface does
|
|
32
|
+
* instead of under-filling the band and standing a different height from the
|
|
33
|
+
* same chip un-peeked beside it.
|
|
34
|
+
*
|
|
35
|
+
* The HORIZONTAL bleed is common to both: the trigger's padding must not
|
|
36
|
+
* shift its content off the surface's left edge, or a peekable token sits 8px
|
|
37
|
+
* right of the plain one above it.
|
|
38
|
+
*/
|
|
39
|
+
variant?: "text" | "control";
|
|
18
40
|
side?: PopoverSide;
|
|
19
41
|
align?: PopoverAlign;
|
|
20
42
|
}
|
|
@@ -28,7 +50,7 @@ export interface PeekProps {
|
|
|
28
50
|
* customer names, order ids, member names, linked records.
|
|
29
51
|
*/
|
|
30
52
|
export function Peek(props: PeekProps) {
|
|
31
|
-
const { children, content, accessibilityLabel, side = "bottom", align = "start" } = props;
|
|
53
|
+
const { children, content, accessibilityLabel, variant = "text", side = "bottom", align = "start" } = props;
|
|
32
54
|
return (
|
|
33
55
|
<Popover side={side} align={align}>
|
|
34
56
|
<PopoverTrigger>
|
|
@@ -36,7 +58,7 @@ export function Peek(props: PeekProps) {
|
|
|
36
58
|
focusRing
|
|
37
59
|
accessibilityRole="button"
|
|
38
60
|
accessibilityLabel={accessibilityLabel}
|
|
39
|
-
style={styles.trigger}
|
|
61
|
+
style={[styles.trigger, variant === "control" ? styles.triggerControl : null]}
|
|
40
62
|
// 32px visual, 40px touch target (32 + 2×4) — same as IconButton.
|
|
41
63
|
hitSlop={4}
|
|
42
64
|
>
|
|
@@ -51,9 +73,9 @@ export function Peek(props: PeekProps) {
|
|
|
51
73
|
}
|
|
52
74
|
|
|
53
75
|
const styles = StyleSheet.create({
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
76
|
+
// The `text` register: a 32px chip box (Badge scale) whose extra height is
|
|
77
|
+
// bled back out with negative margins, so a reference inside a line of
|
|
78
|
+
// reading never grows that line. hitSlop keeps the 40px touch target.
|
|
57
79
|
trigger: {
|
|
58
80
|
flexDirection: "row",
|
|
59
81
|
alignItems: "center",
|
|
@@ -63,6 +85,16 @@ const styles = StyleSheet.create({
|
|
|
63
85
|
marginHorizontal: -8,
|
|
64
86
|
marginVertical: -4,
|
|
65
87
|
},
|
|
88
|
+
// The `control` register: the surface's own band. The vertical bleed goes —
|
|
89
|
+
// a control-band token is SUPPOSED to occupy the band — and the radius joins
|
|
90
|
+
// every other control's, so a peekable chip corners like the inputs it sits
|
|
91
|
+
// among rather than like a badge. The horizontal bleed stays, so the token
|
|
92
|
+
// keeps the left edge (see `variant`).
|
|
93
|
+
triggerControl: {
|
|
94
|
+
minHeight: CONTROL_HEIGHT,
|
|
95
|
+
borderRadius: CONTROL_RADIUS,
|
|
96
|
+
marginVertical: 0,
|
|
97
|
+
},
|
|
66
98
|
content: {
|
|
67
99
|
width: 320,
|
|
68
100
|
},
|