@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/AGENTS.md +13 -1
- package/MIGRATION.md +37 -0
- package/docs/catalog.md +69 -14
- package/docs/composition.md +63 -3
- package/docs/data_entry.md +19 -1
- package/docs/templates.md +24 -28
- package/examples/tpl_item_list.tsx +19 -768
- package/examples/tpl_record.tsx +515 -415
- package/examples/tpl_task_board.tsx +2 -2
- package/package.json +5 -2
- package/src/breakdown.tsx +3 -1
- package/src/choice_list.tsx +3 -1
- package/src/date_calendar.tsx +3 -1
- package/src/deadline.ts +157 -0
- package/src/filter_chip.tsx +2 -2
- package/src/funnel.tsx +3 -1
- package/src/heatmap.tsx +3 -1
- package/src/locale.tsx +16 -0
- package/src/matrix.tsx +3 -1
- package/src/option_list.tsx +3 -3
- package/src/press_door.tsx +12 -1
- package/src/reference_field.tsx +174 -0
- package/src/status_grid.tsx +3 -1
- package/src/table.tsx +56 -4
- package/src/text_button.tsx +139 -0
- package/src/text_link.tsx +24 -14
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
|
-
|
|
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`
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* inherits every `Text` prop) — the neutral,
|
|
21
|
-
* which is fixed blue + `role="link"` for the
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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}
|