@lotics/ui 23.1.1 → 24.0.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/src/table.tsx CHANGED
@@ -164,6 +164,27 @@ export type TableRowProps = {
164
164
  action?: ReactNode;
165
165
  /** Min row height (register mode; stacked rows size to their content). Default 52. */
166
166
  minHeight?: number;
167
+ /**
168
+ * The row's detail, revealed BENEATH it when `expanded` — the alternative to
169
+ * sending the reader somewhere else to see it.
170
+ *
171
+ * A register answers "which one", and the moment it cannot answer "and what
172
+ * about it" the reader is pushed into a drawer, loses the surrounding rows,
173
+ * and pays a navigation to come back — which is what makes scanning twenty
174
+ * records unaffordable. Expanding in place keeps the answer next to the
175
+ * question. Reach for it when the detail is READ or light-edit; a heavy form
176
+ * still deserves its own surface.
177
+ *
178
+ * Outside the pressable, so a control inside the detail is not swallowed by
179
+ * the row's own press.
180
+ */
181
+ detail?: ReactNode;
182
+ /**
183
+ * Whether `detail` is showing. CONTROLLED — the caller owns it, because who
184
+ * may be open at once is the caller's rule (one at a time, several, or one
185
+ * per group) and a component-local default would silently pick one.
186
+ */
187
+ expanded?: boolean;
167
188
  /** The `TableCell`s, one per column, in column order. */
168
189
  children: ReactNode;
169
190
  } & (
@@ -198,9 +219,10 @@ export type TableRowProps = {
198
219
  * slots, different geometry.
199
220
  */
200
221
  export function TableRow(props: TableRowProps) {
201
- const { onPress, selected, marked, accessibilityLabel, leading, trailing, action, minHeight = 52, children } = props;
222
+ const { onPress, selected, marked, accessibilityLabel, leading, trailing, action, minHeight = 52, detail, expanded, children } = props;
202
223
  const ctx = useContext(TableContext);
203
224
  if (!ctx) throw new Error("TableRow must be used within a Table");
225
+ const showDetail = detail != null && expanded === true;
204
226
 
205
227
  const cells = (Children.toArray(children).filter(isValidElement) as ReactElement<TableCellProps>[])
206
228
  .map((cell, i) => ({ cell, column: ctx.columns[i] as TableColumn | undefined }))
@@ -249,13 +271,13 @@ export function TableRow(props: TableRowProps) {
249
271
  );
250
272
  }
251
273
 
252
- return (
253
- <PressableRow onPress={onPress} selected={selected} marked={marked} style={styles.row}>
274
+ const rowSurface = (
275
+ <PressableRow onPress={onPress} selected={selected || showDetail} marked={marked} style={styles.row}>
254
276
  {!ctx.stacked && ctx.leading > 0 ? <View style={[styles.slot, { width: ctx.leading }]}>{leading}</View> : null}
255
277
  {/* The door hit-tests above in-flow content; the cells/slots lift back above it
256
278
  via zIndex 1 (see `PressDoor`), so the door gets only the keyboard. Radius
257
279
  matches the register wash. */}
258
- <PressDoor onPress={onPress} accessibilityLabel={accessibilityLabel} />
280
+ <PressDoor onPress={onPress} accessibilityLabel={accessibilityLabel} expanded={detail != null ? showDetail : undefined} />
259
281
  {ctx.stacked ? (
260
282
  body
261
283
  ) : (
@@ -271,6 +293,19 @@ export function TableRow(props: TableRowProps) {
271
293
  )}
272
294
  </PressableRow>
273
295
  );
296
+
297
+ if (!showDetail) return rowSurface;
298
+
299
+ // The detail is a SIBLING of the pressable, never inside it: the row's press
300
+ // surface spans its own children, so a control nested in the detail would be
301
+ // swallowed by the toggle — and a `<button>` inside the door is invalid HTML
302
+ // for the same reason the cells sit outside it.
303
+ return (
304
+ <View>
305
+ {rowSurface}
306
+ <View style={styles.detail}>{detail}</View>
307
+ </View>
308
+ );
274
309
  }
275
310
 
276
311
  export interface TableCellProps {
@@ -301,6 +336,23 @@ export function TableCell(props: TableCellProps) {
301
336
  }
302
337
 
303
338
  const styles = StyleSheet.create({
339
+ // The expanded detail. Indented to the identity column's text edge so it reads
340
+ // as belonging to the row above rather than as a new row, and given the row's
341
+ // own gutter so its content lines up with the cells it explains.
342
+ //
343
+ // Vertical padding is measured from the row's WASH, not its text: an expanded
344
+ // row wears the `selected` highlight, so the band's edge is the hard line the
345
+ // eye reads. With no top padding the detail's first control sat 3px off that
346
+ // edge while the bottom had a designed 16 — visibly cramped for an editor
347
+ // detail. Matching 16 both ends frames the detail evenly inside the band, and
348
+ // equals a `DetailTable`'s own row gap so a field grid keeps one rhythm from
349
+ // the row above it to the hairline below.
350
+ detail: {
351
+ paddingLeft: ROW_GUTTER,
352
+ paddingRight: ROW_GUTTER,
353
+ paddingTop: 16,
354
+ paddingBottom: 16,
355
+ },
304
356
  // A hairline under the column header anchors the columns; the rows below it are
305
357
  // Divider-separated.
306
358
  headerBand: {
@@ -0,0 +1,139 @@
1
+ import { type ReactNode } from "react";
2
+ import { StyleSheet } from "react-native";
3
+ import { PressableHighlight } from "./pressable_highlight";
4
+ import { Text } from "./text";
5
+
6
+ /** Two only. Deliberately NOT the whole `TextColor` palette: `muted` would mute the
7
+ * one thing carrying the act's weight, and the rest (warning/success/inverted) name
8
+ * states, not verbs. Reach for `Button color="muted"` when an act must be quieter. */
9
+ export type TextButtonColor = "default" | "danger";
10
+
11
+ export interface TextButtonProps {
12
+ /** The verb, as words. */
13
+ children: ReactNode;
14
+ /** The act. REQUIRED — a text button with nothing to do is just `Text`. */
15
+ onPress: () => void;
16
+ /** `default` = neutral ink; `danger` = destructive. */
17
+ color?: TextButtonColor;
18
+ /**
19
+ * Nothing to act on. Pass it UNCONDITIONALLY and disable it rather than
20
+ * rendering the verb only once its target exists — an action that appears and
21
+ * disappears reflows the line it sits in, which is the one thing a control in
22
+ * a row of text must never do.
23
+ */
24
+ disabled?: boolean;
25
+ /** Announced name, when the words alone are ambiguous ("Open" → "Open customer"). */
26
+ accessibilityLabel?: string;
27
+ /** Truncate at this many lines (a label in a narrow row). */
28
+ numberOfLines?: number;
29
+ tooltip?: string;
30
+ }
31
+
32
+ /**
33
+ * A low-chrome ACTION rendered at text weight — "Select all", "Clear",
34
+ * "Add stop". The third member of the action family, by CHROME:
35
+ * `Button` (a 40px control with a surface) → `InlineButton` (28px, filled, living
36
+ * on a field's own surface) → this (no surface AT REST, sitting in a line of text).
37
+ *
38
+ * UNDERLINED, in NEUTRAL ink — what Airbnb ships for a text action, and the shape
39
+ * that keeps this kit's own colour rule intact:
40
+ *
41
+ * underline = INTERACTIVE blue = NAVIGATION
42
+ * `Link`/`TextLink` blue + underline → navigates
43
+ * `TextButton` zinc + underline → acts
44
+ *
45
+ * The underline is the affordance and it is not optional. With no surface and no
46
+ * tint, nothing else says at rest that the words can be pressed: POSITION is a
47
+ * learned convention rather than an affordance (invisible to a first-time reader),
48
+ * and hover is not one either — absent on touch, and revealed only once you are
49
+ * already there. A tint is the other way to say it, the one Material and HIG take,
50
+ * but blue is spoken for here and a second accent for "actionable" would collide
51
+ * with the one-accent-per-purpose rule.
52
+ *
53
+ * This is also why it holds INSIDE a run of prose, where colour alone could not
54
+ * distinguish an embedded control at all (WCAG 1.4.1 / F73).
55
+ *
56
+ * Built on `PressableHighlight` rather than a pressable `Text`, because a `Text`
57
+ * with `onPress` gets NEITHER hover nor a focus ring: it renders a real
58
+ * `<button>` with `tabIndex=0`, `outline: none` and no box-shadow, so a keyboard
59
+ * user lands on it with no indication they have. A focusable control with no focus
60
+ * treatment is a bug, and hand-rolling hover + focus onto `Text` would duplicate
61
+ * machinery this already owns. The inherited hover wash is Material's state layer
62
+ * by another name, and it costs nothing at rest, so the chrome ladder above holds.
63
+ *
64
+ * The padding/negative-margin bleed is `Peek`'s: the wash and the focus ring need
65
+ * room off the glyphs, and the margins give it back so the line's layout never
66
+ * shifts — `OptionList` depends on that, insetting 8px so its select-all verbs
67
+ * line up under the option labels above.
68
+ *
69
+ * ONE SIZE (`Text`'s own `sm`) and no icon: it sits in a line of text and matches
70
+ * it. A verb needing an icon or a real hit target is a `Button`.
71
+ *
72
+ * It is TEXT-height, not control-height, and that is deliberate: `marginVertical`
73
+ * absorbs the touch box so the height IN FLOW stays 24 and dropping one into a
74
+ * dense band (`OptionList`'s select-all, `FilterChip`'s Clear) cannot grow that
75
+ * band. Do NOT match `Button`'s 40 to make a mixed row line up — that inflates
76
+ * every inline use and collapses the chrome ladder above, turning this into a
77
+ * Button without a fill. A row that mixes rungs aligns them the way such a row
78
+ * should anyway: `alignItems: "center"`, which lands a centred 32px box's text on
79
+ * the same line as a centred 40px one (measured: 0px apart in a peek footer).
80
+ *
81
+ * It sets NO `alignSelf`, so it obeys its parent — which is what a row wants, and
82
+ * a baked `flex-start` top-aligned it against a taller sibling and silently
83
+ * overrode a footer's `alignItems: center`. In a COLUMN container a `Pressable`
84
+ * stretches, so the hover wash would run the full width for a two-word verb: wrap
85
+ * it in a `flexDirection: "row"` View there, the same thing a `Button` needs.
86
+ */
87
+ export function TextButton(props: TextButtonProps) {
88
+ const { children, onPress, color = "default", disabled, accessibilityLabel, numberOfLines, tooltip } = props;
89
+ // `TextColor` tokens throughout, no raw palette access: neutral ink IS `default`,
90
+ // and `zinc-400` is `Button`'s own disabled ink — an inert control should read the
91
+ // same whatever its chrome, where muting to zinc-600 left it looking merely quiet.
92
+ const ink = disabled === true ? "zinc-400" : color === "danger" ? "danger" : "default";
93
+ return (
94
+ <PressableHighlight
95
+ focusRing
96
+ accessibilityRole="button"
97
+ accessibilityLabel={accessibilityLabel}
98
+ aria-disabled={disabled === true ? true : undefined}
99
+ disabled={disabled}
100
+ tooltip={tooltip}
101
+ // 32px box + 4px slop = the 40px target `Peek` keeps for the same shape. A
102
+ // text action measured 24px on its own, which clears WCAG 2.5.8's 24×24
103
+ // floor and nothing else — and it sits in dense rows, exactly where a thumb
104
+ // needs the margin most.
105
+ hitSlop={4}
106
+ onPress={onPress}
107
+ // After the inherited wash, so a disabled verb does not light up under the
108
+ // pointer — `hovered` still fires on a disabled Pressable.
109
+ style={[styles.trigger, disabled === true ? styles.dead : null]}
110
+ >
111
+ <Text decoration="underline" weight="medium" color={ink} numberOfLines={numberOfLines}>
112
+ {children}
113
+ </Text>
114
+ </PressableHighlight>
115
+ );
116
+ }
117
+
118
+ const styles = StyleSheet.create({
119
+ trigger: {
120
+ flexDirection: "row",
121
+ alignItems: "center",
122
+ // NO `alignSelf` here — see the prop. A baked `flex-start` is a CROSS-axis
123
+ // instruction, so it means "hug the words" only in a column; in a row it means
124
+ // top-align, and it silently overrode a footer's `alignItems: center`.
125
+ borderRadius: 6,
126
+ // Room for the wash + focus ring, given straight back as margin so adding a
127
+ // TextButton to a line cannot move anything around it (`Peek`'s idiom). The
128
+ // 32px box carries the touch target; `marginVertical` absorbs 8 of it, so the
129
+ // height IN FLOW stays 24 and a row's rhythm is unchanged.
130
+ minHeight: 32,
131
+ marginVertical: -4,
132
+ // 6 rather than `Peek`'s 8: these come in GROUPS (OptionList's select-all /
133
+ // deselect-all sit 16 apart), and an 8px bleed each side would leave adjacent
134
+ // washes touching.
135
+ paddingHorizontal: 6,
136
+ marginHorizontal: -6,
137
+ },
138
+ dead: { backgroundColor: "transparent" },
139
+ });
package/src/text_link.tsx CHANGED
@@ -1,39 +1,49 @@
1
1
  import { Text, type TextProps } from "./text";
2
2
  import { Icon, type IconName } from "./icon";
3
+ import { colors } from "./colors";
3
4
  import { getTextColor } from "./text_utils";
4
5
 
5
6
  export interface TextLinkProps extends TextProps {
6
7
  /** Optional leading icon. */
7
8
  icon?: IconName;
8
9
  /** A URL — renders a real anchor (`<a href>` on web; middle-click / open-in-new-tab
9
- * work). For in-app routing, wrap `TextLink` or use `onPress`. */
10
+ * work). For in-app routing, wrap `TextLink` in your own pressable. */
10
11
  href?: string;
11
- /** An in-app action. With NEITHER `href` nor `onPress`, `TextLink` is just
12
- * underlined text you wrap in your own pressable (e.g. a table cell). */
13
- onPress?: () => void;
14
12
  }
15
13
 
16
14
  /**
17
- * Underlined text that is OPTIONALLY a link (`href` a web anchor) or an action
18
- * (`onPress` pressable); with neither it's plain underlined text you drop inside
19
- * your own pressable (a table cell, a custom row). Colour comes from `color` (it
20
- * inherits every `Text` prop) — the neutral, configurable counterpart to `Link`,
21
- * which is fixed blue + `role="link"` for the "this opens somewhere else" signal.
15
+ * Underlined text that NAVIGATES `href` renders a real anchor; with no `href`
16
+ * it is plain underlined text you drop inside your own pressable (a table cell, a
17
+ * custom row), or a MARKER that a value leads somewhere (a record reference).
18
+ * Colour comes from `color` (it inherits every `Text` prop) — the neutral,
19
+ * configurable counterpart to `Link`, which is fixed blue + `role="link"` for the
20
+ * "this opens somewhere else" signal.
21
+ *
22
+ * It does NOT act. An `onPress` here used to resolve to `role="button"`, so one
23
+ * export was three components — a link, a button, and a passive marker — behind
24
+ * one name, inside `OptionList`, `FilterChip` and others. That mode is
25
+ * `TextButton` now — also underlined, since that is what marks a surface-less
26
+ * control interactive, but in ZINC: blue is reserved for navigation, so the ink is
27
+ * what says whether a press moves you or acts. Pick by what the press DOES.
22
28
  */
23
29
  export function TextLink(props: TextLinkProps) {
24
- const { icon, href, onPress, children, color, style, ...textProps } = props;
25
- const interactive = href != null || onPress != null;
30
+ const { icon, href, children, color, style, ...textProps } = props;
26
31
  return (
27
32
  <Text
28
33
  decoration="underline"
29
34
  weight="medium"
30
35
  color={color}
31
36
  href={href}
32
- onPress={onPress}
33
- accessibilityRole={href != null ? "link" : onPress != null ? "button" : undefined}
37
+ accessibilityRole={href != null ? "link" : undefined}
34
38
  style={[
35
39
  icon ? { display: "flex", flexDirection: "row", alignItems: "center", gap: 4 } : null,
36
- interactive ? { cursor: "pointer" } : null,
40
+ // Blue only when it actually NAVIGATES. An `href` is a destination, so it
41
+ // takes the navigation ink (`color` still overrides). Without one this is
42
+ // underlined text you wrap yourself, or a MARKER on a value that leads
43
+ // somewhere — the record's customer field is one, and pressing it opens a
44
+ // PEEK, so blue there would promise a trip it never takes.
45
+ href != null && color == null ? { color: colors.blue[600] } : null,
46
+ href != null ? { cursor: "pointer" } : null,
37
47
  style,
38
48
  ]}
39
49
  {...textProps}