@lotics/ui 27.14.0 → 27.15.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/AGENTS.md +7 -3
- package/MIGRATION.md +20 -0
- package/docs/catalog.md +30 -2
- package/docs/composition.md +16 -1
- package/docs/templates.md +5 -1
- package/examples/tpl_item_list.tsx +12 -2
- package/package.json +6 -1
- package/src/copy_button.tsx +66 -0
- package/src/copy_text.ts +16 -0
- package/src/copy_text.web.ts +17 -0
- package/src/date_stamp.tsx +34 -11
- package/src/locale.tsx +6 -0
- package/src/message_actions.tsx +9 -18
- package/src/press_door.tsx +25 -1
- package/src/press_selection.ts +47 -0
- package/src/pressable_row.tsx +18 -3
- package/src/use_copy_feedback.ts +38 -0
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
|
|
94
|
-
back to the field it belongs to, and one row's action
|
|
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)
|
|
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
|
|
@@ -1030,6 +1056,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1030
1056
|
empty — and a ladder of fields is a form with invisible borders. Reach for `InlineDatePicker`
|
|
1031
1057
|
when a person is expected to ENTER the date, and this when the system already knows it.
|
|
1032
1058
|
Empty renders a thin rule, not a placeholder; read-only (omit `onChange`) renders text alone.
|
|
1059
|
+
`onChange` fires on CLOSE, not on each pick — one commit per visit, and a dismissed panel
|
|
1060
|
+
still writes whatever was picked in it, which is what makes a single tap enough.
|
|
1033
1061
|
Worked screen: `examples/tpl_record.tsx` § Progress.
|
|
1034
1062
|
- **`check_circle`** — `CheckCircle`: the completion ring — `state="none" | "partial" | "done"`,
|
|
1035
1063
|
springing to a filled check when done, distinct from the square checkbox; the
|
package/docs/composition.md
CHANGED
|
@@ -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)).
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "27.15.1",
|
|
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",
|
|
@@ -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
|
+
}
|
package/src/copy_text.ts
ADDED
|
@@ -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/date_stamp.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
2
|
import { Pressable, View } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { Icon } from "./icon";
|
|
@@ -50,6 +50,10 @@ export function DateStamp(props: DateStampProps) {
|
|
|
50
50
|
const [hovered, setHovered] = useState(false);
|
|
51
51
|
const [open, setOpen] = useState(false);
|
|
52
52
|
const [draft, setDraft] = useState<string | null>(value);
|
|
53
|
+
// The panel emits its pick and asks to close in the SAME event, so a commit
|
|
54
|
+
// that read `draft` off render state would still see the value the pick just
|
|
55
|
+
// replaced. This is what the close path reads instead.
|
|
56
|
+
const picked = useRef<string | null>(value);
|
|
53
57
|
|
|
54
58
|
const shown = value ? formatDate(value, { locale: tag }) : "";
|
|
55
59
|
|
|
@@ -57,21 +61,34 @@ export function DateStamp(props: DateStampProps) {
|
|
|
57
61
|
// so a caller can drop this into a column without a conditional.
|
|
58
62
|
if (!onChange) return shown ? <Text size="xs" color="muted">{shown}</Text> : null;
|
|
59
63
|
|
|
64
|
+
// Commit on CLOSE, matching `InlineDatePicker`: a single pick and the panel's
|
|
65
|
+
// Today button both auto-close, so committing on CHANGE instead would fire
|
|
66
|
+
// twice and a dismissed panel would still write.
|
|
67
|
+
//
|
|
68
|
+
// It has to be THIS function rather than `onOpenChange`, and every close has to
|
|
69
|
+
// route through it: a day press closes the panel via `onRequestClose`, which
|
|
70
|
+
// never reaches `onOpenChange`, so a commit living there is skipped by the one
|
|
71
|
+
// path this component exists for — picking a date. The popover shuts, the
|
|
72
|
+
// correction is discarded, and nothing says so. A stamp that silently drops the
|
|
73
|
+
// correction is worse than a read-only one.
|
|
74
|
+
const commit = () => {
|
|
75
|
+
setOpen(false);
|
|
76
|
+
if (picked.current !== value) onChange(picked.current ?? "");
|
|
77
|
+
};
|
|
78
|
+
const reopen = () => {
|
|
79
|
+
picked.current = value;
|
|
80
|
+
setDraft(value);
|
|
81
|
+
setOpen(true);
|
|
82
|
+
};
|
|
83
|
+
|
|
60
84
|
return (
|
|
61
85
|
<Popover
|
|
62
86
|
open={open}
|
|
63
|
-
onOpenChange={(next) => {
|
|
64
|
-
setOpen(next);
|
|
65
|
-
// Commit on CLOSE, matching `InlineDatePicker`: a single pick and the
|
|
66
|
-
// panel's Today button both auto-close through here, so committing on
|
|
67
|
-
// change instead would fire twice and a dismissed panel would still write.
|
|
68
|
-
if (!next && draft !== value) onChange(draft ?? "");
|
|
69
|
-
if (next) setDraft(value);
|
|
70
|
-
}}
|
|
87
|
+
onOpenChange={(next) => { if (next) reopen(); else commit(); }}
|
|
71
88
|
>
|
|
72
89
|
<PopoverTrigger>
|
|
73
90
|
<Pressable
|
|
74
|
-
onPress={
|
|
91
|
+
onPress={reopen}
|
|
75
92
|
onHoverIn={() => setHovered(true)}
|
|
76
93
|
onHoverOut={() => setHovered(false)}
|
|
77
94
|
accessibilityRole="button"
|
|
@@ -114,7 +131,13 @@ export function DateStamp(props: DateStampProps) {
|
|
|
114
131
|
</Pressable>
|
|
115
132
|
</PopoverTrigger>
|
|
116
133
|
<PopoverContent>
|
|
117
|
-
<DatePickerPanel
|
|
134
|
+
<DatePickerPanel
|
|
135
|
+
value={draft}
|
|
136
|
+
onValueChange={(iso) => { picked.current = iso; setDraft(iso); }}
|
|
137
|
+
format="date"
|
|
138
|
+
locale={tag}
|
|
139
|
+
onRequestClose={commit}
|
|
140
|
+
/>
|
|
118
141
|
</PopoverContent>
|
|
119
142
|
</Popover>
|
|
120
143
|
);
|
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);
|
package/src/message_actions.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { useCallback
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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}>
|
package/src/press_door.tsx
CHANGED
|
@@ -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={
|
|
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
|
+
}
|
package/src/pressable_row.tsx
CHANGED
|
@@ -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={
|
|
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
|
-
|
|
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
|
+
}
|