@lotics/ui 14.1.0 → 14.3.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.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `FileBadge` geometry — RN-free so it is unit-testable (a component file importing
3
+ * `react-native` cannot be loaded by Vitest).
4
+ *
5
+ * A `FileBadge`'s `size` is its WIDTH; every other dimension scales off it, so the badge
6
+ * has a FIXED aspect (26 × 32 at the default) and is TALLER than it is wide. A caller that
7
+ * owns a SQUARE slot — the thumbnail family, whose `size` is the tile side — therefore
8
+ * cannot pass its tile side through as a width: the badge would overflow the tile's
9
+ * bottom. `fileBadgeWidthForBox` converts a square slot to the width that fits it.
10
+ */
11
+
12
+ /** The default badge width. A default badge is exactly `FILE_BADGE_BOX` tall. */
13
+ export const FILE_BADGE_DEFAULT_WIDTH = 26;
14
+
15
+ /** The height of a default-width badge — and the square slot it exactly fills. */
16
+ export const FILE_BADGE_BOX = 32;
17
+
18
+ /** The badge's height at a given width. The aspect is fixed. */
19
+ export function fileBadgeHeight(width: number): number {
20
+ return Math.round((FILE_BADGE_BOX * width) / FILE_BADGE_DEFAULT_WIDTH);
21
+ }
22
+
23
+ /**
24
+ * The widest badge that fits inside a `box`×`box` square slot — height is the binding
25
+ * dimension, since the badge is taller than it is wide. `fileBadgeWidthForBox(32)` is
26
+ * `FILE_BADGE_DEFAULT_WIDTH`: the default badge fills the compact thumbnail slot exactly.
27
+ */
28
+ export function fileBadgeWidthForBox(box: number): number {
29
+ let width = Math.max(1, Math.floor((box * FILE_BADGE_DEFAULT_WIDTH) / FILE_BADGE_BOX));
30
+ // Guard the rounding in `fileBadgeHeight`: the floored width can still round UP past
31
+ // the box by a pixel.
32
+ while (width > 1 && fileBadgeHeight(width) > box) width--;
33
+ return width;
34
+ }
@@ -1,4 +1,5 @@
1
1
  import { FileBadge, resolveMime } from "./file_badge";
2
+ import { fileBadgeWidthForBox } from "./file_badge_fit";
2
3
  import { fontFamilySemiBold } from "./text_utils";
3
4
  import { Text } from "./text";
4
5
  import { Icon, type IconName } from "./icon";
@@ -76,6 +77,9 @@ interface FileThumbnailProps {
76
77
  uploading?: boolean;
77
78
  /** Marks the file as a document TEMPLATE — adds a "TMPL" badge (any file type). */
78
79
  isTemplate?: boolean;
80
+ /** Accessible name for the tile — what pressing it DOES, when the raw filename isn't
81
+ * the useful name ("Xem chứng từ"). Defaults to `file.filename`. */
82
+ accessibilityLabel?: string;
79
83
  }
80
84
 
81
85
  /**
@@ -84,6 +88,7 @@ interface FileThumbnailProps {
84
88
  */
85
89
  export function FileThumbnail(props: FileThumbnailProps) {
86
90
  const { file, size, onPress, onLongPress, onRemove, selected, uploading, isTemplate } = props;
91
+ const accessibilityLabel = props.accessibilityLabel ?? file.filename;
87
92
 
88
93
  const rootStyle =
89
94
  size !== undefined ? { width: size, height: size } : { width: "100%" as const, aspectRatio: 1 };
@@ -91,7 +96,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
91
96
  if (isImageMimeType(file.mimeType)) {
92
97
  return (
93
98
  <View style={rootStyle}>
94
- <ImageThumbnail file={file} size={size} onPress={onPress} onLongPress={onLongPress} />
99
+ <ImageThumbnail file={file} size={size} accessibilityLabel={accessibilityLabel} onPress={onPress} onLongPress={onLongPress} />
95
100
  {uploading && <UploadingOverlay size={size} />}
96
101
  {onRemove && <RemoveButton onPress={onRemove} />}
97
102
  {selected !== undefined && <SelectionOverlay selected={selected} />}
@@ -108,7 +113,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
108
113
  mimeType={file.mimeType}
109
114
  size={size}
110
115
  isTemplate={isTemplate}
111
- accessibilityLabel={file.filename}
116
+ accessibilityLabel={accessibilityLabel}
112
117
  onPress={onPress ?? (() => Linking.openURL(file.url))}
113
118
  onLongPress={onLongPress}
114
119
  />
@@ -126,6 +131,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
126
131
  filename={file.filename}
127
132
  icon={mediaIcon}
128
133
  size={size}
134
+ accessibilityLabel={accessibilityLabel}
129
135
  onPress={onPress ?? (() => Linking.openURL(file.url))}
130
136
  onLongPress={onLongPress}
131
137
  />
@@ -135,6 +141,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
135
141
  filename={file.filename}
136
142
  size={size}
137
143
  isTemplate={isTemplate}
144
+ accessibilityLabel={accessibilityLabel}
138
145
  onPress={onPress ?? (() => Linking.openURL(file.url))}
139
146
  onLongPress={onLongPress}
140
147
  />
@@ -176,6 +183,9 @@ const uploadingOverlayStyles = StyleSheet.create({
176
183
 
177
184
  interface DocumentBadgeProps {
178
185
  mimeType: string;
186
+ /** The square SLOT side the badge fills — the same unit as `DocumentCard`/`MediaCard`
187
+ * `size` and the thumbnail tile it sits in. A `FileBadge` is TALLER than it is wide,
188
+ * so the slot is converted to a fitting badge width; omit for the default 32px slot. */
179
189
  size?: number;
180
190
  isTemplate?: boolean;
181
191
  onPress?: () => void;
@@ -189,7 +199,7 @@ interface DocumentBadgeProps {
189
199
  * Compact two-tone file badge with document lines and colored label.
190
200
  */
191
201
  export function DocumentBadge(props: DocumentBadgeProps) {
192
- const { mimeType, isTemplate, onPress, onLongPress, accessibilityLabel } = props;
202
+ const { mimeType, size, isTemplate, onPress, onLongPress, accessibilityLabel } = props;
193
203
  const { focusVisible, focusProps } = useFocusRing();
194
204
 
195
205
  return (
@@ -201,7 +211,11 @@ export function DocumentBadge(props: DocumentBadgeProps) {
201
211
  {...focusProps}
202
212
  style={focusVisible ? { boxShadow: FOCUS_RING, borderRadius: 6 } : undefined}
203
213
  >
204
- <FileBadge mimeType={mimeType} isTemplate={isTemplate} />
214
+ <FileBadge
215
+ mimeType={mimeType}
216
+ size={size !== undefined ? fileBadgeWidthForBox(size) : undefined}
217
+ isTemplate={isTemplate}
218
+ />
205
219
  </Pressable>
206
220
  );
207
221
  }
@@ -214,6 +228,8 @@ interface DocumentCardProps {
214
228
  onPress?: () => void;
215
229
  onLongPress?: () => void;
216
230
  overlay?: React.ReactNode;
231
+ /** Accessible name for the tile. Defaults to `filename` (which the tile also shows). */
232
+ accessibilityLabel?: string;
217
233
  }
218
234
 
219
235
  /**
@@ -224,6 +240,7 @@ interface DocumentCardProps {
224
240
  */
225
241
  export function DocumentCard(props: DocumentCardProps) {
226
242
  const { mimeType, filename, size, isTemplate, onPress, onLongPress, overlay } = props;
243
+ const a11yLabel = props.accessibilityLabel ?? filename;
227
244
 
228
245
  const sizeStyle =
229
246
  size !== undefined
@@ -246,7 +263,7 @@ export function DocumentCard(props: DocumentCardProps) {
246
263
  onPress={onPress}
247
264
  onLongPress={onLongPress}
248
265
  accessibilityRole="button"
249
- accessibilityLabel={filename}
266
+ accessibilityLabel={a11yLabel}
250
267
  {...focusProps}
251
268
  >
252
269
  <FileBadge mimeType={mimeType} size={badgeSize} isTemplate={isTemplate} />
@@ -267,6 +284,8 @@ interface MediaCardProps {
267
284
  onPress?: () => void;
268
285
  onLongPress?: () => void;
269
286
  overlay?: React.ReactNode;
287
+ /** Accessible name for the tile. Defaults to `filename` (which the card also shows). */
288
+ accessibilityLabel?: string;
270
289
  }
271
290
 
272
291
  /**
@@ -276,6 +295,7 @@ interface MediaCardProps {
276
295
  */
277
296
  export function MediaCard(props: MediaCardProps) {
278
297
  const { mimeType, filename, size, onPress, onLongPress, overlay, icon } = props;
298
+ const a11yLabel = props.accessibilityLabel ?? filename;
279
299
  const { label, color } = resolveMime(mimeType);
280
300
 
281
301
  const sizeStyle =
@@ -294,7 +314,7 @@ export function MediaCard(props: MediaCardProps) {
294
314
  onPress={onPress}
295
315
  onLongPress={onLongPress}
296
316
  accessibilityRole="button"
297
- accessibilityLabel={filename}
317
+ accessibilityLabel={a11yLabel}
298
318
  {...focusProps}
299
319
  >
300
320
  <Icon name={icon} size={iconSize} color={colors.white} />
@@ -307,7 +327,7 @@ export function MediaCard(props: MediaCardProps) {
307
327
  const backdrop = iconSize + 20;
308
328
 
309
329
  return (
310
- <Pressable style={[styles.documentCard, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]} onPress={onPress} onLongPress={onLongPress} accessibilityRole="button" accessibilityLabel={filename} {...focusProps}>
330
+ <Pressable style={[styles.documentCard, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]} onPress={onPress} onLongPress={onLongPress} accessibilityRole="button" accessibilityLabel={a11yLabel} {...focusProps}>
311
331
  {(state) => (
312
332
  <>
313
333
  <View style={[styles.mediaCardBody, { backgroundColor: color }]}>
@@ -342,10 +362,13 @@ interface ImageThumbnailProps {
342
362
  size?: number;
343
363
  onPress?: () => void;
344
364
  onLongPress?: () => void;
365
+ /** Accessible name for the tile. Defaults to `file.filename`. */
366
+ accessibilityLabel?: string;
345
367
  }
346
368
 
347
369
  function ImageThumbnail(props: ImageThumbnailProps) {
348
370
  const { file, size, onPress, onLongPress } = props;
371
+ const a11yLabel = props.accessibilityLabel ?? file.filename;
349
372
  const [useFallback, setUseFallback] = useState(false);
350
373
  const { focusVisible, focusProps } = useFocusRing();
351
374
 
@@ -358,7 +381,7 @@ function ImageThumbnail(props: ImageThumbnailProps) {
358
381
  : { width: "100%" as const, height: "100%" as const };
359
382
 
360
383
  return (
361
- <Pressable onPress={onPress} onLongPress={onLongPress} accessibilityRole={onPress ? "button" : "image"} accessibilityLabel={file.filename} {...focusProps} style={[styles.imageThumbnail, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]}>
384
+ <Pressable onPress={onPress} onLongPress={onLongPress} accessibilityRole={onPress ? "button" : "image"} accessibilityLabel={a11yLabel} {...focusProps} style={[styles.imageThumbnail, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]}>
362
385
  {(state) => (
363
386
  <>
364
387
  <Image
@@ -1,5 +1,5 @@
1
- import { useRef, type ReactNode } from "react";
2
- import { ScrollView, StyleProp, ViewStyle, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native";
1
+ import { type ReactNode } from "react";
2
+ import { FlatList, StyleProp, View, ViewStyle } from "react-native";
3
3
 
4
4
  export interface FollowScrollProps {
5
5
  children: ReactNode;
@@ -9,50 +9,45 @@ export interface FollowScrollProps {
9
9
  contentContainerStyle?: StyleProp<ViewStyle>;
10
10
  }
11
11
 
12
- // "At the bottom" tolerance: within this many px of the end still counts as
13
- // pinned, so sub-pixel scroll positions and streaming-growth rounding never
14
- // silently unpin the follow.
15
- const PIN_THRESHOLD = 40;
12
+ const DATA = ["content"];
16
13
 
17
14
  /**
18
15
  * A scroll container that FOLLOWS its growing content — pinned to the bottom, it
19
16
  * keeps the newest content in view as children stream in (an `AgentRun` in a
20
- * dialog, a log feed). Scrolling up UNPINS it so the human can read history while
21
- * the content keeps growing; scrolling back to the bottom re-pins. Chat doesn't
22
- * need this (its message list is inverted, which follows by construction) — this
23
- * is for the plain top-down containers everywhere else: dialogs, drawers, panels.
17
+ * dialog, a log feed). Scrolling up releases the pin so the human can read
18
+ * history; scrolling back to the bottom re-pins.
19
+ *
20
+ * Implemented as a single-cell INVERTED list the same mechanism as a chat
21
+ * message list — so the follow happens at LAYOUT level: the browser paints each
22
+ * growth already pinned (no scroll-after-paint flash, no scroll math). Short
23
+ * content still reads top-down (the content is end-justified in inverted
24
+ * coordinates); once it overflows, the newest edge stays in view natively. The
25
+ * inverted pattern's quirks (scrollbar thumb runs opposite, keyboard paging
26
+ * inverted) match the chat surface users already know.
24
27
  */
25
28
  export function FollowScroll(props: FollowScrollProps) {
26
29
  const { children, style, contentContainerStyle } = props;
27
- const scrollRef = useRef<ScrollView>(null);
28
- // Pinned state lives in a ref — it changes on every scroll tick and must never
29
- // re-render the (streaming, already-busy) subtree.
30
- const pinnedRef = useRef(true);
31
-
32
- const followIfPinned = () => {
33
- if (pinnedRef.current) scrollRef.current?.scrollToEnd({ animated: false });
34
- };
35
-
36
- const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
37
- const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
38
- const distanceFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height;
39
- pinnedRef.current = distanceFromBottom < PIN_THRESHOLD;
40
- };
41
-
42
30
  return (
43
- <ScrollView
44
- ref={scrollRef}
31
+ <FlatList
32
+ inverted
33
+ data={DATA}
34
+ keyExtractor={(k) => k}
35
+ // The consumer's content styles live on an inner wrapper INSIDE the cell —
36
+ // the cell renders upright (double flip), so authored padding keeps its
37
+ // visual orientation; the flipped outer container must not carry it.
38
+ renderItem={() => <View style={contentContainerStyle}>{children}</View>}
39
+ // Children identity changes every stream tick; without extraData the list
40
+ // would keep rendering the mounted cell's stale content.
41
+ extraData={children}
45
42
  style={style}
46
- contentContainerStyle={contentContainerStyle}
47
- // Content grew (a new part streamed in) or the frame resized (dialog
48
- // re-measured) — both move the bottom edge, so both re-follow when pinned.
49
- onContentSizeChange={followIfPinned}
50
- onLayout={followIfPinned}
51
- onScroll={onScroll}
52
- scrollEventThrottle={16}
43
+ // flexGrow + flex-end top-aligns SHORT content (flex-end is the visual TOP
44
+ // in inverted coordinates), so a feed reads top-down until it overflows.
45
+ contentContainerStyle={followStyles.grow}
53
46
  nestedScrollEnabled
54
- >
55
- {children}
56
- </ScrollView>
47
+ />
57
48
  );
58
49
  }
50
+
51
+ const followStyles = {
52
+ grow: { flexGrow: 1, justifyContent: "flex-end" } as const,
53
+ };
@@ -1,11 +1,10 @@
1
1
  import { type ReactNode } from "react";
2
- import { View, Pressable, StyleSheet } from "react-native";
2
+ import { View, StyleSheet } from "react-native";
3
3
  import { PressableRow } from "./pressable_row";
4
+ import { PressDoor } from "./press_door";
4
5
  import { Text } from "./text";
5
6
  import { Icon, type IconName } from "./icon";
6
7
  import { colors } from "./colors";
7
- import { useFocusRing } from "./use_focus_ring";
8
- import { FOCUS_RING } from "./control_surface";
9
8
 
10
9
  interface LinkedRecordBoxProps {
11
10
  /** The linked record's kind glyph (building-2 for a company, file-text for a record…). */
@@ -38,13 +37,13 @@ interface LinkedRecordBoxProps {
38
37
  *
39
38
  * The a11y contract it enforces (the error-prone part, hence a primitive): a container with
40
39
  * interactive descendants must NEVER be `role="button"` (invalid HTML). So the box is a
41
- * role-less `PressableRow`, an empty absolutely-positioned door sibling carries the tab stop /
42
- * accessible name / focus ring, and the interior verbs lift above it via `zIndex`.
40
+ * role-less `PressableRow`, a `PressDoor` sibling carries the tab stop / accessible name /
41
+ * focus ring, and the interior verbs lift above it via `zIndex`.
43
42
  */
44
43
  export function LinkedRecordBox({ icon, name, subtitle, facts, factLabelWidth = 80, doorLabel, onOpen, actions }: LinkedRecordBoxProps) {
45
44
  return (
46
45
  <PressableRow onPress={onOpen} style={styles.box}>
47
- <Door label={doorLabel} onPress={onOpen} />
46
+ <PressDoor accessibilityLabel={doorLabel} onPress={onOpen} />
48
47
  <View style={styles.identity}>
49
48
  <View style={styles.glyph}>
50
49
  <Icon name={icon} size={17} color={colors.zinc[600]} />
@@ -75,25 +74,8 @@ export function LinkedRecordBox({ icon, name, subtitle, facts, factLabelWidth =
75
74
  );
76
75
  }
77
76
 
78
- // The keyboard DOOR: an empty absolutely-positioned sibling beneath the box content — a
79
- // button must NOT wrap the box's interactive descendants, so the door alone carries the tab
80
- // stop, accessible name, and focus ring; mouse presses ride the PressableRow surface.
81
- function Door({ label, onPress }: { label: string; onPress: () => void }) {
82
- const { focusVisible, focusProps } = useFocusRing();
83
- return (
84
- <Pressable
85
- accessibilityRole="button"
86
- accessibilityLabel={label}
87
- onPress={onPress}
88
- {...focusProps}
89
- style={[styles.door, focusVisible ? { boxShadow: FOCUS_RING } : null]}
90
- />
91
- );
92
- }
93
-
94
77
  const styles = StyleSheet.create({
95
78
  box: { flexDirection: "column", alignItems: "stretch", borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 10, paddingHorizontal: 16, paddingTop: 14, paddingBottom: 10, gap: 12 },
96
- door: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, borderRadius: 10 },
97
79
  identity: { flexDirection: "row", alignItems: "center", gap: 10 },
98
80
  glyph: { width: 34, height: 34, borderRadius: 8, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" },
99
81
  fact: { flexDirection: "row", gap: 10 },
@@ -0,0 +1,68 @@
1
+ import { Pressable, StyleSheet } from "react-native";
2
+ import { FOCUS_RING } from "./control_surface";
3
+ import { useFocusRing } from "./use_focus_ring";
4
+
5
+ export interface PressDoorProps {
6
+ /** Opens the thing the surface stands for — the same handler the surface presses. */
7
+ onPress: () => void;
8
+ /**
9
+ * REQUIRED. The door is EMPTY, so it has no text content to derive a name from —
10
+ * without a label it is a nameless button. Name the destination ("Open ORD-1042").
11
+ */
12
+ accessibilityLabel: string;
13
+ /** Focus-ring corner radius — match the surface the door spans. Default 10 (the register wash). */
14
+ radius?: number;
15
+ }
16
+
17
+ /**
18
+ * THE keyboard door: an empty, absolutely-positioned `Pressable` that carries the
19
+ * `role="button"` tab stop, the accessible name, and the focus ring for a pressable
20
+ * surface whose CONTENT can hold its own controls.
21
+ *
22
+ * A button must not contain interactive descendants — nesting `<button>` in `<button>`
23
+ * is invalid HTML (React hydration warning) and collapses the inner control's press and
24
+ * tab stop. So the door is never an ANCESTOR of the content: it is a SIBLING beneath it.
25
+ *
26
+ * Composition (the contract) — the role-less `PressableRow` takes the mouse and paints the
27
+ * hover wash, the door takes the keyboard, the content keeps its own controls:
28
+ * ```tsx
29
+ * <PressableRow onPress={open}>
30
+ * <PressDoor onPress={open} accessibilityLabel="Open ORD-1042" />
31
+ * <View style={{ zIndex: 1 }}>…cells, CTAs, overflow menus…</View>
32
+ * </PressableRow>
33
+ * ```
34
+ * The parent must establish the positioning context (`PressableRow` and any
35
+ * `position: "relative"` box do), and the in-flow content must lift above the door with
36
+ * `zIndex: 1` — an absolutely-positioned sibling paints AND hit-tests above in-flow
37
+ * content, which would otherwise swallow every nested control's press. (A negative z on
38
+ * the door instead would escape the row — no stacking context — and bury the focus ring
39
+ * under the card background.) With that, the door takes only the keyboard, the mouse
40
+ * rides the surface, and nested controls claim their own presses.
41
+ *
42
+ * Wrap the content in the pressable itself (`PressableHighlight`, `FileRow`'s door) ONLY
43
+ * when that content is non-interactive by construction.
44
+ *
45
+ * `TableRow` and `LinkedRecordBox` are the in-kit consumers.
46
+ */
47
+ export function PressDoor({ onPress, accessibilityLabel, radius = 10 }: PressDoorProps) {
48
+ const { focusVisible, focusProps } = useFocusRing();
49
+ return (
50
+ <Pressable
51
+ accessibilityRole="button"
52
+ accessibilityLabel={accessibilityLabel}
53
+ onPress={onPress}
54
+ {...focusProps}
55
+ style={[styles.door, { borderRadius: radius }, focusVisible && { boxShadow: FOCUS_RING }]}
56
+ />
57
+ );
58
+ }
59
+
60
+ const styles = StyleSheet.create({
61
+ door: {
62
+ position: "absolute",
63
+ top: 0,
64
+ right: 0,
65
+ bottom: 0,
66
+ left: 0,
67
+ },
68
+ });
@@ -63,6 +63,13 @@ export interface PressableHighlightProps extends PressableProps {
63
63
  /**
64
64
  * A pressable component that highlights when hovered and pressed.
65
65
  * It will darken the `backgroundColor` prop when hovered and pressed.
66
+ *
67
+ * It IS a button and it WRAPS its children — so its content must be non-interactive by
68
+ * construction. A row whose content carries its own controls (a CTA, a ⋯ menu, a
69
+ * checkbox, a `Link`) must not be built on it: a button cannot contain interactive
70
+ * descendants. Compose the role-less `PressableRow` + a `PressDoor` sibling instead —
71
+ * the surface takes the mouse, the door takes the keyboard, the controls stay their own
72
+ * tab stops (`TableRow` and `LinkedRecordBox` are the references).
66
73
  */
67
74
  export function PressableHighlight(props: PressableHighlightProps) {
68
75
  const {
package/src/table.tsx CHANGED
@@ -8,15 +8,14 @@ import {
8
8
  type ReactNode,
9
9
  type ReactElement,
10
10
  } from "react";
11
- import { StyleSheet, View, Pressable, type ViewStyle } from "react-native";
11
+ import { StyleSheet, View, type ViewStyle } from "react-native";
12
12
  import { Text } from "./text";
13
13
  import { colors } from "./colors";
14
14
  import { PressableRow } from "./pressable_row";
15
+ import { PressDoor } from "./press_door";
15
16
  import { Divider } from "./divider";
16
17
  import { DetailRow } from "./detail_row";
17
18
  import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
18
- import { FOCUS_RING } from "./control_surface";
19
- import { useFocusRing } from "./use_focus_ring";
20
19
  import { COLUMN_GAP, ROW_GUTTER, computeTableFit, type TableFit, type TableFitColumn } from "./table_fit";
21
20
 
22
21
  /**
@@ -182,7 +181,7 @@ export type TableRowProps = {
182
181
 
183
182
  /**
184
183
  * A register row: a full-bleed `PressableRow` (its hover wash spans the whole row,
185
- * incl. the leading/trailing controls) holding the cells, with the door — the
184
+ * incl. the leading/trailing controls) holding the cells, with a `PressDoor` — the
186
185
  * keyboard-accessible `role="button"` open target — as an EMPTY absolutely-
187
186
  * positioned SIBLING beneath them, never their ancestor. A button must not
188
187
  * contain interactive descendants, and cells legitimately carry their own
@@ -209,7 +208,6 @@ export function TableRow(props: TableRowProps) {
209
208
  // the declared columns stay — same tolerance as the width mapping.
210
209
  .filter(({ column }) => ctx.stacked || column == null || ctx.visibleKeys.has(column.key))
211
210
  .map(({ cell, column }) => cloneElement(cell, { _column: column, _stacked: ctx.stacked }));
212
- const { focusVisible, focusProps } = useFocusRing();
213
211
 
214
212
  const body = ctx.stacked ? (
215
213
  <View style={styles.stackedBody}>
@@ -254,18 +252,10 @@ export function TableRow(props: TableRowProps) {
254
252
  return (
255
253
  <PressableRow onPress={onPress} selected={selected} marked={marked} style={styles.row}>
256
254
  {!ctx.stacked && ctx.leading > 0 ? <View style={[styles.slot, { width: ctx.leading }]}>{leading}</View> : null}
257
- {/* An absolutely-positioned sibling paints and hit-tests above in-flow
258
- content, which would swallow every cell control's press; the slots lift
259
- above it via zIndex 1 (z-index applies to flex items), so the door gets
260
- only the keyboard. Negative z on the door instead would escape the row
261
- (no stacking context) and bury the focus ring under the Card background. */}
262
- <Pressable
263
- accessibilityRole="button"
264
- accessibilityLabel={accessibilityLabel}
265
- onPress={onPress}
266
- {...focusProps}
267
- style={[styles.door, focusVisible && { boxShadow: FOCUS_RING }]}
268
- />
255
+ {/* The door hit-tests above in-flow content; the cells/slots lift back above it
256
+ via zIndex 1 (see `PressDoor`), so the door gets only the keyboard. Radius
257
+ matches the register wash. */}
258
+ <PressDoor onPress={onPress} accessibilityLabel={accessibilityLabel} />
269
259
  {ctx.stacked ? (
270
260
  body
271
261
  ) : (
@@ -348,17 +338,6 @@ const styles = StyleSheet.create({
348
338
  flexDirection: "row",
349
339
  paddingHorizontal: ROW_GUTTER,
350
340
  },
351
- // The keyboard door: an empty overlay spanning the row — the tab stop + focus
352
- // ring live here, the cells are its SIBLINGS (see TableRow's doc). Radius
353
- // matches the register wash.
354
- door: {
355
- position: "absolute",
356
- top: 0,
357
- right: 0,
358
- bottom: 0,
359
- left: 0,
360
- borderRadius: 10,
361
- },
362
341
  cells: {
363
342
  flex: 1,
364
343
  flexDirection: "row",