@lotics/ui 27.13.3 → 27.15.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 CHANGED
@@ -90,9 +90,13 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
90
90
  same gesture.
91
91
  → [composition.md](./docs/composition.md), [catalog.md](./docs/catalog.md).
92
92
  - **A verb about a VALUE goes on the value** — an `InlineButton` in the field's `actions`
93
- (Copy, Open), never the row's `trailing`: a control at the page's right edge has to be paired
94
- back to the field it belongs to, and one row's action makes EVERY row reserve the column.
95
- → [composition.md](./docs/composition.md).
93
+ (Copy, Open) or a **`CopyButton`** in a register cell, never the row's `trailing`: a control at
94
+ the page's right edge has to be paired back to the field it belongs to, and one row's action
95
+ makes EVERY row reserve the column. → [composition.md](./docs/composition.md).
96
+ - **A register row's text is SELECTABLE and a drag is not a press** — the row is a button wrapped
97
+ around DATA, so dragging across a name and copying it works, and the release that ended the
98
+ selection does not also open the record. Reach for `CopyButton` only where ONE value is copied
99
+ often enough to earn a control. → [composition.md](./docs/composition.md).
96
100
  - **Files land THREE ways on any surface that holds them** — the section's Add CTA
97
101
  (`pickFiles`), and a drag OR paste, both from ONE **`<FileDropTarget … paste>`** wrapping the
98
102
  region — a Files section, OR a WHOLE surface (a record's section stack, a register page), so a
package/MIGRATION.md CHANGED
@@ -4,6 +4,26 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 27.15.0 — a register row's text is selectable, and a drag no longer opens the record
8
+
9
+ No API change, but two behaviors changed on **every** `PressableRow` / `TableRow` / `PressDoor`
10
+ in every app:
11
+
12
+ - The row surface no longer sets `userSelect: "none"`, so **text inside a row can be selected**.
13
+ Nothing moves at rest — only what a drag does. Furniture that must not smear (`Badge`,
14
+ `Avatar`, `Button`) already opted out for itself, which is why the blanket only ever cost the
15
+ text it had no business covering.
16
+ - A press that ENDED a text selection **does not fire `onPress`**. Previously a drag-select
17
+ released into `navigate(...)` and discarded the selection, which is what made a register's own
18
+ data uncopyable. A caret and a whitespace-only range still press normally.
19
+
20
+ **A browser test that opens a row by dragging across it must click instead** — a real drag now
21
+ reads as a selection, not a press. Programmatic `fireEvent.click` / `press()` are unaffected
22
+ (they leave no selection).
23
+
24
+ `LoticsLocale` also gained a **`copyButton: { copy, copied }`** slice for the new `CopyButton`.
25
+ The shipped `en` / `vi` packs carry it; a HAND-AUTHORED pack must add it or it will not typecheck.
26
+
7
27
  ## 27.13.3 — a stacked `Table` row's identity cell no longer renders its column label
8
28
 
9
29
  No API change, but the rendered output changed on NARROW containers only (below the
package/docs/catalog.md CHANGED
@@ -41,7 +41,9 @@ fill + border + shadow, for a button sitting ON imagery — a tile's remove ✕,
41
41
  retry); needs `accessibilityLabel`/`tooltip`), `TextLink` (underlined text that's OPTIONALLY
42
42
  an action (`onPress`) or a link (`href` — a real web anchor) — or, with neither, plain
43
43
  underlined text you drop in your own pressable like a table cell; colour via `color`; the
44
- go-to for Clear / Select all / inline links), `Chip` (dismissible facet chip). A button is
44
+ go-to for Clear / Select all / inline links), `Chip` (dismissible facet chip), `CopyButton`
45
+ (put a value on the clipboard — rides the value it copies, confirms only a real write).
46
+ A button is
45
47
  never a raw `Pressable`. For a link OUT (a URL / record / document) use `Link` — fixed
46
48
  underline+blue + `role="link"`, the destination signal (`onPress` only — the consumer wires
47
49
  the opener, e.g. the app SDK's `openExternal`); `TextLink` is the neutral,
@@ -492,6 +494,21 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
492
494
  heading row) where no text edge exists to betray, or give it the filled tier.
493
495
  - **`icon_button`** — `IconButton`: the icon-only circular action (see
494
496
  [Actions](#actions)).
497
+ - **`copy_button`** — `CopyButton`: THE copy control — `value` onto the clipboard, glyph
498
+ flips to a check and the tooltip to the pack's "Copied" for ~2s. The confirmation follows
499
+ a REAL write: an unreachable clipboard throws and the button stays at rest rather than
500
+ claiming a copy nothing can paste. `label` names WHAT is copied ("Copy phone number") and
501
+ is the accessible name — give it on any row carrying more than one value. `size` `sm`
502
+ (default, beside body/sm text) / `md` (toolbar). **Render it unconditionally**: an empty
503
+ `value` disables it, because a control that appears once a value arrives shifts a
504
+ register's columns as its rows load. Nested in a `PressableRow`/`TableRow` it claims its
505
+ own press. It is a verb about a VALUE, so it rides the value — never a trailing column
506
+ ([composition.md § A VERB GOES WHERE ITS EFFECT LANDS](./composition.md)).
507
+ - **`copy_text`** — `copyText(value)`: the bare clipboard write under `CopyButton`, for a
508
+ control that owns its own chrome (a menu item, a `MessageActions` bar taking an action
509
+ rather than a value). Throws when the clipboard is unreachable — never resolve a write
510
+ that did not happen, or the caller confirms a lie. Web-only (native has no
511
+ `navigator.clipboard`).
495
512
  - **`back_button`** — `BackButton`: the one go-back control — don't hand-roll it (an
496
513
  `arrow-left` `MenuButton` reads as another nav ITEM, not an exit). `onPress` +
497
514
  `accessibilityLabel` (defaults to `label`, else the locale's `nav.back` — "Back" /
@@ -530,7 +547,16 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
530
547
  - **`floating_action_bar`** — `FloatingActionBar`: the floating bulk-select action bar.
531
548
  - **`pressable_row`** — `PressableRow`: THE register row — full-width hover/open wash,
532
549
  `selected` (the open record) vs `marked` (ticked in bulk-select), forwards `ref` for
533
- popover anchoring.
550
+ popover anchoring. **Its text is SELECTABLE**: a register row is a button wrapped around
551
+ data, so dragging across a name and copying it works, and a release that selected text
552
+ does not open the record (see `press_selection`). Furniture that must not smear over —
553
+ `Badge`, `Avatar`, `Button` — opts out for itself.
554
+ The guard behind it is internal (`press_selection`): only a real, non-empty selection
555
+ suppresses — a caret and a whitespace-only range (what a stray click can leave) both read
556
+ as "no selection", because a swallowed click would leave the row impossible to open.
557
+ `PressDoor` consults it only on a POINTER gesture, since it is the KEYBOARD target and a
558
+ selection survives a Tab. Off the web there is no selection to read, so a native press is
559
+ never suppressed.
534
560
  - **`press_door`** — `PressDoor`: THE keyboard door — an EMPTY absolutely-positioned
535
561
  `Pressable` sibling that carries the `role="button"` tab stop, the (REQUIRED)
536
562
  `accessibilityLabel`, and the focus ring for a pressable surface whose content holds its
@@ -355,6 +355,21 @@ hover **OR focus-within**, keeping the action in the DOM + tab order and gating
355
355
  so it's still keyboard-reachable. Do that locally — there is deliberately no shared hover-reveal
356
356
  primitive (one existed and made the keyboard-inaccessible anti-pattern easy; it was removed).
357
357
 
358
+ **A row is a button wrapped around DATA, so its text stays SELECTABLE and a drag is not a press.**
359
+ The surface carries no blanket `userSelect: "none"` — that made a customer's own name and number
360
+ undraggable, which is the one way anybody gets a value out of a table, and it bought nothing the
361
+ furniture wasn't already buying for itself (`Badge`, `Avatar`, `Button` each opt out). Both press
362
+ surfaces consult `pressSelectedText()` instead, so a release that ENDED a selection doesn't also
363
+ open the record and throw that selection away. Only a real, non-empty selection suppresses: a
364
+ swallowed ordinary click would leave the row impossible to open, which is a worse bug than the one
365
+ this fixes.
366
+
367
+ Selection covers every value at once, so reach for **`CopyButton`** only where one value is copied
368
+ often enough to earn a control (a phone number a rep dials, a reference quoted into a message) —
369
+ and put it ON that value inside its cell, never in the row's trailing column, which has nothing to
370
+ name on a row carrying two of them. It is an action, so the rule above applies: render it
371
+ unconditionally and let an empty `value` disable it, rather than revealing it on hover.
372
+
358
373
  ## Master-detail = list + workspace Drawer with sequencing
359
374
 
360
375
  Pressing a PRIMARY entity row opens the record workspace in a `Drawer` with `onPrev`/`onNext`/
@@ -592,7 +607,7 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
592
607
 
593
608
  | the act changes… | it lives… |
594
609
  |---|---|
595
- | a VALUE (copy it, open what it points at) | ON the value — `InlineButton` in the editor's `actions` |
610
+ | a VALUE (copy it, open what it points at) | ON the value — `InlineButton` in the editor's `actions`, or `CopyButton` in a register cell |
596
611
  | a ROW (remove it, move it) | ON the row — an `ActionMenu`, or the row's own controls |
597
612
  | the LIST (one more of them) | its section's HEADING row, right edge, `primary` — see [the add-placement law](#the-add-placement-law--a-sections-add-rides-its-heading-row-right-edge) |
598
613
  | the RECORD (issue, print, hand off) | its own action row at the section's END |
package/docs/templates.md CHANGED
@@ -208,7 +208,11 @@ screens — register, per-row action, gated selection, and act-on-many in one. T
208
208
  value beneath as `size="xs" color="muted"`, `gap: 2` — and a category / type / attribute goes
209
209
  on THAT line, never a second chip beside the name (which reads as its peer, or louder when
210
210
  colored, inverting what the row is scanned by; → [composition grammar §`Badge` is for STATUS
211
- only](./composition.md)). A per-row action `Button` (here Print) is the row's primary action in
211
+ only](./composition.md)). Every value in a row is SELECTABLE, so a reader copies any of them
212
+ without chrome; the supporting phone additionally carries a **`CopyButton`** as the worked
213
+ example of a verb riding its value — the one value copied often enough to earn a control, and
214
+ in its own cell rather than the trailing gutter, which two values in a cell give nothing to
215
+ name. A per-row action `Button` (here Print) is the row's primary action in
212
216
  the trailing column, `⋯` its overflow. A select-all band, footer totals + `Pagination`, and a
213
217
  `FloatingActionBar` carrying the bulk action while rows are ticked.
214
218
  - **A row press opens the PRODUCTION workspace `Drawer`** — which RENDERS
@@ -17,6 +17,7 @@ import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
17
17
  import { FormField } from "@lotics/ui/form_field";
18
18
  import { Icon, type IconName } from "@lotics/ui/icon";
19
19
  import { IconButton } from "@lotics/ui/icon_button";
20
+ import { CopyButton } from "@lotics/ui/copy_button";
20
21
  import { ListItem } from "@lotics/ui/list_item";
21
22
  import { TplRecord } from "./tpl_record";
22
23
  import { Screen, ScreenRouter, useScreenRouter } from "@lotics/ui/screen_router";
@@ -204,9 +205,18 @@ function HoSoRow({ hs, daThu, selected, marked, selectable, onToggle, onPress }:
204
205
  <Text size="sm" weight="semibold" tabular>{hs.ma}</Text>
205
206
  </TableCell>
206
207
  <TableCell>
207
- <View style={{ gap: 2 }}>
208
+ <View style={{ gap: 2, alignItems: "flex-start" }}>
208
209
  <Text size="sm" weight="medium" numberOfLines={1}>{hs.khach}</Text>
209
- <Text size="xs" color="muted" tabular numberOfLines={1}>{hs.dienThoai}</Text>
210
+ {/* Every value on this row is selectable — that is what a register owes
211
+ its reader, and it costs no chrome. The phone gets a CONTROL on top
212
+ because it is the one value copied often enough to earn one (a desk
213
+ pastes it into a message, a rep dials it). The verb rides the value
214
+ inside its own cell: the trailing gutter belongs to the ⋯, and with
215
+ two values in this cell it would have nothing to name. */}
216
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 2 }}>
217
+ <Text size="xs" color="muted" tabular numberOfLines={1}>{hs.dienThoai}</Text>
218
+ <CopyButton value={hs.dienThoai} label="Copy phone number" />
219
+ </View>
210
220
  </View>
211
221
  </TableCell>
212
222
  <TableCell>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "27.13.3",
3
+ "version": "27.15.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -93,6 +93,11 @@
93
93
  "./agent_progress": "./src/agent_progress.tsx",
94
94
  "./approval_prompt": "./src/approval_prompt.tsx",
95
95
  "./message_actions": "./src/message_actions.tsx",
96
+ "./copy_button": "./src/copy_button.tsx",
97
+ "./copy_text": {
98
+ "react-native": "./src/copy_text.ts",
99
+ "default": "./src/copy_text.web.ts"
100
+ },
96
101
  "./follow_scroll": "./src/follow_scroll.tsx",
97
102
  "./markdown": {
98
103
  "react-native": "./src/markdown.tsx",
@@ -271,7 +276,7 @@
271
276
  "license": "SEE LICENSE IN LICENSE.md",
272
277
  "dependencies": {
273
278
  "@lotics/docx": "^0.3.0",
274
- "@lotics/xlsx": "^0.1.0",
279
+ "@lotics/xlsx": "^0.2.0",
275
280
  "ai": "^7.0.30",
276
281
  "mdast-util-from-markdown": "^2.0.3",
277
282
  "mdast-util-gfm-footnote": "^2.1.0",
@@ -0,0 +1,66 @@
1
+ import { useCallback } from "react";
2
+ import { IconButton } from "./icon_button";
3
+ import { useLoticsLocale } from "./locale";
4
+ import { copyText } from "./copy_text";
5
+ import { useCopyFeedback } from "./use_copy_feedback";
6
+
7
+ export interface CopyButtonProps {
8
+ /** The text placed on the clipboard. Empty disables the button (see below). */
9
+ value: string;
10
+ /** Names WHAT is copied — "Copy phone number", "Sao chép số điện thoại". This
11
+ * is the accessible name, so give it whenever the glyph alone leaves the
12
+ * target ambiguous, which is any row carrying more than one value. Defaults to
13
+ * the pack's bare verb. */
14
+ label?: string;
15
+ /** `sm` (the default, 24px) sits beside body/sm text — a value in a register
16
+ * cell or a detail row. `md` (28px) for a toolbar. */
17
+ size?: "sm" | "md";
18
+ disabled?: boolean;
19
+ testID?: string;
20
+ }
21
+
22
+ /**
23
+ * THE copy control — a verb about a VALUE, so it rides the value it copies
24
+ * rather than a trailing column (see `docs/composition.md` § A VERB GOES WHERE
25
+ * ITS EFFECT LANDS: a reserved column is paid by every row for the benefit of
26
+ * one, and a row carrying two values gives that column nothing to name).
27
+ *
28
+ * It owns the whole gesture: the clipboard write, and the confirmation — the
29
+ * glyph flips to a check and the tooltip to the pack's "Copied" for ~2s. The
30
+ * confirmation follows a REAL write; an unreachable clipboard throws and the
31
+ * button stays at rest rather than claiming a copy the user cannot paste.
32
+ *
33
+ * **Render it unconditionally and let it disable itself.** An empty `value`
34
+ * disables the button, which is the same rule an `Inline*` editor's `actions`
35
+ * slot follows: a control that appears once a value is non-empty changes the
36
+ * cell's width as the value arrives, and a register whose columns shift as rows
37
+ * load is worse than a dimmed glyph.
38
+ *
39
+ * Nested inside a `PressableRow`/`TableRow` it claims its own press — the row
40
+ * does not also open.
41
+ */
42
+ export function CopyButton(props: CopyButtonProps) {
43
+ const { value, label, size = "sm", disabled, testID } = props;
44
+ const labels = useLoticsLocale().copyButton;
45
+ const { copied, run } = useCopyFeedback();
46
+
47
+ const handlePress = useCallback(() => {
48
+ void run(() => copyText(value));
49
+ }, [run, value]);
50
+
51
+ const name = label ?? labels.copy;
52
+ return (
53
+ <IconButton
54
+ testID={testID}
55
+ size={size}
56
+ icon={copied ? "check" : "copy"}
57
+ // The announced name stays PUT while the tooltip flips: a name that changes
58
+ // under a screen-reader user mid-press reads as a different control, and the
59
+ // check glyph is the confirmation for everyone who can see it.
60
+ accessibilityLabel={name}
61
+ tooltip={copied ? labels.copied : name}
62
+ disabled={disabled || value === ""}
63
+ onPress={handlePress}
64
+ />
65
+ );
66
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Native stand-in for the web clipboard write.
3
+ *
4
+ * React Native has no `navigator.clipboard` — the OS clipboard is a separate
5
+ * module (`@react-native-clipboard/clipboard`) the kit does not depend on. It
6
+ * throws for the same reason the web variant throws on an unreachable clipboard:
7
+ * a caller confirms the write in the UI, so failing silently would confirm a copy
8
+ * that never happened. The file exists so a shared screen can import `copyText`
9
+ * unconditionally without `navigator` ever reaching Metro.
10
+ *
11
+ * Web targets resolve `copy_text.web.ts` instead (Metro's `.web` extension
12
+ * resolution; the package's `react-native` export condition).
13
+ */
14
+ export async function copyText(_value: string): Promise<void> {
15
+ throw new Error("copyText is web-only: React Native has no navigator.clipboard.");
16
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Web variant: place `value` on the system clipboard.
3
+ *
4
+ * Isolated into its own module so the native bundle (which resolves
5
+ * `copy_text.ts`) never references `navigator`.
6
+ *
7
+ * It THROWS when the clipboard is unreachable — a non-secure origin, a browser
8
+ * without the async clipboard, a denied permission — rather than resolving. Every
9
+ * caller confirms the write in the UI, and a confirmation for a copy that never
10
+ * happened sends the user off to paste nothing.
11
+ */
12
+ export async function copyText(value: string): Promise<void> {
13
+ if (typeof navigator === "undefined" || !navigator.clipboard) {
14
+ throw new Error("Clipboard is not available in this environment.");
15
+ }
16
+ await navigator.clipboard.writeText(value);
17
+ }
package/src/locale.tsx CHANGED
@@ -209,6 +209,10 @@ export interface LoticsLocale {
209
209
  /** `MessageActions`: the copy tooltip + its flipped "copied" confirmation, the
210
210
  * regenerate and edit tooltips, and the branch pager's prev/next names. */
211
211
  messageActions: { copy: string; copied: string; regenerate: string; edit: string; previousVersion: string; nextVersion: string };
212
+ /** `CopyButton`: the bare verb and its flipped confirmation. WHAT is being
213
+ * copied stays a per-instance `label` ("Copy phone number") — that names a
214
+ * value only the call site knows, the same split `referenceField.open` draws. */
215
+ copyButton: { copy: string; copied: string };
212
216
  }
213
217
 
214
218
  /** The platform default — English. Every component's hardcoded default lives
@@ -370,6 +374,7 @@ export const en: LoticsLocale = {
370
374
  },
371
375
  approvalPrompt: { message: "The assistant wants to perform an action that needs your approval.", approve: "Approve", deny: "Deny" },
372
376
  messageActions: { copy: "Copy", copied: "Copied", regenerate: "Regenerate", edit: "Edit", previousVersion: "Previous version", nextVersion: "Next version" },
377
+ copyButton: { copy: "Copy", copied: "Copied" },
373
378
  };
374
379
 
375
380
  /** Vietnamese. Maintained once here so every app (and the frontend) shares one
@@ -528,6 +533,7 @@ export const vi: LoticsLocale = {
528
533
  },
529
534
  approvalPrompt: { message: "Trợ lý muốn thực hiện thao tác cần bạn duyệt.", approve: "Cho phép", deny: "Từ chối" },
530
535
  messageActions: { copy: "Sao chép", copied: "Đã sao chép", regenerate: "Tạo lại", edit: "Chỉnh sửa", previousVersion: "Phiên bản trước", nextVersion: "Phiên bản sau" },
536
+ copyButton: { copy: "Sao chép", copied: "Đã sao chép" },
531
537
  };
532
538
 
533
539
  const LoticsLocaleContext = createContext<LoticsLocale>(en);
@@ -1,8 +1,9 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback } from "react";
2
2
  import { StyleSheet, View } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { IconButton } from "./icon_button";
5
5
  import { useLoticsLocale } from "./locale";
6
+ import { useCopyFeedback } from "./use_copy_feedback";
6
7
 
7
8
  /** The branch/version pager slot — the previous/next controls over a message's
8
9
  * sibling versions. Rendered only when `total > 1`. */
@@ -41,24 +42,14 @@ export interface MessageActionsProps {
41
42
  export function MessageActions(props: MessageActionsProps) {
42
43
  const { onCopy, onRegenerate, onEdit, branch } = props;
43
44
  const labels = useLoticsLocale().messageActions;
44
- const [copied, setCopied] = useState(false);
45
- const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
45
+ // The flip + its unmount-safe timer are `CopyButton`'s too — see `useCopyFeedback`.
46
+ // The bar keeps its own control because it takes an ACTION, not a value: the
47
+ // message text it would copy is assembled from parts the bar cannot see.
48
+ const { copied, run } = useCopyFeedback();
46
49
 
47
- // The copied flag flips back after a beat; the timer is cleared on unmount so
48
- // a bar that disappears (last message loses its actions) never sets state on
49
- // an unmounted tree.
50
- useEffect(() => {
51
- return () => {
52
- if (timer.current) clearTimeout(timer.current);
53
- };
54
- }, []);
55
-
56
- const handleCopy = useCallback(async () => {
57
- await onCopy?.();
58
- setCopied(true);
59
- if (timer.current) clearTimeout(timer.current);
60
- timer.current = setTimeout(() => setCopied(false), 2000);
61
- }, [onCopy]);
50
+ const handleCopy = useCallback(() => {
51
+ void run(() => onCopy?.());
52
+ }, [run, onCopy]);
62
53
 
63
54
  return (
64
55
  <View style={styles.bar}>
@@ -1,6 +1,8 @@
1
+ import { useCallback, useRef } from "react";
1
2
  import { Pressable, StyleSheet } from "react-native";
2
3
  import { FOCUS_RING } from "./control_surface";
3
4
  import { useFocusRing } from "./use_focus_ring";
5
+ import { pressSelectedText } from "./press_selection";
4
6
 
5
7
  export interface PressDoorProps {
6
8
  /** Opens the thing the surface stands for — the same handler the surface presses. */
@@ -53,6 +55,27 @@ export interface PressDoorProps {
53
55
  */
54
56
  export function PressDoor({ onPress, accessibilityLabel, expanded, radius = 10 }: PressDoorProps) {
55
57
  const { focusVisible, focusProps } = useFocusRing();
58
+
59
+ // The door spans the row beneath its cells, so a drag BETWEEN cells — from the
60
+ // blank left of a name to the blank right of it — selects that name with both
61
+ // ends of the gesture on the door. Same guard as `PressableRow`: it selected
62
+ // text, so it was not a press.
63
+ //
64
+ // Scoped to POINTER gestures, which `PressableRow` doesn't need (it is
65
+ // `tabIndex={-1}`, so the mouse is the only thing that presses it) and the door
66
+ // does: the door IS the keyboard target, and a selection left anywhere on the
67
+ // page survives a Tab. Without this, selecting a name and then keyboard-opening
68
+ // any row would silently do nothing.
69
+ const fromPointer = useRef(false);
70
+ const mouseProps = { onMouseDown: () => (fromPointer.current = true) } as object;
71
+
72
+ const handlePress = useCallback(() => {
73
+ const pointer = fromPointer.current;
74
+ fromPointer.current = false;
75
+ if (pointer && pressSelectedText()) return;
76
+ onPress();
77
+ }, [onPress]);
78
+
56
79
  return (
57
80
  <Pressable
58
81
  accessibilityRole="button"
@@ -61,7 +84,8 @@ export function PressDoor({ onPress, accessibilityLabel, expanded, radius = 10 }
61
84
  // the latter silently. Set only when the door TOGGLES something: on a door
62
85
  // that navigates it would promise a disclosure the press does not perform.
63
86
  aria-expanded={expanded}
64
- onPress={onPress}
87
+ onPress={handlePress}
88
+ {...mouseProps}
65
89
  {...focusProps}
66
90
  style={[styles.door, { borderRadius: radius }, focusVisible && { boxShadow: FOCUS_RING }]}
67
91
  />
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Whether a press must be swallowed because the same gesture SELECTED text.
3
+ *
4
+ * A register row is a button wrapped around DATA. Dragging across a customer's
5
+ * name ends in a click on the row, so without this guard the release navigates
6
+ * to the record and discards the selection — the row's own press is what makes
7
+ * its content uncopyable. Both of a row's press surfaces consult this:
8
+ * `PressableRow` (the mouse) and `PressDoor` (the blank space beneath the cells).
9
+ *
10
+ * Deliberately narrow. A plain click that got swallowed would leave the row
11
+ * impossible to open, which is far worse than losing a selection, so only a
12
+ * real, non-empty selection suppresses — and only a POINTER gesture consults it
13
+ * on the door, which is the KEYBOARD target: a selection left anywhere on the
14
+ * page survives a Tab, so a door that checked it on Enter would refuse to open
15
+ * any row until the user cleared a selection they may not even see.
16
+ */
17
+
18
+ /** The shape read off `window.getSelection()`. Structural, so this module stays
19
+ * DOM-free at the type level and is testable without a document. */
20
+ export interface TextSelection {
21
+ isCollapsed: boolean;
22
+ toString(): string;
23
+ }
24
+
25
+ /**
26
+ * True when `selection` holds text the user meant to keep. A COLLAPSED selection
27
+ * is a caret, not a selection. Whitespace-only is what an ordinary click can
28
+ * leave behind when its anchor and focus land in different inline boxes — a row
29
+ * that refused to open after a stray click would be a worse bug than the one
30
+ * this fixes, so that reads as "nothing selected" too.
31
+ */
32
+ export function isTextSelected(selection: TextSelection | null | undefined): boolean {
33
+ if (!selection || selection.isCollapsed) return false;
34
+ return selection.toString().trim() !== "";
35
+ }
36
+
37
+ /** The live selection, or null off the web — React Native has no `getSelection`,
38
+ * so a native press is never suppressed. */
39
+ export function activeTextSelection(): TextSelection | null {
40
+ if (typeof window === "undefined" || typeof window.getSelection !== "function") return null;
41
+ return window.getSelection();
42
+ }
43
+
44
+ /** The guard itself: did the gesture that just ended select text? */
45
+ export function pressSelectedText(): boolean {
46
+ return isTextSelected(activeTextSelection());
47
+ }
@@ -1,6 +1,7 @@
1
- import { ReactNode, Ref, useState } from "react";
1
+ import { ReactNode, Ref, useCallback, useState } from "react";
2
2
  import { Pressable, StyleProp, StyleSheet, View, ViewStyle } from "react-native";
3
3
  import { colors } from "./colors";
4
+ import { pressSelectedText } from "./press_selection";
4
5
 
5
6
  export interface PressableRowProps {
6
7
  onPress: () => void;
@@ -56,6 +57,14 @@ export function PressableRow(props: PressableRowProps) {
56
57
  const { onPress, selected = false, marked = false, variant = "register", children, style, ref } = props;
57
58
  const [hovered, setHovered] = useState(false);
58
59
 
60
+ // A drag that selected text ends in a click on this row. Opening the record
61
+ // here would navigate away and take the selection with it, which is what made
62
+ // a register's own data uncopyable — see `press_selection`.
63
+ const handlePress = useCallback(() => {
64
+ if (pressSelectedText()) return;
65
+ onPress();
66
+ }, [onPress]);
67
+
59
68
  // RN's types don't declare mouse handlers; react-native-web forwards them
60
69
  // to the DOM element (same boundary cast PressableHighlight uses).
61
70
  const mouseProps = {
@@ -66,7 +75,7 @@ export function PressableRow(props: PressableRowProps) {
66
75
  return (
67
76
  <Pressable
68
77
  ref={ref}
69
- onPress={onPress}
78
+ onPress={handlePress}
70
79
  // Non-focusable surface: tabIndex, NOT focusable — RN-Web's Pressable
71
80
  // ignores `focusable` and writes its own tabIndex (the keyboard target is
72
81
  // the nested "Open …" button, not this row).
@@ -91,7 +100,13 @@ const styles = StyleSheet.create({
91
100
  flexDirection: "row",
92
101
  alignItems: "center",
93
102
  gap: 12,
94
- ...({ cursor: "pointer", userSelect: "none", transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
103
+ // No `userSelect: "none"` here. A register row is a button wrapped around
104
+ // DATA, and a blanket none on the surface made the customer's own name and
105
+ // number unselectable — the one way anybody gets a value out of a table. The
106
+ // furniture that must not be dragged over already opts out for itself
107
+ // (`Badge`, `Avatar`, `Button` each set none), so the blanket only ever cost
108
+ // the text it had no business covering.
109
+ ...({ cursor: "pointer", transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
95
110
  },
96
111
  // THE register row — FULL-WIDTH rounded highlight. The hover/selected wash
97
112
  // spans the entire row (so the pressable area === the row, never narrower than
@@ -0,0 +1,38 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ /**
4
+ * The copied-feedback flip every copy control shares: after a write RESOLVES the
5
+ * control reports copied for a beat, then returns to rest.
6
+ *
7
+ * Two rules live here rather than in each control. The flip follows a REAL
8
+ * write — `run` awaits, and a rejected write propagates without ever setting the
9
+ * flag, so a control never confirms a copy that did not happen. And the timer is
10
+ * cleared on unmount, so a control that disappears mid-beat (a row scrolled out
11
+ * of a register, a message bar that loses its actions) never sets state on an
12
+ * unmounted tree.
13
+ */
14
+ export function useCopyFeedback(resetMs = 2000): {
15
+ copied: boolean;
16
+ run: (write: () => void | Promise<void>) => Promise<void>;
17
+ } {
18
+ const [copied, setCopied] = useState(false);
19
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
20
+
21
+ useEffect(() => {
22
+ return () => {
23
+ if (timer.current) clearTimeout(timer.current);
24
+ };
25
+ }, []);
26
+
27
+ const run = useCallback(
28
+ async (write: () => void | Promise<void>) => {
29
+ await write();
30
+ setCopied(true);
31
+ if (timer.current) clearTimeout(timer.current);
32
+ timer.current = setTimeout(() => setCopied(false), resetMs);
33
+ },
34
+ [resetMs],
35
+ );
36
+
37
+ return { copied, run };
38
+ }