@lotics/ui 13.9.0 → 13.10.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/docs/ai_patterns.md +15 -7
- package/docs/catalog.md +11 -3
- package/package.json +2 -1
- package/src/choice_list.tsx +103 -14
- package/src/clarify.tsx +31 -15
- package/src/clarify_wizard.tsx +84 -0
- package/src/locale.tsx +4 -0
- package/src/table.tsx +5 -1
package/docs/ai_patterns.md
CHANGED
|
@@ -243,15 +243,23 @@ plain `useState` (controlled `status` everywhere); the family owns the mechanics
|
|
|
243
243
|
Undo, the counter, Keep-all, apply gating). Never apply a field with no value — gate unresolved
|
|
244
244
|
conflicts (`keepDisabled`, `applyDisabled`). Editing IS the review.
|
|
245
245
|
|
|
246
|
-
## Ask back — `Clarify`
|
|
246
|
+
## Ask back — `Clarify` / `ClarifyWizard`
|
|
247
247
|
|
|
248
248
|
`Clarify` (`@lotics/ui/clarify`): when the agent is unsure, it asks a question with quick-reply
|
|
249
|
-
options and PAUSES, instead of guessing wrong. Human-in-the-loop input mid-run: `question
|
|
250
|
-
`options` (`{ label, value, description
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
249
|
+
options and PAUSES, instead of guessing wrong. Human-in-the-loop input mid-run: `question` (the
|
|
250
|
+
prompt, rendered as-is — no eyebrow chrome), `options` (`{ label, value, description }` — every
|
|
251
|
+
answer carries a REQUIRED one-line description so the human picks without re-deriving it from the
|
|
252
|
+
question; a `ChoiceList`), `onAnswer(value)` resumes the run, the controlled `answer` keeps the pick
|
|
253
|
+
switchable until committed. **`allowCustom`** adds an always-visible, borderless, auto-growing multiline field below the
|
|
254
|
+
options — typing it IS the selection; its text becomes the value (any value not matching an option
|
|
255
|
+
reads as the custom answer).
|
|
256
|
+
|
|
257
|
+
`ClarifyWizard` (`@lotics/ui/clarify_wizard`): a SEQUENCE of clarify questions worked one at a time —
|
|
258
|
+
Back / Next / Cancel / Submit (Next/Submit primary), the step position an eyebrow ("1 / 3") above the
|
|
259
|
+
question — no progress bar. Each step is a `Clarify`; advance only once the
|
|
260
|
+
current question is answered; `onSubmit` gives one `{ value, custom }` per question (aligned by index,
|
|
261
|
+
`custom` true when the answer came from "Other…"), `onCancel` dismisses. All navigation chrome is
|
|
262
|
+
locale-wired (the `clarify` slice). The multi-question form of the ask-back.
|
|
255
263
|
|
|
256
264
|
## Provenance — `Sources`
|
|
257
265
|
|
package/docs/catalog.md
CHANGED
|
@@ -980,11 +980,19 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
980
980
|
`ChangeRecord` (THE item card: registers like a Change; tone wash + localized op word;
|
|
981
981
|
verb level follows the decision level) · `ChangeBand` (the raw ± band) ·
|
|
982
982
|
`ChangeValueInput` (the diff-at-rest editor); the `changeReview` locale slice.
|
|
983
|
-
- **`clarify`** — `Clarify`: the agent asks back — a borderless block
|
|
984
|
-
`ChoiceList
|
|
983
|
+
- **`clarify`** — `Clarify` + `ClarifyOption`: the agent asks back — a borderless block
|
|
984
|
+
(the question text + a `ChoiceList`, no card wrapper; an optional muted `eyebrow` sits tight above
|
|
985
|
+
the question — e.g. a wizard's "1 / 3"). `ClarifyOption` requires
|
|
986
|
+
`{ value, label, description }` — the description is MANDATORY (every answer explains itself).
|
|
987
|
+
`allowCustom` adds an always-visible borderless multiline custom-answer field below the options.
|
|
988
|
+
- **`clarify_wizard`** — `ClarifyWizard` + `ClarifyWizardQuestion`/`ClarifyWizardAnswer`: a SEQUENCE
|
|
989
|
+
of `Clarify` questions with Back/Next/Cancel/Submit + a `n / total` position indicator; advance
|
|
990
|
+
only when the current is answered, `onSubmit` returns one `{ value, custom }` per question.
|
|
991
|
+
Navigation chrome is locale-wired (the `clarify` locale slice).
|
|
985
992
|
- **`choice_list`** — `ChoiceList` + `ChoiceOption`: selectable answer options as
|
|
986
993
|
divider-separated rows (no bordered cards) with a per-row focus ring + hover wash; the
|
|
987
|
-
agent's quick-reply surface.
|
|
994
|
+
agent's quick-reply surface. `allowCustom` appends an always-visible borderless multiline field whose
|
|
995
|
+
text IS the value (a value not in `options` reads as the custom answer).
|
|
988
996
|
- **`sources`** — `Sources` + `SourceRef`/`SourceKind` (record | document | table | web |
|
|
989
997
|
knowledge): provenance chips, per-kind glyphs.
|
|
990
998
|
- **`finding`** — `Finding` + `FindingComparison` + `FindingSeverity`/`FindingLabels`: one
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "13.
|
|
3
|
+
"version": "13.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
"./change_review": "./src/change_review.tsx",
|
|
90
90
|
"./finding": "./src/finding.tsx",
|
|
91
91
|
"./clarify": "./src/clarify.tsx",
|
|
92
|
+
"./clarify_wizard": "./src/clarify_wizard.tsx",
|
|
92
93
|
"./choice_list": "./src/choice_list.tsx",
|
|
93
94
|
"./sources": "./src/sources.tsx",
|
|
94
95
|
"./icon": "./src/icon.tsx",
|
package/src/choice_list.tsx
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Pressable, TextInput, View } from "react-native";
|
|
2
3
|
import { colors } from "./colors";
|
|
3
4
|
import { Text } from "./text";
|
|
4
5
|
import { Icon } from "./icon";
|
|
5
6
|
import { Divider } from "./divider";
|
|
6
|
-
import { useFocusRing } from "./use_focus_ring";
|
|
7
|
-
import {
|
|
7
|
+
import { composeHandler, useFocusRing } from "./use_focus_ring";
|
|
8
|
+
import { useHover } from "./use_hover";
|
|
9
|
+
import { CONTROL_RADIUS, FOCUS_RING } from "./control_surface";
|
|
10
|
+
import { useAutoGrowHeight } from "./use_auto_grow_height";
|
|
11
|
+
import { fontFamilyMedium, getInputTextStyle } from "./text_utils";
|
|
12
|
+
import { useLoticsLocale } from "./locale";
|
|
13
|
+
|
|
14
|
+
// A typed custom answer must read exactly like a picked option's LABEL (`Text
|
|
15
|
+
// size="sm" weight="medium"`). Its SIZE stays on the input contract (getInputTextStyle:
|
|
16
|
+
// 14 on desktop = the sm label, 16 on mobile to defeat iOS-Safari focus-zoom — and the
|
|
17
|
+
// size useAutoGrowHeight is tuned to); this override only aligns the family, tracking,
|
|
18
|
+
// and colour to the label, which is where an unstyled input actually diverges from it.
|
|
19
|
+
const ANSWER_LABEL_FONT = { fontFamily: fontFamilyMedium, letterSpacing: -0.4, color: colors.zinc[900] } as const;
|
|
8
20
|
|
|
9
21
|
export interface ChoiceOption {
|
|
10
22
|
label: string;
|
|
@@ -15,25 +27,33 @@ export interface ChoiceOption {
|
|
|
15
27
|
|
|
16
28
|
export interface ChoiceListProps {
|
|
17
29
|
options: ChoiceOption[];
|
|
18
|
-
/** The chosen value
|
|
30
|
+
/** The chosen value — an option's `value`, or (with `allowCustom`) the free
|
|
31
|
+
* TEXT of an "Other" answer. `undefined` = nothing chosen. */
|
|
19
32
|
value?: string;
|
|
20
33
|
onSelect: (value: string) => void;
|
|
34
|
+
/** Show an always-visible inline free-text field at the bottom for a custom
|
|
35
|
+
* answer — typing it IS the selection. Its text IS the value (any `value` not
|
|
36
|
+
* matching an option reads as the custom answer, so the caller distinguishes a
|
|
37
|
+
* custom answer by "not in `options`"). */
|
|
38
|
+
allowCustom?: boolean;
|
|
21
39
|
}
|
|
22
40
|
|
|
23
41
|
/** One selectable option — a role-less-free `Pressable` (its own accessible name
|
|
24
42
|
* + selected state), hover wash + focus ring, no border. */
|
|
25
|
-
function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selected: boolean; onSelect: (
|
|
43
|
+
function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selected: boolean; onSelect: () => void }) {
|
|
26
44
|
const { focusVisible, focusProps } = useFocusRing();
|
|
27
45
|
return (
|
|
28
46
|
<Pressable
|
|
29
47
|
accessibilityRole="button"
|
|
30
48
|
accessibilityLabel={option.label}
|
|
31
49
|
accessibilityState={{ selected }}
|
|
32
|
-
onPress={
|
|
50
|
+
onPress={onSelect}
|
|
33
51
|
{...focusProps}
|
|
34
52
|
style={({ hovered, pressed }) => [
|
|
35
|
-
{ flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 14, paddingHorizontal:
|
|
36
|
-
|
|
53
|
+
{ flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 14, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS },
|
|
54
|
+
// Selected reads as a persistent tint (zinc-100); an unselected row only
|
|
55
|
+
// washes on hover (zinc-50). The check below reserves its slot always.
|
|
56
|
+
selected ? { backgroundColor: colors.zinc[100] } : hovered || pressed ? { backgroundColor: colors.zinc[50] } : null,
|
|
37
57
|
focusVisible ? { boxShadow: FOCUS_RING } : null,
|
|
38
58
|
]}
|
|
39
59
|
>
|
|
@@ -47,28 +67,97 @@ function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selec
|
|
|
47
67
|
</Text>
|
|
48
68
|
) : null}
|
|
49
69
|
</View>
|
|
50
|
-
|
|
70
|
+
<View style={{ width: 17, alignItems: "center" }}>
|
|
71
|
+
{selected ? <Icon name="check" size={17} color={colors.zinc[900]} /> : null}
|
|
72
|
+
</View>
|
|
51
73
|
</Pressable>
|
|
52
74
|
);
|
|
53
75
|
}
|
|
54
76
|
|
|
77
|
+
/** The custom-answer row — the SAME box as a `ChoiceRow` (padding · radius · hover
|
|
78
|
+
* wash · focus ring), but holding an always-editable, auto-growing multiline input
|
|
79
|
+
* instead of a pressable label. Typing it IS the selection; focusing it re-selects a
|
|
80
|
+
* parked draft (`onFocus`). */
|
|
81
|
+
function CustomAnswerRow({ value, selected, onChangeText, onFocus, placeholder }: { value: string; selected: boolean; onChangeText: (text: string) => void; onFocus: () => void; placeholder: string }) {
|
|
82
|
+
const { hovered, hoverProps } = useHover();
|
|
83
|
+
const { focusVisible, focusProps } = useFocusRing({ always: true });
|
|
84
|
+
const grow = useAutoGrowHeight({ minLines: 1 });
|
|
85
|
+
return (
|
|
86
|
+
<View
|
|
87
|
+
{...(hoverProps as object)}
|
|
88
|
+
style={[
|
|
89
|
+
{ paddingVertical: 14, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS },
|
|
90
|
+
// Same states as an option row: a filled custom answer reads as selected
|
|
91
|
+
// (zinc-100); otherwise it washes on hover (zinc-50).
|
|
92
|
+
selected ? { backgroundColor: colors.zinc[100] } : hovered ? { backgroundColor: colors.zinc[50] } : null,
|
|
93
|
+
focusVisible ? { boxShadow: FOCUS_RING } : null,
|
|
94
|
+
]}
|
|
95
|
+
>
|
|
96
|
+
<TextInput
|
|
97
|
+
ref={grow.inputRef}
|
|
98
|
+
multiline
|
|
99
|
+
value={value}
|
|
100
|
+
onChangeText={(text) => { onChangeText(text); grow.measure(); }}
|
|
101
|
+
onFocus={composeHandler(onFocus, focusProps.onFocus)}
|
|
102
|
+
onBlur={focusProps.onBlur}
|
|
103
|
+
onContentSizeChange={grow.onContentSizeChange}
|
|
104
|
+
scrollEnabled={grow.scrollEnabled}
|
|
105
|
+
placeholder={placeholder}
|
|
106
|
+
placeholderTextColor={colors.zinc[400]}
|
|
107
|
+
// Bare input: the row owns padding + hover + focus ring, so it strips its own
|
|
108
|
+
// padding and browser outline and just grows with the text. Input-contract size +
|
|
109
|
+
// the option-label family/tracking/colour, so a typed answer reads like a picked one.
|
|
110
|
+
style={[getInputTextStyle(), ANSWER_LABEL_FONT, { height: grow.containerHeight, padding: 0, outlineStyle: "none" as unknown as "solid" }]}
|
|
111
|
+
/>
|
|
112
|
+
</View>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
55
116
|
/**
|
|
56
117
|
* A vertical set of selectable answer options — divider-separated rows (no
|
|
57
118
|
* bordered cards), each with its own focus ring + hover wash, single-select and
|
|
58
119
|
* freely switchable (pick a different option any time). The chosen row shows a
|
|
59
|
-
* check.
|
|
60
|
-
*
|
|
120
|
+
* check. With `allowCustom`, an always-visible inline free-text field at the
|
|
121
|
+
* bottom captures a custom answer (typing it IS the selection). The agent's
|
|
122
|
+
* quick-reply surface (`Clarify` / `ClarifyWizard` use it) and any "pick one" question.
|
|
61
123
|
*/
|
|
62
124
|
export function ChoiceList(props: ChoiceListProps) {
|
|
63
|
-
const { options, value, onSelect } = props;
|
|
125
|
+
const { options, value, onSelect, allowCustom } = props;
|
|
126
|
+
const labels = useLoticsLocale().clarify;
|
|
127
|
+
// A value that matches no option IS a custom answer (its own text, or "" right
|
|
128
|
+
// after focusing an empty custom row).
|
|
129
|
+
const isCustomValue = (v: string | undefined): v is string => !!allowCustom && typeof v === "string" && !options.some((o) => o.value === v);
|
|
130
|
+
// The custom TEXT is a draft that outlives selection: picking an option deselects
|
|
131
|
+
// the custom row but must KEEP its text so the human can return to it. The single
|
|
132
|
+
// `value` can't hold both a picked option and the parked text, so the draft is
|
|
133
|
+
// local — seeded from `value` when this row mounts already custom-selected.
|
|
134
|
+
const [customDraft, setCustomDraft] = useState(() => (isCustomValue(value) ? value : ""));
|
|
135
|
+
const customSelected = isCustomValue(value);
|
|
64
136
|
return (
|
|
65
137
|
<View>
|
|
66
138
|
{options.map((o, i) => (
|
|
67
139
|
<View key={o.value}>
|
|
68
|
-
{
|
|
69
|
-
|
|
140
|
+
{/* The hairline carries vertical padding so each row's rounded hover/selected
|
|
141
|
+
fill has air on both sides of it (a flush divider would pinch the corners). */}
|
|
142
|
+
{i > 0 ? <Divider paddingVertical={6} /> : null}
|
|
143
|
+
<ChoiceRow option={o} selected={value === o.value} onSelect={() => onSelect(o.value)} />
|
|
70
144
|
</View>
|
|
71
145
|
))}
|
|
146
|
+
{allowCustom ? (
|
|
147
|
+
<View>
|
|
148
|
+
{options.length > 0 ? <Divider paddingVertical={6} /> : null}
|
|
149
|
+
{/* The custom answer is an ALWAYS-visible inline row (no press-to-reveal). Its
|
|
150
|
+
text persists (customDraft) across option switches; typing selects it, and
|
|
151
|
+
focusing a non-empty parked draft re-selects it without retyping. */}
|
|
152
|
+
<CustomAnswerRow
|
|
153
|
+
value={customDraft}
|
|
154
|
+
selected={customSelected && customDraft.length > 0}
|
|
155
|
+
onChangeText={(text) => { setCustomDraft(text); onSelect(text); }}
|
|
156
|
+
onFocus={() => { if (customDraft.length > 0 && !customSelected) onSelect(customDraft); }}
|
|
157
|
+
placeholder={labels.otherPlaceholder}
|
|
158
|
+
/>
|
|
159
|
+
</View>
|
|
160
|
+
) : null}
|
|
72
161
|
</View>
|
|
73
162
|
);
|
|
74
163
|
}
|
package/src/clarify.tsx
CHANGED
|
@@ -1,38 +1,54 @@
|
|
|
1
1
|
import { View } from "react-native";
|
|
2
2
|
import { Text } from "./text";
|
|
3
|
-
import { ChoiceList
|
|
3
|
+
import { ChoiceList } from "./choice_list";
|
|
4
4
|
|
|
5
|
-
export
|
|
5
|
+
export interface ClarifyOption {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
/** REQUIRED for a clarify answer: the one-line "what this choice means" so the
|
|
9
|
+
* human can pick without re-deriving it from the question. */
|
|
10
|
+
description: string;
|
|
11
|
+
}
|
|
6
12
|
|
|
7
13
|
export interface ClarifyProps {
|
|
8
14
|
/** The agent's question — what it needs settled to proceed. */
|
|
9
15
|
question: string;
|
|
16
|
+
/** A muted eyebrow rendered tight above the question — e.g. a wizard's "1 / 3". */
|
|
17
|
+
eyebrow?: string;
|
|
10
18
|
options: ClarifyOption[];
|
|
11
19
|
onAnswer: (value: string) => void;
|
|
12
|
-
/** The chosen option's VALUE —
|
|
13
|
-
* switchable so the human can change
|
|
20
|
+
/** The chosen option's VALUE — or, with `allowCustom`, a custom answer's free
|
|
21
|
+
* text. The picked choice shows selected; switchable so the human can change. */
|
|
14
22
|
answer?: string;
|
|
23
|
+
/** Offer an "Other…" free-text row below the options. */
|
|
24
|
+
allowCustom?: boolean;
|
|
15
25
|
}
|
|
16
26
|
|
|
17
27
|
/**
|
|
18
|
-
* The agent asks BACK — a question with selectable answer options
|
|
19
|
-
* before the run continues, freely
|
|
20
|
-
* (no card): the question,
|
|
21
|
-
*
|
|
22
|
-
*
|
|
28
|
+
* The agent asks BACK — a question with selectable answer options (each carrying
|
|
29
|
+
* a one-line description) the human picks before the run continues, freely
|
|
30
|
+
* switchable until committed. A borderless block (no card): the question text,
|
|
31
|
+
* then divider-separated option rows (`ChoiceList`); `allowCustom` adds an
|
|
32
|
+
* "Other…" free-text row. For a SEQUENCE of questions with Back/Next/Submit
|
|
33
|
+
* navigation, use `ClarifyWizard`. Human-in-the-loop: when unsure, the agent
|
|
34
|
+
* clarifies instead of guessing wrong. Pair with `AgentRun` / `AgentProgress`.
|
|
23
35
|
*/
|
|
24
36
|
export function Clarify(props: ClarifyProps) {
|
|
25
37
|
return (
|
|
26
|
-
<View style={{ gap:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
<View style={{ gap: 12 }}>
|
|
39
|
+
{/* paddingHorizontal 8 aligns the eyebrow + question with the option/answer
|
|
40
|
+
text, which sits on the `ChoiceList` rows' 8px inset (CONTROL_RADIUS grid). */}
|
|
41
|
+
<View style={{ gap: 4, paddingHorizontal: 8 }}>
|
|
42
|
+
{props.eyebrow ? (
|
|
43
|
+
<Text size="xs" color="muted" weight="medium">
|
|
44
|
+
{props.eyebrow}
|
|
45
|
+
</Text>
|
|
46
|
+
) : null}
|
|
31
47
|
<Text size="sm" weight="medium">
|
|
32
48
|
{props.question}
|
|
33
49
|
</Text>
|
|
34
50
|
</View>
|
|
35
|
-
<ChoiceList options={props.options} value={props.answer} onSelect={props.onAnswer} />
|
|
51
|
+
<ChoiceList options={props.options} value={props.answer} onSelect={props.onAnswer} allowCustom={props.allowCustom} />
|
|
36
52
|
</View>
|
|
37
53
|
);
|
|
38
54
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { Clarify, type ClarifyOption } from "./clarify";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
6
|
+
|
|
7
|
+
export interface ClarifyWizardQuestion {
|
|
8
|
+
question: string;
|
|
9
|
+
answers: ClarifyOption[];
|
|
10
|
+
/** Offer an "Other…" free-text answer for this question. */
|
|
11
|
+
allowCustom?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ClarifyWizardAnswer {
|
|
15
|
+
/** The chosen option's value, or the custom free text. */
|
|
16
|
+
value: string;
|
|
17
|
+
/** True when the value came from the "Other…" row (not one of the options). */
|
|
18
|
+
custom: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ClarifyWizardProps {
|
|
22
|
+
questions: ClarifyWizardQuestion[];
|
|
23
|
+
/** Fires when every question is answered and the human submits — one answer
|
|
24
|
+
* per question, aligned by index. */
|
|
25
|
+
onSubmit: (answers: ClarifyWizardAnswer[]) => void;
|
|
26
|
+
onCancel: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A SEQUENCE of `Clarify` questions the human works through one at a time —
|
|
31
|
+
* Back / Next / Cancel / Submit with a position indicator. Each step reuses
|
|
32
|
+
* `Clarify` (question + described answers + optional "Other…"). You can only
|
|
33
|
+
* advance once the current question is answered; Submit fires when the last is.
|
|
34
|
+
* The multi-question form of the agent's ask-back; single questions use `Clarify`.
|
|
35
|
+
*/
|
|
36
|
+
export function ClarifyWizard(props: ClarifyWizardProps) {
|
|
37
|
+
const { questions, onSubmit, onCancel } = props;
|
|
38
|
+
const labels = useLoticsLocale().clarify;
|
|
39
|
+
const [index, setIndex] = useState(0);
|
|
40
|
+
const [answers, setAnswers] = useState<(string | undefined)[]>(() => questions.map(() => undefined));
|
|
41
|
+
|
|
42
|
+
const total = questions.length;
|
|
43
|
+
const q = questions[index];
|
|
44
|
+
const current = answers[index];
|
|
45
|
+
// Answered = a non-empty string: an option value, or filled custom text ("" is
|
|
46
|
+
// "Other" picked but not yet typed — not answered).
|
|
47
|
+
const answered = typeof current === "string" && current.length > 0;
|
|
48
|
+
const isLast = index === total - 1;
|
|
49
|
+
|
|
50
|
+
const setAnswer = (v: string) =>
|
|
51
|
+
setAnswers((prev) => {
|
|
52
|
+
const next = [...prev];
|
|
53
|
+
next[index] = v;
|
|
54
|
+
return next;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const submit = () =>
|
|
58
|
+
onSubmit(
|
|
59
|
+
questions.map((question, i) => {
|
|
60
|
+
const value = answers[i] ?? "";
|
|
61
|
+
return { value, custom: !question.answers.some((a) => a.value === value) };
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<View style={{ gap: 16 }}>
|
|
67
|
+
{/* The step position is an eyebrow above the question (no progress bar); the
|
|
68
|
+
footer aligns to the same 8px inset as the question and answers. Keyed by
|
|
69
|
+
index so each step's custom-answer draft is its own (no bleed across steps). */}
|
|
70
|
+
<Clarify key={index} eyebrow={`${index + 1} / ${total}`} question={q.question} options={q.answers} answer={current} onAnswer={setAnswer} allowCustom={q.allowCustom} />
|
|
71
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8, paddingHorizontal: 8 }}>
|
|
72
|
+
<Button title={labels.cancel} color="muted" onPress={onCancel} />
|
|
73
|
+
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
74
|
+
{index > 0 ? <Button title={labels.back} color="secondary" onPress={() => setIndex(index - 1)} /> : null}
|
|
75
|
+
{isLast ? (
|
|
76
|
+
<Button title={labels.submit} color="primary" onPress={submit} disabled={!answered} />
|
|
77
|
+
) : (
|
|
78
|
+
<Button title={labels.next} color="primary" onPress={() => setIndex(index + 1)} disabled={!answered} />
|
|
79
|
+
)}
|
|
80
|
+
</View>
|
|
81
|
+
</View>
|
|
82
|
+
</View>
|
|
83
|
+
);
|
|
84
|
+
}
|
package/src/locale.tsx
CHANGED
|
@@ -57,6 +57,8 @@ export interface LoticsLocale {
|
|
|
57
57
|
/** The `Inline*` editor family: the shared save-error line and the
|
|
58
58
|
* text-editor Save/Cancel tooltips. */
|
|
59
59
|
inline: { saveError: string; save: string; cancel: string };
|
|
60
|
+
/** `ChoiceList` custom-answer placeholder + `ClarifyWizard` navigation chrome. */
|
|
61
|
+
clarify: { otherPlaceholder: string; back: string; next: string; cancel: string; submit: string };
|
|
60
62
|
/** `Ledger`: the screen-reader name of a peekable row. */
|
|
61
63
|
ledger: { rowDetails: (label: string) => string };
|
|
62
64
|
/** `SectionHeadingTitle`: the info-popover trigger's screen-reader name. */
|
|
@@ -159,6 +161,7 @@ export const en: LoticsLocale = {
|
|
|
159
161
|
dangerZone: { title: "Danger zone" },
|
|
160
162
|
drawer: { previous: "Previous record", next: "Next record", close: "Close" },
|
|
161
163
|
inline: { saveError: "Couldn't save. Try again.", save: "Save", cancel: "Cancel" },
|
|
164
|
+
clarify: { otherPlaceholder: "Or type your own answer…", back: "Back", next: "Next", cancel: "Cancel", submit: "Submit" },
|
|
162
165
|
ledger: { rowDetails: (label) => `${label} details` },
|
|
163
166
|
sectionHeading: { info: "About this data" },
|
|
164
167
|
chip: { remove: "Remove" },
|
|
@@ -254,6 +257,7 @@ export const vi: LoticsLocale = {
|
|
|
254
257
|
dangerZone: { title: "Vùng nguy hiểm" },
|
|
255
258
|
drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
|
|
256
259
|
inline: { saveError: "Không lưu được. Thử lại.", save: "Lưu", cancel: "Hủy" },
|
|
260
|
+
clarify: { otherPlaceholder: "Hoặc nhập câu trả lời khác…", back: "Quay lại", next: "Tiếp", cancel: "Hủy", submit: "Gửi" },
|
|
257
261
|
ledger: { rowDetails: (label) => `Chi tiết ${label}` },
|
|
258
262
|
sectionHeading: { info: "Giải thích dữ liệu" },
|
|
259
263
|
chip: { remove: "Xóa" },
|
package/src/table.tsx
CHANGED
|
@@ -315,7 +315,11 @@ const styles = StyleSheet.create({
|
|
|
315
315
|
// Divider-separated.
|
|
316
316
|
headerBand: {
|
|
317
317
|
paddingHorizontal: ROW_GUTTER,
|
|
318
|
-
|
|
318
|
+
// NO top padding (GAP-85): a Table is borderless, so its container ALWAYS owns
|
|
319
|
+
// the top spacing (a Section's gap, a Card's padding, any `gap` above). A band
|
|
320
|
+
// paddingTop would stack on that and double-gap the column header; the bottom
|
|
321
|
+
// padding is the band's own — it spaces the header off the hairline below.
|
|
322
|
+
paddingBottom: 10,
|
|
319
323
|
flexDirection: "row",
|
|
320
324
|
alignItems: "center",
|
|
321
325
|
gap: COLUMN_GAP,
|