@lotics/ui 46.2.0 → 46.8.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 +38 -1
- package/MIGRATION.md +87 -0
- package/docs/ai_patterns.md +11 -0
- package/docs/catalog.md +217 -21
- package/docs/composition.md +74 -6
- package/docs/data_entry.md +56 -2
- package/docs/reviewing.md +34 -0
- package/docs/templates.md +93 -30
- package/docs/testing.md +6 -0
- package/examples/tpl_board.tsx +257 -0
- package/examples/tpl_money.tsx +1027 -0
- package/package.json +261 -258
- package/src/accordion.tsx +7 -1
- package/src/alert.css +0 -1
- package/src/alert.tsx +8 -0
- package/src/axis_label_indices.ts +84 -0
- package/src/bar_chart.tsx +137 -16
- package/src/board.tsx +611 -0
- package/src/card.tsx +7 -1
- package/src/charge_lines.tsx +373 -0
- package/src/chip_group.tsx +57 -1
- package/src/dialog.tsx +46 -24
- package/src/drawer.tsx +21 -2
- package/src/file_gallery_modal.tsx +3 -0
- package/src/file_row.tsx +98 -5
- package/src/icon.tsx +6 -0
- package/src/inline_edit.tsx +54 -10
- package/src/inline_number_input.tsx +5 -1
- package/src/inline_text_input.tsx +1 -1
- package/src/line_chart.tsx +2 -2
- package/src/locale.tsx +26 -1
- package/src/matrix.tsx +23 -8
- package/src/modal.tsx +23 -3
- package/src/overlay_layer.ts +65 -0
- package/src/page_content.tsx +8 -22
- package/src/page_header.tsx +60 -11
- package/src/popover.tsx +29 -5
- package/src/reference_field.tsx +36 -13
- package/src/skip_link.tsx +2 -1
- package/src/stacked_bar_chart.tsx +31 -1
- package/src/table.tsx +6 -1
- package/src/tabs.tsx +1 -1
- package/src/text.tsx +21 -0
- package/src/tooltip.tsx +2 -1
- package/src/use_change_set.ts +66 -17
- package/src/use_scroll_seam.ts +79 -0
- package/examples/tpl_report.tsx +0 -410
- package/examples/tpl_statements.tsx +0 -221
- package/src/line_chart_labels.ts +0 -32
package/src/board.tsx
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { Platform, ScrollView, StyleSheet, View } from "react-native";
|
|
12
|
+
import { colors, tint } from "./colors";
|
|
13
|
+
import { CONTROL_RADIUS, MIN_CONTROL_WIDTH } from "./control_surface";
|
|
14
|
+
import { EmptyState } from "./empty_state";
|
|
15
|
+
import { Icon } from "./icon";
|
|
16
|
+
import { IconButton } from "./icon_button";
|
|
17
|
+
import { useLoticsLocale } from "./locale";
|
|
18
|
+
import { MenuButton } from "./menu_button";
|
|
19
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
20
|
+
import { PressableRow } from "./pressable_row";
|
|
21
|
+
import { PressDoor } from "./press_door";
|
|
22
|
+
import { SPACE } from "./spacing";
|
|
23
|
+
import { Text } from "./text";
|
|
24
|
+
import { usePointerDrag } from "./use_pointer_drag";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* THE COLUMN WIDTH, owned by the kit.
|
|
28
|
+
*
|
|
29
|
+
* A board's columns are all the same width — they hold the same kind of card, and
|
|
30
|
+
* a column that sized to its content would make "which pile is this" a question of
|
|
31
|
+
* width rather than of heading. So the number belongs to ONE place: every app that
|
|
32
|
+
* picked its own landed on a hand-typed 280 inside a `View`, which is the defect
|
|
33
|
+
* this component exists to remove.
|
|
34
|
+
*
|
|
35
|
+
* 280 is `MIN_CONTROL_WIDTH` (160) plus room for a title that wraps twice and a
|
|
36
|
+
* footer row of a member chip beside a date — the narrowest a card stays READABLE.
|
|
37
|
+
* It also leaves the next column visibly peeking at 375, which is the only thing
|
|
38
|
+
* telling a phone reader the board continues sideways.
|
|
39
|
+
*/
|
|
40
|
+
/** A card is a container, so it sits on the container rung of the radius ladder
|
|
41
|
+
* — the same 16 a `Card` takes. Kept here rather than imported so the two
|
|
42
|
+
* cannot drift apart silently; if the ladder moves, both move. */
|
|
43
|
+
const CARD_RADIUS = 16;
|
|
44
|
+
|
|
45
|
+
/** Half the difference between an `sm` IconButton's box (24) and its glyph (14).
|
|
46
|
+
* It exists so a control cluster can be pulled out by exactly the amount that
|
|
47
|
+
* puts its INK on the same edge as the text above it. */
|
|
48
|
+
const CONTROL_INK_INSET = 5;
|
|
49
|
+
|
|
50
|
+
export const BOARD_COLUMN_WIDTH = 280;
|
|
51
|
+
|
|
52
|
+
/** One place a card may be sent — a value of the field the board's columns are. */
|
|
53
|
+
export interface BoardMove {
|
|
54
|
+
/** The destination column's `columnKey`. */
|
|
55
|
+
key: string;
|
|
56
|
+
/** What the destination is CALLED, in the reader's language. This is the whole
|
|
57
|
+
* point of the control: a move names where the card lands. */
|
|
58
|
+
label: string;
|
|
59
|
+
disabled?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface BoardCardEntry {
|
|
63
|
+
moves: BoardMove[];
|
|
64
|
+
onMove: (columnKey: string) => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface BoardCtx {
|
|
68
|
+
/** A column publishes its DOM box so a pointer drop can be hit-tested against it. */
|
|
69
|
+
registerColumn: (key: string, node: unknown) => void;
|
|
70
|
+
/** …and its heading, so a card's move MENU can name each destination in the
|
|
71
|
+
* destination's own vocabulary — the same node the pile is titled with,
|
|
72
|
+
* rather than a lookalike a caller has to keep in step. */
|
|
73
|
+
columnHeadings: Record<string, ReactNode>;
|
|
74
|
+
publishHeading: (key: string, heading: ReactNode) => void;
|
|
75
|
+
/** A card publishes where it may LEGALLY go, so the pointer path and the keyboard
|
|
76
|
+
* path resolve the same destinations and can never disagree. */
|
|
77
|
+
registerCard: (id: string, entry: BoardCardEntry | null) => void;
|
|
78
|
+
bindDrag: (id: string) => (node: unknown) => void;
|
|
79
|
+
draggingId: string | null;
|
|
80
|
+
dragDelta: { dx: number; dy: number } | null;
|
|
81
|
+
overColumn: string | null;
|
|
82
|
+
/** True for the tick after a drop, so the click that ends the drag does not
|
|
83
|
+
* also open the record. */
|
|
84
|
+
justDragged: { current: boolean };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const BoardContext = createContext<BoardCtx | null>(null);
|
|
88
|
+
|
|
89
|
+
/** A column hands its own heading down so a card's move control can WEAR it.
|
|
90
|
+
* Passing the node rather than asking the caller to repeat it is what makes the
|
|
91
|
+
* two identical by construction instead of by discipline. */
|
|
92
|
+
const BoardColumnContext = createContext<ReactNode>(null);
|
|
93
|
+
|
|
94
|
+
export interface BoardProps {
|
|
95
|
+
/** `BoardColumn`s, in the order the field's values are read. */
|
|
96
|
+
children: ReactNode;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* THE COLUMN BOARD — records as cards, columns as the values of ONE field, and the
|
|
101
|
+
* act the shape exists for: MOVING a card from one column to another.
|
|
102
|
+
*
|
|
103
|
+
* Reach for it when the reader's job is to advance work between named places and the
|
|
104
|
+
* pile sizes are themselves the report. When the reader's job is to compare a row's
|
|
105
|
+
* measures, that is `DataGrid`; when it is to browse thousands, `Table`. Grouped rows
|
|
106
|
+
* answer "what is in each pile" and stop there — a board answers it AND hands over the
|
|
107
|
+
* move, which is why a view labelled Kanban over bands of rows is the wrong form
|
|
108
|
+
* rather than a smaller version of the right one.
|
|
109
|
+
*
|
|
110
|
+
* **The move is keyboard-first.** Every card carries a menu naming its destinations
|
|
111
|
+
* (`moves`), so the act is reachable by Tab and operable by a screen reader. Pointer
|
|
112
|
+
* drag is layered on top of that same list — the grip drags, the drop hit-tests the
|
|
113
|
+
* column under the pointer, and the drop is refused unless the destination is one the
|
|
114
|
+
* card already declared. On native, where there are no pointer events, the menu is
|
|
115
|
+
* simply the only path and nothing is lost.
|
|
116
|
+
*
|
|
117
|
+
* **The board owns its horizontal scroll.** Columns overflow sideways INSIDE the
|
|
118
|
+
* board; the page body never scrolls horizontally, which is what makes the shape
|
|
119
|
+
* usable at 375 rather than a desktop-only pattern.
|
|
120
|
+
*
|
|
121
|
+
* **Columns grow; the page scrolls.** A board with independently scrolling columns
|
|
122
|
+
* needs a bounded height, and a height nobody can derive gets hand-picked — that is
|
|
123
|
+
* how `height: 560` ends up in an app. So a column is as tall as its cards and the
|
|
124
|
+
* page's own scroller carries the overflow.
|
|
125
|
+
*
|
|
126
|
+
* ```tsx
|
|
127
|
+
* <Board>
|
|
128
|
+
* {stages.map((s) => (
|
|
129
|
+
* <BoardColumn key={s.key} heading={<OptionBadge variant="dot" value={s} />} count={s.items.length}>
|
|
130
|
+
* {s.items.map((t) => (
|
|
131
|
+
* <BoardCard
|
|
132
|
+
* key={t.id} id={t.id} title={t.title}
|
|
133
|
+
* onPress={() => open(t.id)}
|
|
134
|
+
* openLabel={`Mở ${t.title}`} moveLabel={`Chuyển ${t.title}`}
|
|
135
|
+
* moves={destinations(s.key)} onMove={(to) => setStage(t.id, to)}
|
|
136
|
+
* />
|
|
137
|
+
* ))}
|
|
138
|
+
* </BoardColumn>
|
|
139
|
+
* ))}
|
|
140
|
+
* </Board>
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
export function Board(props: BoardProps) {
|
|
144
|
+
const { children } = props;
|
|
145
|
+
|
|
146
|
+
const columns = useRef(new Map<string, HTMLElement>());
|
|
147
|
+
const cards = useRef(new Map<string, BoardCardEntry>());
|
|
148
|
+
const [overColumn, setOverColumn] = useState<string | null>(null);
|
|
149
|
+
|
|
150
|
+
const [columnHeadings, setColumnHeadings] = useState<Record<string, ReactNode>>({});
|
|
151
|
+
const publishHeading = useCallback((key: string, heading: ReactNode) => {
|
|
152
|
+
setColumnHeadings((prev) => (prev[key] === heading ? prev : { ...prev, [key]: heading }));
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
const registerColumn = useCallback((key: string, node: unknown) => {
|
|
156
|
+
const el = node as HTMLElement | null;
|
|
157
|
+
if (el) columns.current.set(key, el);
|
|
158
|
+
else columns.current.delete(key);
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
const registerCard = useCallback((id: string, entry: BoardCardEntry | null) => {
|
|
162
|
+
if (entry) cards.current.set(id, entry);
|
|
163
|
+
else cards.current.delete(id);
|
|
164
|
+
}, []);
|
|
165
|
+
|
|
166
|
+
/** Which column's box the pointer is inside — the only geometry this shape needs. */
|
|
167
|
+
const columnAt = useCallback((x: number, y: number): string | null => {
|
|
168
|
+
for (const [key, el] of columns.current) {
|
|
169
|
+
const r = el.getBoundingClientRect();
|
|
170
|
+
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return key;
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}, []);
|
|
174
|
+
|
|
175
|
+
/** A drop ends with a click on the row the grip sits in, and that row opens the
|
|
176
|
+
* record — so a drag would dump the reader into a drawer they never asked for.
|
|
177
|
+
* The same shape PressableRow already guards for text selection. */
|
|
178
|
+
const justDragged = useRef(false);
|
|
179
|
+
const { live, bind } = usePointerDrag((id, pointer) => {
|
|
180
|
+
setOverColumn(null);
|
|
181
|
+
justDragged.current = true;
|
|
182
|
+
setTimeout(() => {
|
|
183
|
+
justDragged.current = false;
|
|
184
|
+
}, 0);
|
|
185
|
+
const target = columnAt(pointer.x, pointer.y);
|
|
186
|
+
const card = cards.current.get(id);
|
|
187
|
+
if (!target || !card) return;
|
|
188
|
+
// The drop is resolved against the card's OWN declared destinations, the same
|
|
189
|
+
// list the menu renders. A column the keyboard path refuses is refused here too.
|
|
190
|
+
const move = card.moves.find((m) => m.key === target);
|
|
191
|
+
if (!move || move.disabled) return;
|
|
192
|
+
card.onMove(target);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// The live drop target. `usePointerDrag` reports the delta, not the pointer, so the
|
|
196
|
+
// highlight needs its own listener — mounted only while a drag is in flight.
|
|
197
|
+
//
|
|
198
|
+
// Keyed on WHETHER a drag is running, never on `live` itself. `live` is a fresh
|
|
199
|
+
// object every pointermove, so depending on it re-ran this effect on every move and
|
|
200
|
+
// its cleanup cleared `overColumn` immediately after the listener had set it: the
|
|
201
|
+
// drop still landed (the drop reads the pointer directly) and the highlight simply
|
|
202
|
+
// never appeared, which is the kind of defect only a rendered drag shows.
|
|
203
|
+
const dragging = live != null;
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (Platform.OS !== "web" || !dragging) return;
|
|
206
|
+
const move = (e: PointerEvent) => setOverColumn(columnAt(e.clientX, e.clientY));
|
|
207
|
+
window.addEventListener("pointermove", move);
|
|
208
|
+
return () => {
|
|
209
|
+
window.removeEventListener("pointermove", move);
|
|
210
|
+
setOverColumn(null);
|
|
211
|
+
};
|
|
212
|
+
}, [dragging, columnAt]);
|
|
213
|
+
|
|
214
|
+
const ctx = useMemo<BoardCtx>(
|
|
215
|
+
() => ({
|
|
216
|
+
registerColumn,
|
|
217
|
+
columnHeadings,
|
|
218
|
+
publishHeading,
|
|
219
|
+
registerCard,
|
|
220
|
+
bindDrag: (id: string) => bind(id, "grab"),
|
|
221
|
+
justDragged,
|
|
222
|
+
draggingId: live?.id ?? null,
|
|
223
|
+
dragDelta: live ? { dx: live.dx, dy: live.dy } : null,
|
|
224
|
+
overColumn,
|
|
225
|
+
}),
|
|
226
|
+
[registerColumn, columnHeadings, publishHeading, registerCard, bind, live, overColumn],
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<BoardContext.Provider value={ctx}>
|
|
231
|
+
<ScrollView
|
|
232
|
+
horizontal
|
|
233
|
+
showsHorizontalScrollIndicator
|
|
234
|
+
contentContainerStyle={styles.track}
|
|
235
|
+
>
|
|
236
|
+
{children}
|
|
237
|
+
</ScrollView>
|
|
238
|
+
</BoardContext.Provider>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface BoardColumnProps {
|
|
243
|
+
/** The value this column IS, drawn by the component its data role owns — an
|
|
244
|
+
* `OptionBadge` for a select, a `MemberChip` for a person, a plain `Text`
|
|
245
|
+
* otherwise. The board never renders the identity itself, so a column heading and
|
|
246
|
+
* the same value in a cell cannot look like two different things. */
|
|
247
|
+
heading: ReactNode;
|
|
248
|
+
/** The column's key — what a `BoardMove` names and what a drop resolves to. */
|
|
249
|
+
columnKey: string;
|
|
250
|
+
/**
|
|
251
|
+
* How many cards are in this pile — and what decides whether the column shows its
|
|
252
|
+
* cards or its empty state, so the number beside the heading and the pile under it
|
|
253
|
+
* are one statement rather than two that can disagree.
|
|
254
|
+
*
|
|
255
|
+
* A PROP, never formatted into `heading`: the count is the board's own report and
|
|
256
|
+
* it changes with every filter, so folding it into the identity makes the name of
|
|
257
|
+
* the column and its size one string that a screen reader reads as a name and that
|
|
258
|
+
* no caller can restyle. It also guarantees the number beside a heading is the
|
|
259
|
+
* number of cards under it.
|
|
260
|
+
*/
|
|
261
|
+
count: number;
|
|
262
|
+
/** This column's own act, on the heading row's right edge — an Add that pre-sets
|
|
263
|
+
* the column's value. Renders whether or not the column has cards. */
|
|
264
|
+
action?: ReactNode;
|
|
265
|
+
/** What to say when the column is empty. Falls back to the locale's own line. */
|
|
266
|
+
emptyMessage?: string;
|
|
267
|
+
/** The `BoardCard`s. */
|
|
268
|
+
children?: ReactNode;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** One pile — a heading that states its value and its size, and the cards under it. */
|
|
272
|
+
export function BoardColumn(props: BoardColumnProps) {
|
|
273
|
+
const { heading, columnKey, count, action, emptyMessage, children } = props;
|
|
274
|
+
const board = useContext(BoardContext);
|
|
275
|
+
const locale = useLoticsLocale().board;
|
|
276
|
+
const register = board?.registerColumn;
|
|
277
|
+
|
|
278
|
+
const ref = useCallback(
|
|
279
|
+
(node: unknown) => register?.(columnKey, node),
|
|
280
|
+
[register, columnKey],
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const publish = board?.publishHeading;
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
publish?.(columnKey, heading);
|
|
286
|
+
}, [publish, columnKey, heading]);
|
|
287
|
+
|
|
288
|
+
const over = board?.draggingId != null && board.overColumn === columnKey;
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<View ref={ref} style={[styles.column, over && styles.columnOver]}>
|
|
292
|
+
<View style={styles.columnHeading}>
|
|
293
|
+
<View style={styles.columnIdentity}>{heading}</View>
|
|
294
|
+
<Text size="sm" color="zinc-500" tabular>
|
|
295
|
+
{count}
|
|
296
|
+
</Text>
|
|
297
|
+
<View style={styles.columnAction}>{action}</View>
|
|
298
|
+
</View>
|
|
299
|
+
<View style={styles.columnBody}>
|
|
300
|
+
{count === 0 ? (
|
|
301
|
+
<EmptyState message={emptyMessage ?? locale.empty} />
|
|
302
|
+
) : (
|
|
303
|
+
<BoardColumnContext.Provider value={heading}>{children}</BoardColumnContext.Provider>
|
|
304
|
+
)}
|
|
305
|
+
</View>
|
|
306
|
+
</View>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export interface BoardCardProps {
|
|
311
|
+
/** The record's id — what `onMove` is about, and what a drag carries. */
|
|
312
|
+
id: string;
|
|
313
|
+
/** The record's identity. Rendered as the card's own first line. */
|
|
314
|
+
title: string;
|
|
315
|
+
/** The facts under the title — a member, a date, a badge. Keep it to what the
|
|
316
|
+
* reader sorts the pile by; everything else is what opening the card is for. */
|
|
317
|
+
children?: ReactNode;
|
|
318
|
+
/** Opens the record. */
|
|
319
|
+
onPress: () => void;
|
|
320
|
+
/** The keyboard door's accessible name — name the record ("Open ORD-1042").
|
|
321
|
+
* Falls back to the locale's "Open <title>". */
|
|
322
|
+
openLabel?: string;
|
|
323
|
+
/** This record is the one currently open. */
|
|
324
|
+
selected?: boolean;
|
|
325
|
+
/** Where this card may go — every column but its own. Empty or omitted and the
|
|
326
|
+
* card carries no move control at all, which is the honest rendering of a card
|
|
327
|
+
* that cannot be moved. */
|
|
328
|
+
moves?: BoardMove[];
|
|
329
|
+
/** Called with the destination `columnKey`, by the menu AND by a pointer drop. */
|
|
330
|
+
onMove?: (columnKey: string) => void;
|
|
331
|
+
/** The move control's accessible name — name the record ("Move ORD-1042").
|
|
332
|
+
* Falls back to the locale's "Move <title>". */
|
|
333
|
+
moveLabel?: string;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* One record on the board: a press that opens it, and the control that moves it.
|
|
338
|
+
*
|
|
339
|
+
* The card is a `PressableRow` + `PressDoor` pair, not a button wrapping its content
|
|
340
|
+
* — it carries its own controls, and a button must not contain a button.
|
|
341
|
+
*/
|
|
342
|
+
export function BoardCard(props: BoardCardProps) {
|
|
343
|
+
const {
|
|
344
|
+
id,
|
|
345
|
+
title,
|
|
346
|
+
children,
|
|
347
|
+
onPress,
|
|
348
|
+
openLabel,
|
|
349
|
+
selected = false,
|
|
350
|
+
moves,
|
|
351
|
+
onMove,
|
|
352
|
+
moveLabel,
|
|
353
|
+
} = props;
|
|
354
|
+
const board = useContext(BoardContext);
|
|
355
|
+
const locale = useLoticsLocale().board;
|
|
356
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
357
|
+
const [hovered, setHovered] = useState(false);
|
|
358
|
+
const openCard = useCallback(() => {
|
|
359
|
+
if (board?.justDragged.current) return;
|
|
360
|
+
onPress();
|
|
361
|
+
}, [board, onPress]);
|
|
362
|
+
|
|
363
|
+
const canMove = moves != null && moves.length > 0 && onMove != null;
|
|
364
|
+
|
|
365
|
+
const registerCard = board?.registerCard;
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
if (!registerCard || !canMove) return;
|
|
368
|
+
registerCard(id, { moves: moves!, onMove: onMove! });
|
|
369
|
+
return () => registerCard(id, null);
|
|
370
|
+
}, [registerCard, canMove, id, moves, onMove]);
|
|
371
|
+
|
|
372
|
+
const dragging = board?.draggingId === id;
|
|
373
|
+
const delta = dragging ? board?.dragDelta : null;
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
/* PressableRow keeps its own hover and does not hand it back, so the mouse is
|
|
377
|
+
caught one level out — a card that never lit up under the pointer was the
|
|
378
|
+
row swallowing the events, not the wash being too faint. */
|
|
379
|
+
<View
|
|
380
|
+
{...({
|
|
381
|
+
onMouseEnter: () => setHovered(true),
|
|
382
|
+
onMouseLeave: () => setHovered(false),
|
|
383
|
+
} as object)}
|
|
384
|
+
>
|
|
385
|
+
<PressableRow
|
|
386
|
+
onPress={openCard}
|
|
387
|
+
selected={selected}
|
|
388
|
+
variant="inset"
|
|
389
|
+
style={[
|
|
390
|
+
styles.card,
|
|
391
|
+
/* One neutral step darker. PressableRow paints its own hover through the
|
|
392
|
+
same property, so the card's resting wash silently overrode it and the
|
|
393
|
+
row that knows how to light up went quiet. */
|
|
394
|
+
hovered && styles.cardHovered,
|
|
395
|
+
dragging && styles.cardDragging,
|
|
396
|
+
delta ? { transform: [{ translateX: delta.dx }, { translateY: delta.dy }] } : null,
|
|
397
|
+
]}
|
|
398
|
+
>
|
|
399
|
+
<PressDoor
|
|
400
|
+
onPress={openCard}
|
|
401
|
+
accessibilityLabel={openLabel ?? locale.open(title)}
|
|
402
|
+
radius={CARD_RADIUS}
|
|
403
|
+
/>
|
|
404
|
+
<View style={styles.cardBody}>
|
|
405
|
+
<View style={styles.cardTitleRow}>
|
|
406
|
+
<Text size="sm" weight="medium" numberOfLines={3} style={styles.cardTitle}>
|
|
407
|
+
{title}
|
|
408
|
+
</Text>
|
|
409
|
+
{canMove ? (
|
|
410
|
+
<View style={styles.cardControls}>
|
|
411
|
+
{/* The grip is the POINTER path and nothing else: it is not a tab stop
|
|
412
|
+
and not in the a11y tree, because the menu beside it performs the
|
|
413
|
+
same act and is reachable by every input. An affordance that only a
|
|
414
|
+
pointer can use must not advertise itself to a keyboard. */}
|
|
415
|
+
<Popover open={menuOpen} onOpenChange={setMenuOpen} side="bottom" align="end">
|
|
416
|
+
<PopoverTrigger>
|
|
417
|
+
<IconButton
|
|
418
|
+
icon="arrow-right-left"
|
|
419
|
+
size="sm"
|
|
420
|
+
accessibilityLabel={moveLabel ?? locale.move(title)}
|
|
421
|
+
/>
|
|
422
|
+
</PopoverTrigger>
|
|
423
|
+
<PopoverContent style={styles.menu} disableBodyScroll>
|
|
424
|
+
<View style={styles.menuList}>
|
|
425
|
+
{moves!.map((m) => (
|
|
426
|
+
<MenuButton
|
|
427
|
+
key={m.key}
|
|
428
|
+
/* The destination named in ITS OWN vocabulary — the dot
|
|
429
|
+
and colour the pile itself is titled with, taken from
|
|
430
|
+
that column rather than re-declared here, so the menu
|
|
431
|
+
and the board cannot describe one stage two ways. */
|
|
432
|
+
title={board?.columnHeadings[m.key] ?? m.label}
|
|
433
|
+
// Visibly the destination's own name — the menu is short and
|
|
434
|
+
// its trigger already says what it does. ANNOUNCED as the whole
|
|
435
|
+
// sentence, because a screen reader reads an item without the
|
|
436
|
+
// trigger beside it.
|
|
437
|
+
accessibilityLabel={locale.moveTo(m.label)}
|
|
438
|
+
disabled={m.disabled}
|
|
439
|
+
onPress={() => {
|
|
440
|
+
setMenuOpen(false);
|
|
441
|
+
onMove!(m.key);
|
|
442
|
+
}}
|
|
443
|
+
/>
|
|
444
|
+
))}
|
|
445
|
+
</View>
|
|
446
|
+
</PopoverContent>
|
|
447
|
+
</Popover>
|
|
448
|
+
{Platform.OS === "web" ? (
|
|
449
|
+
<View
|
|
450
|
+
ref={board?.bindDrag(id) ?? null}
|
|
451
|
+
aria-hidden
|
|
452
|
+
tabIndex={-1}
|
|
453
|
+
style={styles.grip}
|
|
454
|
+
>
|
|
455
|
+
<Icon name="grip-vertical" size={14} color={colors.zinc[400]} />
|
|
456
|
+
</View>
|
|
457
|
+
) : null}
|
|
458
|
+
</View>
|
|
459
|
+
) : null}
|
|
460
|
+
</View>
|
|
461
|
+
{children}
|
|
462
|
+
</View>
|
|
463
|
+
</PressableRow>
|
|
464
|
+
</View>
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const styles = StyleSheet.create({
|
|
469
|
+
// The columns sit on AIR, the way a `TableGroup` band separates itself — no rules,
|
|
470
|
+
// no per-column ground. `alignItems: flex-start` keeps each column as tall as its
|
|
471
|
+
// own cards instead of stretching every one to the tallest.
|
|
472
|
+
track: {
|
|
473
|
+
flexDirection: "row",
|
|
474
|
+
alignItems: "flex-start",
|
|
475
|
+
gap: SPACE.md,
|
|
476
|
+
// The track is padded on EVERY side, not just the bottom: a column paints a
|
|
477
|
+
// ring when it is the drop target, and a ring drawn at the scroller's own
|
|
478
|
+
// edge is clipped by it — so the one moment the board most needs to say
|
|
479
|
+
// "here" is the one where its signal is half missing. The padding is the
|
|
480
|
+
// room that ring needs to exist in.
|
|
481
|
+
padding: SPACE.xs,
|
|
482
|
+
paddingBottom: SPACE.md,
|
|
483
|
+
},
|
|
484
|
+
column: {
|
|
485
|
+
width: BOARD_COLUMN_WIDTH,
|
|
486
|
+
gap: SPACE.sm,
|
|
487
|
+
borderRadius: CONTROL_RADIUS,
|
|
488
|
+
borderWidth: 1,
|
|
489
|
+
borderColor: "transparent",
|
|
490
|
+
padding: SPACE.sm,
|
|
491
|
+
margin: -1,
|
|
492
|
+
},
|
|
493
|
+
// The drop target, in the kit's own attention language — the tint `FileDropTarget`
|
|
494
|
+
// paints, not a colour picked here.
|
|
495
|
+
columnOver: {
|
|
496
|
+
backgroundColor: colors.accent_wash,
|
|
497
|
+
borderColor: colors.accent,
|
|
498
|
+
},
|
|
499
|
+
columnHeading: {
|
|
500
|
+
flexDirection: "row",
|
|
501
|
+
alignItems: "center",
|
|
502
|
+
gap: SPACE.sm,
|
|
503
|
+
minHeight: 28,
|
|
504
|
+
},
|
|
505
|
+
columnIdentity: {
|
|
506
|
+
flexShrink: 1,
|
|
507
|
+
minWidth: 0,
|
|
508
|
+
},
|
|
509
|
+
// The column's act rides the right edge, and holds its slot whether or not there
|
|
510
|
+
// is one, so headings across the board keep one baseline.
|
|
511
|
+
columnAction: {
|
|
512
|
+
marginLeft: "auto",
|
|
513
|
+
},
|
|
514
|
+
// The gap INSIDE the column (between its cards) is half the gap BETWEEN columns.
|
|
515
|
+
columnBody: {
|
|
516
|
+
gap: SPACE.sm,
|
|
517
|
+
},
|
|
518
|
+
card: {
|
|
519
|
+
flexDirection: "column",
|
|
520
|
+
alignItems: "stretch",
|
|
521
|
+
// `PressableRow`'s inset variant bleeds its wash 8px each way, which is right on a
|
|
522
|
+
// register sitting on a page edge and wrong here: the card IS the object, and its
|
|
523
|
+
// border must land where its box does.
|
|
524
|
+
marginHorizontal: 0,
|
|
525
|
+
paddingHorizontal: SPACE.md,
|
|
526
|
+
paddingVertical: SPACE.md,
|
|
527
|
+
// A card HOLDS content, so it takes the container rung of the radius ladder
|
|
528
|
+
// (16), not CONTROL_RADIUS (10) — that one is for things you press directly.
|
|
529
|
+
// The whole card being pressable does not make it a control; a Card is
|
|
530
|
+
// pressable on plenty of surfaces and still reads as a container.
|
|
531
|
+
borderRadius: CARD_RADIUS,
|
|
532
|
+
// A transparent card on a white canvas is a BORDER, not an object. The wash
|
|
533
|
+
// gives it a body — which is what the eye follows while it is being dragged,
|
|
534
|
+
// and what tells a full column from an empty one at a glance. It also does
|
|
535
|
+
// the whole job on its own: a border on top would be a second assertion of
|
|
536
|
+
// the same edge, on a surface already full of vertical rules — the columns.
|
|
537
|
+
backgroundColor: tint("zinc", 0.05),
|
|
538
|
+
minWidth: 0,
|
|
539
|
+
},
|
|
540
|
+
cardHovered: { backgroundColor: tint("zinc", 0.1) },
|
|
541
|
+
cardDragging: {
|
|
542
|
+
opacity: 0.9,
|
|
543
|
+
zIndex: 2,
|
|
544
|
+
boxShadow: colors.shadow,
|
|
545
|
+
},
|
|
546
|
+
cardBody: {
|
|
547
|
+
zIndex: 1,
|
|
548
|
+
// The title, the owner and the date are three separate FACTS, not one block
|
|
549
|
+
// of text. At the tighter step they read as a paragraph; at this one the eye
|
|
550
|
+
// takes them one at a time — and it stays below the gap BETWEEN cards, so
|
|
551
|
+
// within-card is still tighter than between-card.
|
|
552
|
+
gap: SPACE.sm,
|
|
553
|
+
minWidth: 0,
|
|
554
|
+
width: "100%",
|
|
555
|
+
},
|
|
556
|
+
cardTitleRow: {
|
|
557
|
+
flexDirection: "row",
|
|
558
|
+
alignItems: "flex-start",
|
|
559
|
+
gap: SPACE.xs,
|
|
560
|
+
minWidth: 0,
|
|
561
|
+
},
|
|
562
|
+
cardTitle: {
|
|
563
|
+
flex: 1,
|
|
564
|
+
minWidth: 0,
|
|
565
|
+
// The room the pinned controls occupy. Without it a long title runs under
|
|
566
|
+
// them and the last word is unreadable — absolute positioning takes an
|
|
567
|
+
// element out of the flow, so the flow has to be told it is still there.
|
|
568
|
+
paddingRight: 56,
|
|
569
|
+
},
|
|
570
|
+
cardControls: {
|
|
571
|
+
// Pinned to the card's own corner, not carried by the title row: a title that
|
|
572
|
+
// wraps to two lines would otherwise push the controls down, and a control
|
|
573
|
+
// that moves with the length of the text beside it is one the reader has to
|
|
574
|
+
// find again on every card.
|
|
575
|
+
position: "absolute",
|
|
576
|
+
// Measured from the card's CONTENT box, which already begins one padding in
|
|
577
|
+
// — so the offset here is zero, not another padding. Setting SPACE.md again
|
|
578
|
+
// put the cluster twice as far down as the title it lines up with.
|
|
579
|
+
top: 0,
|
|
580
|
+
// A control's BOX carries its hit area, and its glyph sits inset within it.
|
|
581
|
+
// Align the boxes and the INK lands further in than the title's — five
|
|
582
|
+
// pixels here, which reads as a lopsided card and is invisible until
|
|
583
|
+
// measured. Pull the cluster out by that inset so the glyph shares the
|
|
584
|
+
// title's edge; the hit target simply reaches nearer the corner, which
|
|
585
|
+
// costs nothing.
|
|
586
|
+
right: -CONTROL_INK_INSET,
|
|
587
|
+
zIndex: 2,
|
|
588
|
+
flexDirection: "row",
|
|
589
|
+
alignItems: "center",
|
|
590
|
+
gap: 2,
|
|
591
|
+
flexShrink: 0,
|
|
592
|
+
},
|
|
593
|
+
// `usePointerDrag`'s bind sets this node's `cursor: grab` and `touch-action: none`
|
|
594
|
+
// on the DOM element itself — restating them here would be a second owner of the
|
|
595
|
+
// same two properties, and RN's `CursorValue` has no `grab` to state them with.
|
|
596
|
+
grip: {
|
|
597
|
+
// The SAME box as the sm IconButton beside it. Two control sizes in one
|
|
598
|
+
// cluster leave their glyphs on two different edges, and then the inset that
|
|
599
|
+
// aligns the cluster's ink is right for one of them and wrong for the other.
|
|
600
|
+
width: 24,
|
|
601
|
+
height: 24,
|
|
602
|
+
alignItems: "center",
|
|
603
|
+
justifyContent: "center",
|
|
604
|
+
},
|
|
605
|
+
menu: {
|
|
606
|
+
minWidth: MIN_CONTROL_WIDTH + SPACE.md,
|
|
607
|
+
},
|
|
608
|
+
menuList: {
|
|
609
|
+
gap: 2,
|
|
610
|
+
},
|
|
611
|
+
});
|
package/src/card.tsx
CHANGED
|
@@ -169,10 +169,16 @@ const styles = StyleSheet.create({
|
|
|
169
169
|
paddingVertical: 14,
|
|
170
170
|
flexDirection: "row",
|
|
171
171
|
alignItems: "center",
|
|
172
|
-
|
|
172
|
+
flexWrap: "wrap",
|
|
173
|
+
rowGap: 4,
|
|
174
|
+
columnGap: 12,
|
|
173
175
|
},
|
|
174
176
|
headerTitle: {
|
|
175
177
|
flex: 1,
|
|
178
|
+
// flex:1 một mình KHÔNG co lại dưới nội dung: bề rộng tối thiểu mặc định là
|
|
179
|
+
// "vừa đủ chứa", nên một tiêu đề dài đẩy hàng rộng hơn cả thẻ. minWidth:0
|
|
180
|
+
// cho phép nó nhường, còn flexWrap ở trên là lối thoát khi đã hết chỗ.
|
|
181
|
+
minWidth: 0,
|
|
176
182
|
gap: 2,
|
|
177
183
|
},
|
|
178
184
|
headerTitleRow: {
|