@lotics/ui 23.0.0 → 23.1.1

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/catalog.md CHANGED
@@ -95,7 +95,14 @@ Batch draft-form state → `useForm`.
95
95
 
96
96
  ### Edit a record's fields in place
97
97
 
98
- The `Inline*` family: `InlineTextInput`, `InlineNumberInput`, `InlineSelect` (single or
98
+ The `Inline*` family: `InlineTextInput` (**`numberOfLines`** — the line budget for a value a
99
+ reader must read WHOLE rather than recognise at a glance: a payment term, an address, a clause.
100
+ Above 1 the resting box RESERVES that many lines — the full editor height, not the height this
101
+ particular value needs — so clicking in moves neither the field nor anything below it; Enter
102
+ then inserts a newline and the value commits on blur. Both halves matter: a field that only
103
+ goes multiline once focused hides the text from the reader who never clicks, and one that
104
+ reserves less than it opens to shoves the rest of the form down the page on every click),
105
+ `InlineNumberInput`, `InlineSelect` (single or
99
106
  `multi`), `InlineMemberSelect`, `InlineDatePicker`, `InlineTimePicker`; a
100
107
  READ-ONLY field in that same column uses `InlineStatic` (matches the editor box exactly, no
101
108
  input chrome, so it aligns pixel-for-pixel). A stack of labelled field rows lives in
@@ -246,7 +253,11 @@ scoped to that region's focus; the region-wrapper next to `FileDropzone`'s dedic
246
253
  "files land without hunting for a dropzone", `FileRow` (a horizontal file/document LINE —
247
254
  badge-or-placeholder + name + meta + a composable `trailing` slot for a status badge /
248
255
  action / remove; `onPress` makes the whole row a pressable door, `trailing` stays an
249
- independently-pressable sibling; for checklists & readable lists), `FileBadge` (the two-tone
256
+ independently-pressable sibling; for checklists & readable lists. **`size`** — `sm` (default)
257
+ is the compact attachment line for many files scanned as a list; **`md` is the document-desk
258
+ row** (taller badge, `ListItem` height) for the FEW rows that ARE the section's subject — an
259
+ expected-document checklist, a slot a button fills. A two-row section at `sm` reads as an
260
+ appendix to the page rather than its point), `FileBadge` (the two-tone
250
261
  type mark), `FilePreview` / `FileGalleryModal`, `ImageGallery`; picking is `pickFiles`
251
262
  (`@lotics/ui/file_picker` — opens the browser picker and resolves the chosen `File[]`, the
252
263
  imperative half behind every Add-file CTA); for gated CRUD compose locally with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "23.0.0",
3
+ "version": "23.1.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -266,7 +266,7 @@
266
266
  },
267
267
  "license": "SEE LICENSE IN LICENSE.md",
268
268
  "dependencies": {
269
- "@lotics/docx": "^0.1.0",
269
+ "@lotics/docx": "^0.2.0",
270
270
  "@lotics/xlsx": "^0.1.0",
271
271
  "ai": "^7.0.30",
272
272
  "mdast-util-from-markdown": "^2.0.3",
@@ -275,7 +275,7 @@
275
275
  "mdast-util-gfm-table": "^2.0.0",
276
276
  "mdast-util-gfm-task-list-item": "^2.0.0",
277
277
  "micromark-extension-gfm": "^3.0.0",
278
- "pdfjs-dist": "^6.0.227",
278
+ "pdfjs-dist": "^6.2.108",
279
279
  "react-markdown": "^10.0.0"
280
280
  },
281
281
  "peerDependencies": {
package/src/file_row.tsx CHANGED
@@ -25,6 +25,14 @@ export interface FileRowProps {
25
25
  * control. An independently-interactive SIBLING: never swallowed by the row
26
26
  * press (a button never nests in the door button). */
27
27
  trailing?: React.ReactNode;
28
+ /**
29
+ * Row weight. `"sm"` (default) is the compact attachment line — many files
30
+ * scanned as a list. `"md"` is the DOCUMENT-DESK row: a taller badge and a
31
+ * `ListItem`-height body, for the few rows that are the surface's subject
32
+ * rather than an appendix to it — an expected-document checklist, a slot a
33
+ * button fills. A one-or-two-row section at `sm` reads as an afterthought.
34
+ */
35
+ size?: "sm" | "md";
28
36
  }
29
37
 
30
38
  /**
@@ -37,7 +45,17 @@ export interface FileRowProps {
37
45
  * a button/menu in it is pressed independently (the door + sibling a11y pattern —
38
46
  * see `PressableRow`). For square thumbnail tiles use `FileThumbnailGrid` / `FileGrid`.
39
47
  */
40
- export function FileRow({ name, meta, mimeType, placeholder, isTemplate, onPress, trailing }: FileRowProps) {
48
+ export function FileRow({
49
+ name,
50
+ meta,
51
+ mimeType,
52
+ placeholder,
53
+ isTemplate,
54
+ onPress,
55
+ trailing,
56
+ size = "sm",
57
+ }: FileRowProps) {
58
+ const md = size === "md";
41
59
  const [hovered, setHovered] = useState(false);
42
60
  const [pressed, setPressed] = useState(false);
43
61
  const { focusVisible, focusProps } = useFocusRing();
@@ -50,7 +68,7 @@ export function FileRow({ name, meta, mimeType, placeholder, isTemplate, onPress
50
68
 
51
69
  const content = (
52
70
  <>
53
- <FileBadge size={30} mimeType={mimeType} placeholder={placeholder} isTemplate={isTemplate} />
71
+ <FileBadge size={md ? 38 : 30} mimeType={mimeType} placeholder={placeholder} isTemplate={isTemplate} />
54
72
  <View style={styles.text}>
55
73
  <Text size="sm" weight="medium" numberOfLines={1}>
56
74
  {name}
@@ -66,7 +84,7 @@ export function FileRow({ name, meta, mimeType, placeholder, isTemplate, onPress
66
84
 
67
85
  if (!onPress) {
68
86
  return (
69
- <View style={styles.row}>
87
+ <View style={[styles.row, md && styles.rowMd]}>
70
88
  {content}
71
89
  {trailing}
72
90
  </View>
@@ -75,7 +93,12 @@ export function FileRow({ name, meta, mimeType, placeholder, isTemplate, onPress
75
93
 
76
94
  return (
77
95
  <View
78
- style={[styles.pressableRow, hovered && styles.rowHovered, pressed && styles.rowPressed]}
96
+ style={[
97
+ styles.pressableRow,
98
+ md && styles.rowMd,
99
+ hovered && styles.rowHovered,
100
+ pressed && styles.rowPressed,
101
+ ]}
79
102
  {...mouseProps}
80
103
  >
81
104
  <Pressable
@@ -110,6 +133,8 @@ const styles = StyleSheet.create({
110
133
  borderRadius: 8,
111
134
  ...({ transitionDuration: "0.1s", transitionProperty: "background-color" } as object),
112
135
  },
136
+ // The `ListItem` beat — for a document row that IS the section's subject.
137
+ rowMd: { minHeight: 56, paddingVertical: 10 },
113
138
  rowHovered: { backgroundColor: colors.zinc["50"] },
114
139
  rowPressed: { backgroundColor: colors.zinc["100"] },
115
140
  // The accessible "Open" door — fills the row left of the trailing sibling.
@@ -7,7 +7,7 @@ import { ActivityIndicator } from "./activity_indicator";
7
7
  import { FocusRingPressable } from "./focus_ring_pressable";
8
8
  import { colors } from "./colors";
9
9
  import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
10
- import { fontFamilyRegular, getInputTextStyle, getTextColor, type TextColor } from "./text_utils";
10
+ import { fontFamilyRegular, getInputTextStyle, getMultilineInputHeight, getTextColor, type TextColor } from "./text_utils";
11
11
  import { getInteractionModality } from "./interaction_modality";
12
12
  import { shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
13
13
  import { trackCommit } from "./pending_commits";
@@ -160,6 +160,14 @@ interface InlineEditFrameProps {
160
160
  struck?: boolean;
161
161
  /** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
162
162
  variant?: InlineEditVariant;
163
+ /**
164
+ * How many lines the RESTING value may occupy before it truncates. Default 1.
165
+ * Raise it for a value a reader has to read whole — a payment term, an
166
+ * address — rather than recognise at a glance; the editor should open to the
167
+ * same height so the row does not reflow on click.
168
+ */
169
+ numberOfLines?: number;
170
+
163
171
  /** Right-aligned rest affordance shown in VIEW mode (e.g. a clock for a time
164
172
  * field) — a hover-independent cue the field opens a picker. Omit for plain
165
173
  * text/number inputs (typing IS the affordance). */
@@ -187,15 +195,16 @@ function fieldContent(o: {
187
195
  struck?: boolean;
188
196
  tone?: TextColor;
189
197
  trailing?: ReactNode;
198
+ numberOfLines?: number;
190
199
  }) {
191
- const { display, placeholder, struck, tone, trailing } = o;
200
+ const { display, placeholder, struck, tone, trailing, numberOfLines } = o;
192
201
  const isEmptyString = typeof display === "string" && display === "";
193
202
  return (
194
203
  <>
195
204
  {isEmptyString && placeholder != null && typeof placeholder !== "string" ? (
196
205
  <View style={styles.viewNode}>{placeholder}</View>
197
206
  ) : typeof display === "string" ? (
198
- <Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null, tone && display && !struck ? { color: getTextColor(tone) } : null]}>
207
+ <Text numberOfLines={numberOfLines ?? 1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null, tone && display && !struck ? { color: getTextColor(tone) } : null]}>
199
208
  {display || (typeof placeholder === "string" ? placeholder : "") || "—"}
200
209
  </Text>
201
210
  ) : (
@@ -301,6 +310,14 @@ interface InlineEditViewProps {
301
310
  accessibilityLabel?: string;
302
311
  /** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
303
312
  variant?: InlineEditVariant;
313
+ /**
314
+ * How many lines the RESTING value may occupy before it truncates. Default 1.
315
+ * Raise it for a value a reader has to read whole — a payment term, an
316
+ * address — rather than recognise at a glance; the editor should open to the
317
+ * same height so the row does not reflow on click.
318
+ */
319
+ numberOfLines?: number;
320
+
304
321
  /**
305
322
  * A handle on the FIELD's outer box, for a caller that anchors an overlay to
306
323
  * it — a select's popover inherits the trigger's width, and with `actions` the
@@ -355,8 +372,31 @@ interface InlineEditViewProps {
355
372
  * the overlay trigger for the select/date inline editors (it forwards ref +
356
373
  * onPress to a `PopoverTrigger`).
357
374
  */
375
+ /**
376
+ * The resting box for a MULTI-LINE field.
377
+ *
378
+ * A field that changes size when you click it moves everything below it, and on
379
+ * a record surface that is the whole form jumping under the pointer. So the box
380
+ * must be identical in both modes — which means the RESTING box reserves the
381
+ * editor's full height, not the height this particular value happens to need.
382
+ * `TextInputField` sizes a multiline input to `numberOfLines * lineHeight + 16`;
383
+ * this reproduces that number exactly, and top-aligns the text the way a
384
+ * textarea does, so neither the box nor the first line moves.
385
+ *
386
+ * Single-line fields are untouched: `INLINE_CONTROL_HEIGHT` already equals the
387
+ * input's own height, which is why they never had this bug.
388
+ */
389
+ function multilineBox(numberOfLines: number | undefined): ViewStyle | null {
390
+ if (numberOfLines == null || numberOfLines <= 1) return null;
391
+ return {
392
+ minHeight: getMultilineInputHeight(numberOfLines, false),
393
+ alignItems: "flex-start",
394
+ paddingVertical: 8,
395
+ };
396
+ }
397
+
358
398
  export function InlineEditView(props: InlineEditViewProps) {
359
- const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, actions, active, struck, variant = "framed", tone, onFocus, ref, anchorRef } = props;
399
+ const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, actions, active, struck, variant = "framed", tone, onFocus, ref, anchorRef, numberOfLines } = props;
360
400
  // Stop the press here so an inline editor nested in a pressable row (a task
361
401
  // row that expands on press) edits the field instead of triggering the row.
362
402
  const handlePress = onPress
@@ -365,7 +405,7 @@ export function InlineEditView(props: InlineEditViewProps) {
365
405
  onPress();
366
406
  }
367
407
  : undefined;
368
- const content = fieldContent({ display, placeholder, struck, tone, trailing });
408
+ const content = fieldContent({ display, placeholder, struck, tone, trailing, numberOfLines });
369
409
  const surface = fieldSurface({ variant, disabled, active });
370
410
 
371
411
  // WITH VERBS: the shell owns the surface, the pressable is just the value
@@ -387,7 +427,7 @@ export function InlineEditView(props: InlineEditViewProps) {
387
427
  onFocus={onFocus}
388
428
  accessibilityRole="button"
389
429
  accessibilityLabel={accessibilityLabel}
390
- style={styles.viewInner}
430
+ style={[styles.viewInner, multilineBox(numberOfLines)]}
391
431
  >
392
432
  {content}
393
433
  </Pressable>
@@ -407,7 +447,7 @@ export function InlineEditView(props: InlineEditViewProps) {
407
447
  accessibilityRole="button"
408
448
  accessibilityLabel={accessibilityLabel}
409
449
  userSelect="none"
410
- style={(state) => surface(state.hovered ?? false)}
450
+ style={(state) => [surface(state.hovered ?? false), multilineBox(numberOfLines)]}
411
451
  >
412
452
  {content}
413
453
  </FocusRingPressable>
@@ -442,6 +482,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
442
482
  variant,
443
483
  affordance,
444
484
  actions,
485
+ numberOfLines,
445
486
  } = props;
446
487
 
447
488
  const viewRef = useRef<View>(null);
@@ -482,6 +523,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
482
523
  accessibilityLabel={accessibilityLabel}
483
524
  struck={struck}
484
525
  trailing={affordance}
526
+ numberOfLines={numberOfLines}
485
527
  />
486
528
  );
487
529
  }
@@ -523,7 +565,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
523
565
  accessibilityLabel={accessibilityLabel}
524
566
  style={styles.viewInner}
525
567
  >
526
- {fieldContent({ display, placeholder, struck, trailing: affordance })}
568
+ {fieldContent({ display, placeholder, struck, trailing: affordance, numberOfLines })}
527
569
  </Pressable>
528
570
  )}
529
571
  </InlineFieldShell>
@@ -17,6 +17,14 @@ export interface InlineTextInputProps {
17
17
  struck?: boolean;
18
18
  /** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
19
19
  variant?: InlineEditVariant;
20
+ /**
21
+ * Line budget for a value a reader has to read WHOLE rather than recognise —
22
+ * a payment term, an address, a clause. Default 1 (the single-line field).
23
+ * Above 1 the resting value wraps to that many lines AND the editor opens
24
+ * multiline at the same height, so the row does not reflow on click. Enter
25
+ * then inserts a newline; the field commits on blur (or the ✓ in "buttons").
26
+ */
27
+ numberOfLines?: number;
20
28
  accessibilityLabel?: string;
21
29
  /** Verbs on the field's surface — an `InlineButton` Copy on a reference a reader
22
30
  * quotes elsewhere. See `InlineEditView.actions`: rendered in BOTH modes so the
@@ -32,16 +40,18 @@ export interface InlineTextInputProps {
32
40
  * value in a dense record / detail surface.
33
41
  */
34
42
  export function InlineTextInput(props: InlineTextInputProps) {
35
- const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant, actions } = props;
43
+ const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant, actions, numberOfLines } = props;
44
+ const multiline = (numberOfLines ?? 1) > 1;
36
45
  const edit = useInlineEdit<string>({ value, onSave });
37
46
 
38
47
  const onKeyPress = useCallback(
39
48
  (e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
40
49
  const key = e.nativeEvent.key;
41
50
  if (key === "Escape") edit.cancel();
42
- else if (key === "Enter") void edit.commit();
51
+ // On a multiline field Enter is a NEWLINE; the value commits on blur.
52
+ else if (key === "Enter" && !multiline) void edit.commit();
43
53
  },
44
- [edit],
54
+ [edit, multiline],
45
55
  );
46
56
 
47
57
  // Commit on blur (the ergonomic default for moving through a form). In
@@ -66,6 +76,7 @@ export function InlineTextInput(props: InlineTextInputProps) {
66
76
  disabled={disabled}
67
77
  struck={struck}
68
78
  variant={variant}
79
+ numberOfLines={numberOfLines}
69
80
  accessibilityLabel={accessibilityLabel}
70
81
  actions={actions}
71
82
  >
@@ -75,6 +86,8 @@ export function InlineTextInput(props: InlineTextInputProps) {
75
86
  onBlur={onBlur}
76
87
  onKeyPress={onKeyPress}
77
88
  autoFocus
89
+ multiline={multiline}
90
+ numberOfLines={numberOfLines}
78
91
  placeholder={placeholder}
79
92
  accessibilityLabel={accessibilityLabel}
80
93
  // With verbs on the field, the FRAME owns the surface and the ring.
@@ -0,0 +1,17 @@
1
+ /** Vertical breathing room inside an input, per side. */
2
+ export const INPUT_VERTICAL_PADDING = 8;
3
+
4
+ /**
5
+ * The height a MULTI-LINE input occupies, for `numberOfLines` lines of text.
6
+ *
7
+ * One formula with two callers on purpose: `TextInputField` sizes the editor
8
+ * with it, and an inline field's RESTING box reserves the same number. When
9
+ * each computed its own, the resting box came out 22px shorter and every click
10
+ * on a payment-term field shoved the rest of the form down the page.
11
+ *
12
+ * RN-free so it can be tested directly — the caller supplies the line height,
13
+ * which is the only platform-dependent part.
14
+ */
15
+ export function multilineInputHeight(numberOfLines: number, lineHeight: number): number {
16
+ return numberOfLines * lineHeight + INPUT_VERTICAL_PADDING * 2;
17
+ }
@@ -13,7 +13,7 @@ import { Ref, useCallback, useState } from "react";
13
13
  import { Icon, IconName } from "./icon";
14
14
  import { IconButton } from "./icon_button";
15
15
  import { ShortcutBadge } from "./shortcut_badge";
16
- import { fontFamilyRegular, getInputLineHeight, getInputTextStyle } from "./text_utils";
16
+ import { fontFamilyRegular, getInputTextStyle, getMultilineInputHeight } from "./text_utils";
17
17
  import { useScreenSize } from "./use_screen_size";
18
18
  import { useAutoGrowHeight } from "./use_auto_grow_height";
19
19
  import { useFormField } from "./form_field";
@@ -84,7 +84,6 @@ export function TextInputField(props: TextInputFieldProps) {
84
84
  if (ariaAutocomplete !== undefined) webAriaAttrs["aria-autocomplete"] = ariaAutocomplete;
85
85
 
86
86
  const { small } = useScreenSize();
87
- const lineHeight = getInputLineHeight(small);
88
87
  const binding = useFormField();
89
88
  // Text-like input: ring on ANY focus (browsers treat typing-capable fields as
90
89
  // focus-visible even on pointer focus), so a clicked field shows its ring.
@@ -99,7 +98,7 @@ export function TextInputField(props: TextInputFieldProps) {
99
98
 
100
99
  const minHeight =
101
100
  numberOfLines && numberOfLines > 1
102
- ? numberOfLines * lineHeight + 16
101
+ ? getMultilineInputHeight(numberOfLines, small)
103
102
  : undefined;
104
103
 
105
104
  const autoGrowResult = useAutoGrowHeight({
package/src/text_utils.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Platform, TextStyle } from "react-native";
2
2
  import { colors } from "./colors";
3
+ import { multilineInputHeight } from "./input_metrics";
3
4
 
4
5
  export type TextColor =
5
6
  | "default"
@@ -112,6 +113,11 @@ export function getInputTextStyle(): TextStyle {
112
113
  *
113
114
  * @param isSmallScreen - Whether the screen is small (mobile). Get this from useScreenSize().small
114
115
  */
116
+ /** {@link multilineInputHeight} at this platform's input line height. */
117
+ export function getMultilineInputHeight(numberOfLines: number, isSmallScreen: boolean): number {
118
+ return multilineInputHeight(numberOfLines, getInputLineHeight(isSmallScreen));
119
+ }
120
+
115
121
  export function getInputLineHeight(isSmallScreen: boolean): number {
116
122
  if (Platform.OS !== "web") {
117
123
  return INPUT_LINE_HEIGHT_DESKTOP;