@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.
Files changed (49) hide show
  1. package/AGENTS.md +38 -1
  2. package/MIGRATION.md +87 -0
  3. package/docs/ai_patterns.md +11 -0
  4. package/docs/catalog.md +217 -21
  5. package/docs/composition.md +74 -6
  6. package/docs/data_entry.md +56 -2
  7. package/docs/reviewing.md +34 -0
  8. package/docs/templates.md +93 -30
  9. package/docs/testing.md +6 -0
  10. package/examples/tpl_board.tsx +257 -0
  11. package/examples/tpl_money.tsx +1027 -0
  12. package/package.json +261 -258
  13. package/src/accordion.tsx +7 -1
  14. package/src/alert.css +0 -1
  15. package/src/alert.tsx +8 -0
  16. package/src/axis_label_indices.ts +84 -0
  17. package/src/bar_chart.tsx +137 -16
  18. package/src/board.tsx +611 -0
  19. package/src/card.tsx +7 -1
  20. package/src/charge_lines.tsx +373 -0
  21. package/src/chip_group.tsx +57 -1
  22. package/src/dialog.tsx +46 -24
  23. package/src/drawer.tsx +21 -2
  24. package/src/file_gallery_modal.tsx +3 -0
  25. package/src/file_row.tsx +98 -5
  26. package/src/icon.tsx +6 -0
  27. package/src/inline_edit.tsx +54 -10
  28. package/src/inline_number_input.tsx +5 -1
  29. package/src/inline_text_input.tsx +1 -1
  30. package/src/line_chart.tsx +2 -2
  31. package/src/locale.tsx +26 -1
  32. package/src/matrix.tsx +23 -8
  33. package/src/modal.tsx +23 -3
  34. package/src/overlay_layer.ts +65 -0
  35. package/src/page_content.tsx +8 -22
  36. package/src/page_header.tsx +60 -11
  37. package/src/popover.tsx +29 -5
  38. package/src/reference_field.tsx +36 -13
  39. package/src/skip_link.tsx +2 -1
  40. package/src/stacked_bar_chart.tsx +31 -1
  41. package/src/table.tsx +6 -1
  42. package/src/tabs.tsx +1 -1
  43. package/src/text.tsx +21 -0
  44. package/src/tooltip.tsx +2 -1
  45. package/src/use_change_set.ts +66 -17
  46. package/src/use_scroll_seam.ts +79 -0
  47. package/examples/tpl_report.tsx +0 -410
  48. package/examples/tpl_statements.tsx +0 -221
  49. 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
- gap: 12,
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: {