@lotics/ui 44.13.0 → 45.2.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 +9 -2
- package/MIGRATION.md +61 -5
- package/docs/ai_patterns.md +2 -3
- package/docs/catalog.md +35 -14
- package/docs/composition.md +143 -42
- package/docs/data_entry.md +17 -6
- package/docs/reviewing.md +24 -12
- package/docs/templates.md +1 -1
- package/package.json +2 -1
- package/src/accordion.tsx +2 -2
- package/src/avatar_group.tsx +1 -1
- package/src/avatar_size.ts +2 -3
- package/src/card_select_item.tsx +7 -6
- package/src/color_tokens.ts +22 -6
- package/src/combobox.tsx +11 -1
- package/src/control_surface.ts +22 -16
- package/src/date_stamp.tsx +1 -3
- package/src/detail_row.tsx +4 -5
- package/src/dialog.tsx +13 -6
- package/src/drawer.tsx +18 -4
- package/src/filter_chip.tsx +1 -2
- package/src/finding.tsx +24 -7
- package/src/heading_altitude.ts +65 -0
- package/src/icon.tsx +8 -0
- package/src/inline_edit.tsx +14 -3
- package/src/inline_markdown.tsx +118 -0
- package/src/inline_text_input.tsx +31 -7
- package/src/ledger.tsx +3 -3
- package/src/markdown.web.tsx +3 -6
- package/src/matrix.tsx +1 -1
- package/src/modal.tsx +10 -1
- package/src/popover.tsx +151 -142
- package/src/pressable_row.tsx +24 -15
- package/src/reference_field.tsx +11 -9
- package/src/section_heading.tsx +57 -71
- package/src/select.tsx +17 -0
- package/src/stepper.tsx +2 -3
- package/src/summary.tsx +3 -3
- package/src/table.tsx +4 -1
- package/src/text_utils.ts +3 -4
- package/src/timeline.tsx +3 -4
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useCallback, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { View, type LayoutChangeEvent, type NativeSyntheticEvent, type TextInputKeyPressEventData } from "react-native";
|
|
3
|
+
import { InlineEditFrame, useInlineEdit, type InlineEditVariant } from "./inline_edit";
|
|
4
|
+
import { Markdown } from "./markdown";
|
|
5
|
+
import { TextInputField } from "./text_input_field";
|
|
6
|
+
|
|
7
|
+
export interface InlineMarkdownProps {
|
|
8
|
+
/** The markdown SOURCE. Rendered at rest, edited raw. */
|
|
9
|
+
value: string;
|
|
10
|
+
/** Persist the new source. May be async — the field shows a saving state and
|
|
11
|
+
* surfaces a thrown error inline, staying in edit mode so nothing is lost. */
|
|
12
|
+
onSave: (next: string) => void | Promise<void>;
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
/** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
|
|
16
|
+
variant?: InlineEditVariant;
|
|
17
|
+
/** The editor's floor, in lines, before the resting height is taken into
|
|
18
|
+
* account. Keeps a short or empty value from opening a one-line slot. */
|
|
19
|
+
minLines?: number;
|
|
20
|
+
accessibilityLabel?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A short prose reserve, so an empty field is still obviously somewhere to write. */
|
|
24
|
+
const DEFAULT_MIN_LINES = 3;
|
|
25
|
+
const APPROX_LINE_HEIGHT = 22;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* An inline-editable MARKDOWN value: rendered at rest, raw source while editing.
|
|
29
|
+
*
|
|
30
|
+
* This one SWAPS, and it is allowed to for the same reason the number and date
|
|
31
|
+
* editors do: its resting display and its editor show genuinely different
|
|
32
|
+
* strings (`**bold**` against **bold**), so the sub-pixel drift that forced
|
|
33
|
+
* `InlineTextInput` to stay a single element is not perceptible here. That rule
|
|
34
|
+
* — same string ⇒ one element, different string ⇒ swap — is what decides which
|
|
35
|
+
* shape a new editor takes, and markdown is the clearest case of the second.
|
|
36
|
+
*
|
|
37
|
+
* **The editor never opens SHORTER than the view it replaced.** A swap editor
|
|
38
|
+
* is supposed to leave the box where it was; for markdown it cannot exactly,
|
|
39
|
+
* because rendered prose and its source are different lengths by nature. What it
|
|
40
|
+
* CAN do is refuse to collapse: the resting height is measured and becomes the
|
|
41
|
+
* input's floor, so pressing a long note opens a long editor. Growing is the
|
|
42
|
+
* tolerable direction — shrinking pulls the text the reader was just looking at
|
|
43
|
+
* out from under them, and everything below it jumps up.
|
|
44
|
+
*
|
|
45
|
+
* Reach for it wherever a field holds markdown a PERSON wrote. A model's output
|
|
46
|
+
* is read-only (you re-run it, you do not hand-edit it) and belongs in a plain
|
|
47
|
+
* `Markdown`.
|
|
48
|
+
*/
|
|
49
|
+
export function InlineMarkdown(props: InlineMarkdownProps) {
|
|
50
|
+
const { value, onSave, placeholder, disabled, variant, minLines, accessibilityLabel } = props;
|
|
51
|
+
const edit = useInlineEdit<string>({ value, onSave });
|
|
52
|
+
const [restingHeight, setRestingHeight] = useState(0);
|
|
53
|
+
// Not state: it must be current for the render that mounts the input, and a
|
|
54
|
+
// set during layout would arrive one frame late — as a visible jump.
|
|
55
|
+
const measured = useRef(0);
|
|
56
|
+
|
|
57
|
+
const onViewLayout = useCallback((e: LayoutChangeEvent) => {
|
|
58
|
+
measured.current = e.nativeEvent.layout.height;
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const begin = useCallback(() => {
|
|
62
|
+
setRestingHeight(measured.current);
|
|
63
|
+
edit.begin();
|
|
64
|
+
}, [edit]);
|
|
65
|
+
|
|
66
|
+
const onKeyPress = useCallback(
|
|
67
|
+
(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
68
|
+
// Enter is a PARAGRAPH BREAK here — this is prose, and `numberOfLines > 1`
|
|
69
|
+
// is what declares that everywhere else in the family. Blur commits.
|
|
70
|
+
if (e.nativeEvent.key === "Escape") edit.cancel();
|
|
71
|
+
},
|
|
72
|
+
[edit],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const floor = Math.max((minLines ?? DEFAULT_MIN_LINES) * APPROX_LINE_HEIGHT, restingHeight);
|
|
76
|
+
|
|
77
|
+
const display: ReactNode = value === "" ? "" : (
|
|
78
|
+
<View onLayout={onViewLayout}>
|
|
79
|
+
<Markdown>{value}</Markdown>
|
|
80
|
+
</View>
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<InlineEditFrame
|
|
85
|
+
// Unlike `InlineTextInput`, `editing` here IS the mount: the input exists
|
|
86
|
+
// only during an edit, which is what makes the rendered view possible.
|
|
87
|
+
editing={edit.editing}
|
|
88
|
+
display={display}
|
|
89
|
+
placeholder={placeholder}
|
|
90
|
+
onBegin={begin}
|
|
91
|
+
controls="blur"
|
|
92
|
+
onCommit={() => void edit.commit()}
|
|
93
|
+
onCancel={edit.cancel}
|
|
94
|
+
saving={edit.saving}
|
|
95
|
+
error={edit.error}
|
|
96
|
+
disabled={disabled}
|
|
97
|
+
variant={variant}
|
|
98
|
+
accessibilityLabel={accessibilityLabel}
|
|
99
|
+
>
|
|
100
|
+
<TextInputField
|
|
101
|
+
value={edit.draft}
|
|
102
|
+
onChangeText={edit.setDraft}
|
|
103
|
+
onBlur={() => void edit.commit()}
|
|
104
|
+
onKeyPress={onKeyPress}
|
|
105
|
+
multiline
|
|
106
|
+
// The input ARRIVES with the edit, so it takes the caret on mount. This
|
|
107
|
+
// is the case `InlineTextInput` cannot serve — it is permanently mounted
|
|
108
|
+
// and deliberately has no `autoFocus`, so a revealed field there would
|
|
109
|
+
// cost the reader a second click.
|
|
110
|
+
autoFocus
|
|
111
|
+
placeholder={placeholder}
|
|
112
|
+
accessibilityLabel={accessibilityLabel}
|
|
113
|
+
disabled={disabled}
|
|
114
|
+
style={{ minHeight: floor }}
|
|
115
|
+
/>
|
|
116
|
+
</InlineEditFrame>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -18,6 +18,23 @@ export interface InlineTextInputProps {
|
|
|
18
18
|
* struck while being edited — the field is one element, so a treatment that
|
|
19
19
|
* dropped on focus would be exactly the jump this control exists to avoid. */
|
|
20
20
|
struck?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The value is a URL — wear the navigation ink (`TextLink`'s underline +
|
|
23
|
+
* medium weight + blue).
|
|
24
|
+
*
|
|
25
|
+
* A TREATMENT, never a swapped-in `TextLink`, and that is the whole point:
|
|
26
|
+
* this field is one `<input>` in both states because a resting `Text` and an
|
|
27
|
+
* `<input>` cannot draw the same string identically (see the note on
|
|
28
|
+
* `editing` below). A link value's string does NOT change between reading and
|
|
29
|
+
* typing it — only its ink does — so swapping would reintroduce exactly the
|
|
30
|
+
* quarter-pixel jump that comment records, for no gain.
|
|
31
|
+
*
|
|
32
|
+
* Blue because a URL genuinely navigates, which is the same test `TextLink`
|
|
33
|
+
* applies to its own `href`. Pressing the field still EDITS: to open the
|
|
34
|
+
* destination, pass an `Open` `InlineButton` through `actions`, where a verb
|
|
35
|
+
* about the value belongs.
|
|
36
|
+
*/
|
|
37
|
+
link?: boolean;
|
|
21
38
|
/** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
|
|
22
39
|
variant?: InlineEditVariant;
|
|
23
40
|
/**
|
|
@@ -37,8 +54,8 @@ export interface InlineTextInputProps {
|
|
|
37
54
|
* a payment term — where reserving the space keeps the page still. It is wrong
|
|
38
55
|
* for open prose, and it fails in the worst way: the field renders a box the
|
|
39
56
|
* value does not fit, with no ellipsis, no scrollbar and no scroll, so the
|
|
40
|
-
* reader is given no evidence that anything is missing
|
|
41
|
-
*
|
|
57
|
+
* reader is given no evidence that anything is missing — a long note simply
|
|
58
|
+
* loses its tail.
|
|
42
59
|
*
|
|
43
60
|
* Growing costs nothing this control was protecting: the field is ONE input in
|
|
44
61
|
* both states, so a grown box is the same height resting and editing, and
|
|
@@ -76,7 +93,7 @@ export interface InlineTextInputProps {
|
|
|
76
93
|
* implementation detail.
|
|
77
94
|
*/
|
|
78
95
|
export function InlineTextInput(props: InlineTextInputProps) {
|
|
79
|
-
const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant, actions, numberOfLines, autoGrow } = props;
|
|
96
|
+
const { value, onSave, placeholder, controls = "blur", disabled, struck, link, accessibilityLabel , variant, actions, numberOfLines, autoGrow } = props;
|
|
80
97
|
// Growing implies wrapping: a field that grows on one line has nowhere to go.
|
|
81
98
|
const multiline = (numberOfLines ?? 1) > 1 || autoGrow === true;
|
|
82
99
|
// WHAT ENTER MEANS IS DECLARED BY `numberOfLines`, NOT BY WRAPPING. They were
|
|
@@ -122,9 +139,8 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
122
139
|
// same string identically, and nothing enforces it: the padding drifted
|
|
123
140
|
// (8px), then the transparent border (1px), then the ink (zinc-900 vs the
|
|
124
141
|
// UA's black) — each fixed in turn, each revealing the next. The last one
|
|
125
|
-
// cannot be fixed at all:
|
|
126
|
-
//
|
|
127
|
-
// (row centroid 12.985 -> 13.985, identical ink, identical column
|
|
142
|
+
// cannot be fixed at all: the glyph mass sits one DEVICE pixel lower while
|
|
143
|
+
// editing (identical ink, identical column
|
|
128
144
|
// centroid). Both paths compute a 10px text top, so it is not a padding
|
|
129
145
|
// mistake — an `<input>` centres its text by FONT METRICS and a `<div>`
|
|
130
146
|
// positions it by LINE BOX, and the residual is a quarter of a CSS pixel.
|
|
@@ -195,7 +211,15 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
195
211
|
// its title upright. Anything else the view used to render has to move the
|
|
196
212
|
// same way, or it goes the same way — quietly.
|
|
197
213
|
variant={variant}
|
|
198
|
-
style={
|
|
214
|
+
style={
|
|
215
|
+
struck
|
|
216
|
+
? { textDecorationLine: "line-through" as const, color: colors.zinc[500] }
|
|
217
|
+
: link && value !== ""
|
|
218
|
+
// `value`, not the draft: an emptied field is not a link, and the
|
|
219
|
+
// ink would otherwise sit on the placeholder.
|
|
220
|
+
? { textDecorationLine: "underline" as const, color: colors.blue[600], fontWeight: "500" as const }
|
|
221
|
+
: null
|
|
222
|
+
}
|
|
199
223
|
// With verbs on the field, the FRAME owns the surface and the ring.
|
|
200
224
|
seamless={actions != null}
|
|
201
225
|
/>
|
package/src/ledger.tsx
CHANGED
|
@@ -257,9 +257,9 @@ export function LedgerRow(props: LedgerRowProps) {
|
|
|
257
257
|
const rowDetails = useLoticsLocale().ledger.rowDetails;
|
|
258
258
|
const { format, dropMeta } = useLedger();
|
|
259
259
|
// The caption goes entirely rather than shrinking to nothing. Yielding first
|
|
260
|
-
// (below) is the right ORDER but not a floor:
|
|
261
|
-
//
|
|
262
|
-
//
|
|
260
|
+
// (below) is the right ORDER but not a floor: at phone width a long caption
|
|
261
|
+
// still claims most of the text budget, clips, and takes the label down with
|
|
262
|
+
// it. Two separate
|
|
263
263
|
// authors had already worked around this by dropping `meta` at small widths
|
|
264
264
|
// in their own apps, which is the component's job.
|
|
265
265
|
const showMeta = meta != null && meta !== "" && !dropMeta;
|
package/src/markdown.web.tsx
CHANGED
|
@@ -34,12 +34,9 @@ const markdownComponents = {
|
|
|
34
34
|
* Sizing the headings down fixes what a screen SHOWS and nothing about what it
|
|
35
35
|
* ANNOUNCES: an `h2` the author of the text happened to write is still an `h2`,
|
|
36
36
|
* so it lands in heading navigation as a PEER of the page's own sections.
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* — a model's call-summary title sitting between two real sections, from inside
|
|
42
|
-
* one row of a feed that can hold twenty more. No visual probe finds this, and
|
|
37
|
+
* A model's call-summary title then sits in the heading outline between two of
|
|
38
|
+
* the page's real sections, from inside one row of a feed that can hold twenty
|
|
39
|
+
* more. No visual probe finds this, and
|
|
43
40
|
* fixing the size is what makes it invisible: the defect stops looking wrong at
|
|
44
41
|
* the exact moment it stops being measurable.
|
|
45
42
|
*
|
package/src/matrix.tsx
CHANGED
|
@@ -135,7 +135,7 @@ function MatrixHeader({ corner, totalLabel }: MatrixHeaderProps) {
|
|
|
135
135
|
<View style={styles.headRow}>
|
|
136
136
|
<View style={[styles.rowLabel, { width: rowLabelWidth }]}>
|
|
137
137
|
{typeof corner === "string" ? (
|
|
138
|
-
<Text size="
|
|
138
|
+
<Text size="sm" color="muted" weight="medium" numberOfLines={1}>
|
|
139
139
|
{corner}
|
|
140
140
|
</Text>
|
|
141
141
|
) : (
|
package/src/modal.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { Text } from "@lotics/ui/text";
|
|
|
6
6
|
import { PortalHost } from "@lotics/ui/portal";
|
|
7
7
|
import { useOverlayScope } from "@lotics/ui/overlay_scope";
|
|
8
8
|
import { useLoticsLocale } from "@lotics/ui/locale";
|
|
9
|
+
import { HeadingAltitudeContext } from "./heading_altitude";
|
|
9
10
|
|
|
10
11
|
export interface ModalProps {
|
|
11
12
|
open: boolean;
|
|
@@ -100,7 +101,15 @@ export interface ModalBodyProps {
|
|
|
100
101
|
*/
|
|
101
102
|
export function ModalBody(props: ModalBodyProps) {
|
|
102
103
|
const { children } = props;
|
|
103
|
-
|
|
104
|
+
// A takeover is still a panel: `ModalHeader`'s title is `lg`, so a section
|
|
105
|
+
// heading inside the body takes the ramp's `####` rung. Same rule as the
|
|
106
|
+
// drawer and the dialog, published from the same kind of region — the one
|
|
107
|
+
// that owns the surface's padding. See `heading_altitude.ts`.
|
|
108
|
+
return (
|
|
109
|
+
<HeadingAltitudeContext.Provider value="panel">
|
|
110
|
+
<ScrollView contentContainerStyle={styles.bodyContent}>{children}</ScrollView>
|
|
111
|
+
</HeadingAltitudeContext.Provider>
|
|
112
|
+
);
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
export interface ModalFooterProps {
|
package/src/popover.tsx
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from "./popover_layers";
|
|
24
24
|
import { PopoverNavContext, type PopoverNavContextValue } from "./popover_nav";
|
|
25
25
|
import { useLoticsLocale } from "./locale";
|
|
26
|
+
import { HeadingAltitudeContext } from "./heading_altitude";
|
|
26
27
|
|
|
27
28
|
export type PopoverSide = "top" | "right" | "bottom" | "left";
|
|
28
29
|
export type PopoverAlign = "start" | "center" | "end";
|
|
@@ -607,155 +608,163 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
607
608
|
const contentZIndex = baseZIndex + nestingLevel * 2 + 1;
|
|
608
609
|
|
|
609
610
|
return (
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
611
|
+
// A popover is dialog-scale — a few hundred px with its own chrome — so a
|
|
612
|
+
// heading inside it takes the ramp's `####` rung, exactly as one inside a
|
|
613
|
+
// dialog does. The provider covers ALL THREE BANDS, not just the scroller:
|
|
614
|
+
// `PopoverHeader` is a slot the CALLER composes (unlike a Drawer's title,
|
|
615
|
+
// which is chrome the container renders), and the identity line of a peek
|
|
616
|
+
// lives there. See `heading_altitude.ts`.
|
|
617
|
+
<HeadingAltitudeContext.Provider value="panel">
|
|
618
|
+
<Portal>
|
|
619
|
+
{/* Modal scrim — ONLY for the bottom-sheet (`small`) mode, which IS modal.
|
|
620
|
+
The anchored popover is NON-MODAL: no overlay, so the rest of the page
|
|
621
|
+
stays interactive; outside-dismiss is the click listener above. */}
|
|
622
|
+
{small && (
|
|
623
|
+
<div
|
|
624
|
+
style={{
|
|
625
|
+
position: "fixed",
|
|
626
|
+
top: 0,
|
|
627
|
+
left: 0,
|
|
628
|
+
right: 0,
|
|
629
|
+
bottom: 0,
|
|
630
|
+
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
631
|
+
opacity: isBottomSheetShown ? 1 : 0,
|
|
632
|
+
transition: "opacity 0.3s ease",
|
|
633
|
+
zIndex: overlayZIndex,
|
|
634
|
+
pointerEvents: "auto",
|
|
635
|
+
}}
|
|
636
|
+
onClick={handleOverlayClick}
|
|
637
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
638
|
+
/>
|
|
639
|
+
)}
|
|
640
|
+
{/* Popover */}
|
|
615
641
|
<div
|
|
642
|
+
ref={popoverRef}
|
|
643
|
+
data-popover="true"
|
|
644
|
+
data-popover-level={nestingLevel}
|
|
645
|
+
data-testid={testID}
|
|
646
|
+
role="dialog"
|
|
647
|
+
aria-modal={small ? true : undefined}
|
|
648
|
+
tabIndex={small ? undefined : -1}
|
|
616
649
|
style={{
|
|
617
650
|
position: "fixed",
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
onMouseDown={(e) => e.stopPropagation()}
|
|
630
|
-
/>
|
|
631
|
-
)}
|
|
632
|
-
{/* Popover */}
|
|
633
|
-
<div
|
|
634
|
-
ref={popoverRef}
|
|
635
|
-
data-popover="true"
|
|
636
|
-
data-popover-level={nestingLevel}
|
|
637
|
-
data-testid={testID}
|
|
638
|
-
role="dialog"
|
|
639
|
-
aria-modal={small ? true : undefined}
|
|
640
|
-
tabIndex={small ? undefined : -1}
|
|
641
|
-
style={{
|
|
642
|
-
position: "fixed",
|
|
643
|
-
padding: PANEL_INSET,
|
|
644
|
-
borderTopLeftRadius: 16,
|
|
645
|
-
borderTopRightRadius: 16,
|
|
646
|
-
borderBottomLeftRadius: small ? 0 : 16,
|
|
647
|
-
borderBottomRightRadius: small ? 0 : 16,
|
|
648
|
-
backgroundColor: colors.background,
|
|
649
|
-
boxShadow: colors.shadow,
|
|
650
|
-
boxSizing: "border-box",
|
|
651
|
-
zIndex: contentZIndex,
|
|
652
|
-
transition: small ? "transform 0.3s ease" : undefined,
|
|
653
|
-
...(small
|
|
654
|
-
? {
|
|
655
|
-
bottom: 0,
|
|
656
|
-
left: 0,
|
|
657
|
-
right: 0,
|
|
658
|
-
maxHeight: "90vh",
|
|
659
|
-
display: "flex",
|
|
660
|
-
flexDirection: "column",
|
|
661
|
-
paddingBottom: 32,
|
|
662
|
-
transform: isBottomSheetShown ? "translateY(0)" : "translateY(100%)",
|
|
663
|
-
}
|
|
664
|
-
: position
|
|
651
|
+
padding: PANEL_INSET,
|
|
652
|
+
borderTopLeftRadius: 16,
|
|
653
|
+
borderTopRightRadius: 16,
|
|
654
|
+
borderBottomLeftRadius: small ? 0 : 16,
|
|
655
|
+
borderBottomRightRadius: small ? 0 : 16,
|
|
656
|
+
backgroundColor: colors.background,
|
|
657
|
+
boxShadow: colors.shadow,
|
|
658
|
+
boxSizing: "border-box",
|
|
659
|
+
zIndex: contentZIndex,
|
|
660
|
+
transition: small ? "transform 0.3s ease" : undefined,
|
|
661
|
+
...(small
|
|
665
662
|
? {
|
|
666
|
-
|
|
667
|
-
left:
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
maxHeight: "80vh",
|
|
663
|
+
bottom: 0,
|
|
664
|
+
left: 0,
|
|
665
|
+
right: 0,
|
|
666
|
+
maxHeight: "90vh",
|
|
671
667
|
display: "flex",
|
|
672
668
|
flexDirection: "column",
|
|
669
|
+
paddingBottom: 32,
|
|
670
|
+
transform: isBottomSheetShown ? "translateY(0)" : "translateY(100%)",
|
|
673
671
|
}
|
|
674
|
-
:
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
672
|
+
: position
|
|
673
|
+
? {
|
|
674
|
+
top: position.top,
|
|
675
|
+
left: position.left,
|
|
676
|
+
width: "max-content",
|
|
677
|
+
maxWidth: 800,
|
|
678
|
+
maxHeight: "80vh",
|
|
679
|
+
display: "flex",
|
|
680
|
+
flexDirection: "column",
|
|
681
|
+
}
|
|
682
|
+
: {
|
|
683
|
+
top: -9999,
|
|
684
|
+
left: -9999,
|
|
685
|
+
width: "max-content",
|
|
686
|
+
maxWidth: 800,
|
|
687
|
+
maxHeight: "80vh",
|
|
688
|
+
display: "flex",
|
|
689
|
+
flexDirection: "column",
|
|
690
|
+
}),
|
|
691
|
+
// INHERIT means inherit — the width, not just a floor under it. The
|
|
692
|
+
// flag is set only by a popover anchored to a FIELD (Select,
|
|
693
|
+
// InlineSelect, Combobox), and a field's own width is the right width
|
|
694
|
+
// for the list of values that field can hold: option text longer than
|
|
695
|
+
// the field belongs on a second line, not on a wider panel.
|
|
696
|
+
//
|
|
697
|
+
// As a floor alone this LOOKED right, because with short options
|
|
698
|
+
// max-content lands on the trigger width anyway. It broke the moment
|
|
699
|
+
// an option was a sentence: the panel grew to the `maxWidth: 800` cap
|
|
700
|
+
// and then slid sideways to stay on screen, so a 371px field opened an
|
|
701
|
+
// 800px list starting 421px to its left, outside the drawer that owns
|
|
702
|
+
// the field.
|
|
703
|
+
//
|
|
704
|
+
// Floored at MIN_CONTROL_WIDTH so a genuinely narrow trigger — a
|
|
705
|
+
// a select in a dense grid column — still opens a list
|
|
706
|
+
// wide enough to read, rather than inheriting a width nothing fits in.
|
|
707
|
+
...(inheritTriggerWidth &&
|
|
708
|
+
!small &&
|
|
709
|
+
triggerWidth > 0 && {
|
|
710
|
+
width: Math.max(triggerWidth, MIN_CONTROL_WIDTH),
|
|
711
|
+
maxWidth: Math.max(triggerWidth, MIN_CONTROL_WIDTH),
|
|
712
|
+
}),
|
|
713
|
+
// Last, so it beats the `max-content` every branch above sets.
|
|
714
|
+
...(width != null && !small ? { width, maxWidth: width } : null),
|
|
715
|
+
}}
|
|
716
|
+
onClick={(e) => e.stopPropagation()}
|
|
717
|
+
// React synthetic events bubble through portals via the REACT tree, so a
|
|
718
|
+
// keydown inside this popover — or inside a Modal/Alert opened from it,
|
|
719
|
+
// which is a React child even though it portals to document.body — would
|
|
720
|
+
// reach the TRIGGER's ancestors (e.g. a grid cell's Escape-cancels-edit
|
|
721
|
+
// onKeyDown) and let a lower layer act on a higher layer's keys, on
|
|
722
|
+
// keydown, before any keyup layering logic runs. Keyboard sibling of the
|
|
723
|
+
// click curtain above. Keyup deliberately keeps flowing: RN-web Modal
|
|
724
|
+
// closes on a document-level keyup listener, and this popover's own
|
|
725
|
+
// Escape handling is a document capture keyup — neither must be starved.
|
|
726
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
727
|
+
>
|
|
728
|
+
{small && (
|
|
729
|
+
<View
|
|
730
|
+
style={{
|
|
731
|
+
padding: 8,
|
|
732
|
+
flexDirection: "row",
|
|
733
|
+
justifyContent: "flex-end",
|
|
734
|
+
}}
|
|
735
|
+
>
|
|
736
|
+
<IconButton icon="x" tooltip={closeLabel} onPress={handleClose} />
|
|
737
|
+
</View>
|
|
738
|
+
)}
|
|
739
|
+
{header}
|
|
740
|
+
{disableBodyScroll ? (
|
|
741
|
+
<SizeBoundary style={style}>{bodyChildren}</SizeBoundary>
|
|
742
|
+
) : (
|
|
743
|
+
// FULL-BLEED HORIZONTALLY, then re-inset by the same 12. The panel
|
|
744
|
+
// pads all four sides, so a scroller sitting inside that padding
|
|
745
|
+
// clips its content 12px short of the edge and parks the scrollbar
|
|
746
|
+
// there too — content slides under an invisible margin instead of
|
|
747
|
+
// under the panel's own edge. Pulling out and padding back keeps the
|
|
748
|
+
// text on the exact column the header and footer use while giving the
|
|
749
|
+
// scroll its real edges. `PopoverFooter` already does this dance for
|
|
750
|
+
// its divider; the body needed it for the same reason.
|
|
751
|
+
//
|
|
752
|
+
// Horizontal only: the vertical padding is the gap to the header and
|
|
753
|
+
// footer, which is a gap the reader wants.
|
|
754
|
+
<ScrollView
|
|
755
|
+
style={[SCROLL_BODY, style]}
|
|
756
|
+
contentContainerStyle={[SCROLL_BODY_CONTENT, contentContainerStyle]}
|
|
757
|
+
>
|
|
758
|
+
{/* A popover is a box of its own — a few hundred px — so its contents
|
|
759
|
+
size to the panel rather than to whatever region it was opened
|
|
760
|
+
from. Both body paths get it; one of them is not a boundary. */}
|
|
761
|
+
<SizeBoundary>{bodyChildren}</SizeBoundary>
|
|
762
|
+
</ScrollView>
|
|
763
|
+
)}
|
|
764
|
+
{footer}
|
|
765
|
+
</div>
|
|
766
|
+
</Portal>
|
|
767
|
+
</HeadingAltitudeContext.Provider>
|
|
759
768
|
);
|
|
760
769
|
}
|
|
761
770
|
|
package/src/pressable_row.tsx
CHANGED
|
@@ -8,9 +8,9 @@ export interface PressableRowProps {
|
|
|
8
8
|
onPress: () => void;
|
|
9
9
|
/** The open/selected record — paints the persistent highlight. */
|
|
10
10
|
selected?: boolean;
|
|
11
|
-
/** Part of a multi-select set — paints
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
/** Part of a multi-select set — paints the same resting ground `selected` does.
|
|
12
|
+
* What tells a ticked row from an open one is the ticked checkbox, which is
|
|
13
|
+
* unmissable; the press wash still overrides both. */
|
|
14
14
|
marked?: boolean;
|
|
15
15
|
/**
|
|
16
16
|
* - "register" (THE record-list default): a rounded row whose hover/open/`marked`
|
|
@@ -89,18 +89,27 @@ export function PressableRow(props: PressableRowProps) {
|
|
|
89
89
|
// FOUR states, and they must not collide. Selection and hover painted
|
|
90
90
|
// the SAME wash, which made "the row whose record is open" and "the row
|
|
91
91
|
// the pointer happens to be over" indistinguishable — the register's
|
|
92
|
-
// one piece of persistent state, erased by a transient one.
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// that
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
92
|
+
// one piece of persistent state, erased by a transient one.
|
|
93
|
+
//
|
|
94
|
+
// ONE NEUTRAL STEP is what separates them: zinc-50 hovered, zinc-100
|
|
95
|
+
// selected, zinc-200 pressed. The fix was briefly to give selection a
|
|
96
|
+
// blue ground instead, which read as a brand nobody had chosen on the
|
|
97
|
+
// busiest surface in the product — and contradicted `accent`'s own rule
|
|
98
|
+
// that it does not paint interaction chrome, which is exactly what
|
|
99
|
+
// these four states are.
|
|
100
|
+
//
|
|
101
|
+
// THE LITERAL, not `accent_wash`. Reading a selection through the brand
|
|
102
|
+
// token is how the blue got here in the first place: a themed app would
|
|
103
|
+
// tint the row, so "selection is neutral" would be true of the kit and
|
|
104
|
+
// false of every app that set an accent. The token still exists and
|
|
105
|
+
// still means the brand's tint — it is spent on ATTENTION now (a drop
|
|
106
|
+
// target lighting up), never on which record is open.
|
|
107
|
+
//
|
|
108
|
+
// `marked` shares the ground rather than keeping its own blue-50: what
|
|
109
|
+
// separates a ticked row from an open one is the ticked checkbox, which
|
|
110
|
+
// is unmissable, and two grounds for two orthogonal states could never
|
|
111
|
+
// render anyway on a row that is both.
|
|
112
|
+
backgroundColor: pressed ? colors.zinc[200] : selected || marked ? colors.zinc[100] : hovered ? colors.zinc[50] : undefined,
|
|
104
113
|
},
|
|
105
114
|
style,
|
|
106
115
|
]}
|