@lotics/ui 18.1.0 → 19.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "18.1.0",
3
+ "version": "19.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -19,6 +19,12 @@ const DetailTableContext = createContext<DetailTableColumns | null>(null);
19
19
  * side-by-side columns and stacks — below it, editors get crushed. */
20
20
  const MIN_VALUE_WIDTH = 160;
21
21
 
22
+ /** THE kit's label column — ~18 characters, where real field names live. It is
23
+ * this table's default AND the floor a `TaskSubRow`'s label sizes from, so a
24
+ * record's fields and a task's read as one language on a surface carrying
25
+ * both; that only holds while the two are ONE number. */
26
+ export const DETAIL_LABEL_WIDTH = 130;
27
+
22
28
  export interface DetailTableProps {
23
29
  /** Label column width (px). Default 130. */
24
30
  labelWidth?: number;
@@ -64,7 +70,7 @@ export interface DetailTableProps {
64
70
  * the switch is automatic.
65
71
  */
66
72
  export function DetailTable(props: DetailTableProps) {
67
- const { labelWidth = 130, trailingWidth, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_VALUE_WIDTH, children, style } = props;
73
+ const { labelWidth = DETAIL_LABEL_WIDTH, trailingWidth, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_VALUE_WIDTH, children, style } = props;
68
74
  const [width, setWidth] = useState<number | null>(null);
69
75
  const stacked =
70
76
  width != null && width < labelWidth + (trailingWidth ?? 0) + minValueWidth + 24;
package/src/file_grid.tsx CHANGED
@@ -59,6 +59,10 @@ export interface FileGridProps {
59
59
  * e.g. open the gallery at the first hidden file. */
60
60
  onOverflowPress?: (hiddenCount: number) => void;
61
61
  style?: ViewStyle;
62
+ /** Render every tile INERT — no press, no `button` role, no tab stop. Use it
63
+ * when something ABOVE the grid owns the press (a table cell, a popover
64
+ * trigger, a pressable row): a tile that keeps its button nests one button
65
+ * inside another. */
62
66
  disablePress?: boolean;
63
67
  partialRowAlign?: "start" | "end";
64
68
  selectedIds?: ReadonlySet<string>;
@@ -162,6 +166,7 @@ export function FileGrid(props: FileGridProps) {
162
166
  file={item.file}
163
167
  size={size}
164
168
  onPress={disablePress ? undefined : () => handleFilePress(item.file)}
169
+ disablePress={disablePress}
165
170
  onRemove={onDisplayRemove ? () => onDisplayRemove(item.removeId) : undefined}
166
171
  selected={selectedIds?.has(item.file.id)}
167
172
  />
@@ -66,6 +66,12 @@ interface FileThumbnailProps {
66
66
  size?: number;
67
67
  /** Called when thumbnail is pressed */
68
68
  onPress?: () => void;
69
+ /** Render the tile INERT — no press handler, no `button` role, no tab stop.
70
+ * Without this an `onPress`-less tile still falls back to opening the file,
71
+ * so it stays a `<button>`: dropping a strip into a pressable row (a table
72
+ * cell, a `PressableRow`) then nests a button in a button. Set it whenever
73
+ * something ABOVE the tile owns the press. */
74
+ disablePress?: boolean;
69
75
  /** Called when thumbnail is long-pressed */
70
76
  onLongPress?: () => void;
71
77
  /** Called when the completed file is removed */
@@ -87,8 +93,15 @@ interface FileThumbnailProps {
87
93
  * Works with any file source via DisplayFile interface.
88
94
  */
89
95
  export function FileThumbnail(props: FileThumbnailProps) {
90
- const { file, size, onPress, onLongPress, onRemove, selected, uploading, isTemplate } = props;
96
+ const { file, size, onPress, onLongPress, onRemove, selected, uploading, isTemplate, disablePress } = props;
91
97
  const accessibilityLabel = props.accessibilityLabel ?? file.filename;
98
+ // A tile with no `onPress` still OPENS the file — a useful default for a bare
99
+ // thumbnail in a list or a gallery. But a caller that said `disablePress`
100
+ // means it: inventing a handler there renders a <button>, and the one place a
101
+ // compact strip belongs is a table cell, which is itself pressable. Nesting a
102
+ // button in a button is invalid HTML and two tab stops for one action.
103
+ const press = disablePress ? undefined : onPress;
104
+ const pressOrOpen = disablePress ? undefined : (onPress ?? (() => Linking.openURL(file.url)));
92
105
 
93
106
  const rootStyle =
94
107
  size !== undefined ? { width: size, height: size } : { width: "100%" as const, aspectRatio: 1 };
@@ -96,7 +109,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
96
109
  if (isImageMimeType(file.mimeType)) {
97
110
  return (
98
111
  <View style={rootStyle}>
99
- <ImageThumbnail file={file} size={size} accessibilityLabel={accessibilityLabel} onPress={onPress} onLongPress={onLongPress} />
112
+ <ImageThumbnail file={file} size={size} accessibilityLabel={accessibilityLabel} onPress={press} onLongPress={onLongPress} />
100
113
  {uploading && <UploadingOverlay size={size} />}
101
114
  {onRemove && <RemoveButton onPress={onRemove} />}
102
115
  {selected !== undefined && <SelectionOverlay selected={selected} />}
@@ -114,7 +127,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
114
127
  size={size}
115
128
  isTemplate={isTemplate}
116
129
  accessibilityLabel={accessibilityLabel}
117
- onPress={onPress ?? (() => Linking.openURL(file.url))}
130
+ onPress={pressOrOpen}
118
131
  onLongPress={onLongPress}
119
132
  />
120
133
  {uploading && <UploadingOverlay size={size} />}
@@ -132,7 +145,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
132
145
  icon={mediaIcon}
133
146
  size={size}
134
147
  accessibilityLabel={accessibilityLabel}
135
- onPress={onPress ?? (() => Linking.openURL(file.url))}
148
+ onPress={pressOrOpen}
136
149
  onLongPress={onLongPress}
137
150
  />
138
151
  ) : (
@@ -142,7 +155,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
142
155
  size={size}
143
156
  isTemplate={isTemplate}
144
157
  accessibilityLabel={accessibilityLabel}
145
- onPress={onPress ?? (() => Linking.openURL(file.url))}
158
+ onPress={pressOrOpen}
146
159
  onLongPress={onLongPress}
147
160
  />
148
161
  )}
@@ -206,7 +219,8 @@ export function DocumentBadge(props: DocumentBadgeProps) {
206
219
  <Pressable
207
220
  onPress={onPress}
208
221
  onLongPress={onLongPress}
209
- accessibilityRole="button"
222
+ accessibilityRole={onPress ? "button" : undefined}
223
+ tabIndex={onPress ? undefined : -1}
210
224
  accessibilityLabel={accessibilityLabel ?? resolveMime(mimeType).label}
211
225
  {...focusProps}
212
226
  style={focusVisible ? { boxShadow: FOCUS_RING, borderRadius: 6 } : undefined}
@@ -262,7 +276,8 @@ export function DocumentCard(props: DocumentCardProps) {
262
276
  ]}
263
277
  onPress={onPress}
264
278
  onLongPress={onLongPress}
265
- accessibilityRole="button"
279
+ accessibilityRole={onPress ? "button" : undefined}
280
+ tabIndex={onPress ? undefined : -1}
266
281
  accessibilityLabel={a11yLabel}
267
282
  {...focusProps}
268
283
  >
@@ -313,7 +328,8 @@ export function MediaCard(props: MediaCardProps) {
313
328
  style={({ pressed }) => [styles.mediaCardCompact, sizeStyle, { backgroundColor: color }, pressed && styles.pressedDim, focusVisible && { boxShadow: FOCUS_RING }]}
314
329
  onPress={onPress}
315
330
  onLongPress={onLongPress}
316
- accessibilityRole="button"
331
+ accessibilityRole={onPress ? "button" : undefined}
332
+ tabIndex={onPress ? undefined : -1}
317
333
  accessibilityLabel={a11yLabel}
318
334
  {...focusProps}
319
335
  >
@@ -327,7 +343,8 @@ export function MediaCard(props: MediaCardProps) {
327
343
  const backdrop = iconSize + 20;
328
344
 
329
345
  return (
330
- <Pressable style={[styles.documentCard, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]} onPress={onPress} onLongPress={onLongPress} accessibilityRole="button" accessibilityLabel={a11yLabel} {...focusProps}>
346
+ <Pressable style={[styles.documentCard, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]} onPress={onPress} onLongPress={onLongPress} accessibilityRole={onPress ? "button" : undefined}
347
+ tabIndex={onPress ? undefined : -1} accessibilityLabel={a11yLabel} {...focusProps}>
331
348
  {(state) => (
332
349
  <>
333
350
  <View style={[styles.mediaCardBody, { backgroundColor: color }]}>
@@ -381,7 +398,7 @@ function ImageThumbnail(props: ImageThumbnailProps) {
381
398
  : { width: "100%" as const, height: "100%" as const };
382
399
 
383
400
  return (
384
- <Pressable onPress={onPress} onLongPress={onLongPress} accessibilityRole={onPress ? "button" : "image"} accessibilityLabel={a11yLabel} {...focusProps} style={[styles.imageThumbnail, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]}>
401
+ <Pressable onPress={onPress} onLongPress={onLongPress} accessibilityRole={onPress ? "button" : "image"} tabIndex={onPress ? undefined : -1} accessibilityLabel={a11yLabel} {...focusProps} style={[styles.imageThumbnail, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]}>
385
402
  {(state) => (
386
403
  <>
387
404
  <Image
@@ -188,6 +188,10 @@ export function FileThumbnailGrid(props: FileThumbnailGridProps) {
188
188
  file={file}
189
189
  size={size}
190
190
  onPress={disablePress || !onFilePress ? undefined : () => onFilePress(file)}
191
+ // Withholding `onPress` is NOT enough: a tile with none falls back to
192
+ // opening the file, so it stays a button and a tab stop. The prop has
193
+ // to reach the tile that honors it.
194
+ disablePress={disablePress}
191
195
  onRemove={onRemove ? () => onRemove(file.id) : undefined}
192
196
  selected={selectedIds?.has(file.id)}
193
197
  />
@@ -5,6 +5,7 @@ import { PressDoor } from "./press_door";
5
5
  import { Text } from "./text";
6
6
  import { Icon, type IconName } from "./icon";
7
7
  import { colors } from "./colors";
8
+ import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
8
9
 
9
10
  /** One reference fact. `V` is the value the variant admits — text under a door, any node without one. */
10
11
  interface LinkedRecordFact<V = ReactNode> {
@@ -94,18 +95,30 @@ export function LinkedRecordBox(props: LinkedRecordBoxProps) {
94
95
  </View>
95
96
  {facts.length > 0 ? (
96
97
  <View style={{ gap: 6 }}>
97
- {facts.map((f) => (
98
- <View key={f.label} style={styles.fact}>
99
- <Text size="sm" color="muted" style={{ width: factLabelWidth }}>{f.label}</Text>
100
- {typeof f.value === "string" ? (
101
- <Text size="sm" style={{ flex: 1 }}>{f.value || "—"}</Text>
102
- ) : (
103
- // A node prints as authored the "—" placeholder is a TEXT affordance, and a
104
- // control renders its own empty state.
105
- <View style={{ flex: 1, minWidth: 0 }}>{f.value}</View>
106
- )}
107
- </View>
108
- ))}
98
+ {facts.map((f) => {
99
+ // THE ALIGNMENT LAW, the same one `DetailRow` runs on: the fact TOP-aligns and each
100
+ // cell centres within the first CONTROL LINE. A node value brings the kit's control
101
+ // band (an inline editor is 40 tall, a chip less), so both cells claim it — without
102
+ // it the label `Text`, stretched to the row, printed its words at the band's TOP,
103
+ // a half-band above the value it names. A STRING value has no band: label and value
104
+ // are the same 20px line, so they sit at the row's top and a long value that wraps
105
+ // still tops out level with its label.
106
+ const banded = typeof f.value !== "string";
107
+ return (
108
+ <View key={f.label} style={styles.fact}>
109
+ <View style={[{ width: factLabelWidth }, banded && styles.bandedCell]}>
110
+ <Text size="sm" color="muted">{f.label}</Text>
111
+ </View>
112
+ {banded ? (
113
+ // A node prints as authored — the "—" placeholder is a TEXT affordance, and a
114
+ // control renders its own empty state.
115
+ <View style={[{ flex: 1, minWidth: 0 }, styles.bandedCell]}>{f.value}</View>
116
+ ) : (
117
+ <Text size="sm" style={{ flex: 1 }}>{f.value || "—"}</Text>
118
+ )}
119
+ </View>
120
+ );
121
+ })}
109
122
  </View>
110
123
  ) : null}
111
124
  {actions ? (
@@ -134,7 +147,11 @@ const styles = StyleSheet.create({
134
147
  box: { flexDirection: "column", alignItems: "stretch", borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 10, paddingHorizontal: 16, paddingTop: 14, paddingBottom: 10, gap: 12 },
135
148
  identity: { flexDirection: "row", alignItems: "center", gap: 10 },
136
149
  glyph: { width: 34, height: 34, borderRadius: 8, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" },
137
- fact: { flexDirection: "row", gap: 10 },
150
+ fact: { flexDirection: "row", gap: 10, alignItems: "flex-start" },
151
+ // A cell on the control band: exactly `minHeight` tall for a one-line label / a short chip,
152
+ // and taller content (a wrapped label, a stack of controls) tops out level instead of being
153
+ // dragged toward the block's centre.
154
+ bandedCell: { minHeight: INLINE_CONTROL_HEIGHT, justifyContent: "center" },
138
155
  divider: { height: 1, backgroundColor: colors.zinc[100], marginHorizontal: -16 },
139
156
  actions: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 8, zIndex: 1 },
140
157
  });